余额告警独立页面与多渠道通知:钉钉/飞书/企业微信/Bark,支持通知频率策略

- 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道
- 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置
- 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送
- 优化告警话术:去商户ID展示、数字千分位格式化
This commit is contained in:
yml2213
2026-08-04 13:56:26 +08:00
parent 941e7ad79a
commit ca58c2bcaa
13 changed files with 1154 additions and 156 deletions
@@ -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;
@@ -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;
+108 -6
View File
@@ -4,6 +4,7 @@ import (
"strconv" "strconv"
"affiliate_dash/internal/middleware" "affiliate_dash/internal/middleware"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/response" "affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service" "affiliate_dash/internal/service"
@@ -113,9 +114,10 @@ func (h *RechargeHandler) GetAlertConfig(c *gin.Context) {
} }
type alertConfigReq struct { type alertConfigReq struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ThresholdPoints int64 `json:"threshold_points"` ThresholdPoints int64 `json:"threshold_points"`
WebhookURL string `json:"webhook_url"` NotifyIntervalMinutes int `json:"notify_interval_minutes"`
MaxNotifications int `json:"max_notifications"`
} }
// SaveAlertConfig PUT /api/merchant/recharge/alert-config // SaveAlertConfig PUT /api/merchant/recharge/alert-config
@@ -126,9 +128,10 @@ func (h *RechargeHandler) SaveAlertConfig(c *gin.Context) {
return return
} }
cfg, err := h.rechargeSvc.SaveAlertConfig(middleware.GetMerchantID(c), service.SaveAlertInput{ cfg, err := h.rechargeSvc.SaveAlertConfig(middleware.GetMerchantID(c), service.SaveAlertInput{
Enabled: req.Enabled, Enabled: req.Enabled,
ThresholdPoints: req.ThresholdPoints, ThresholdPoints: req.ThresholdPoints,
WebhookURL: req.WebhookURL, NotifyIntervalMinutes: req.NotifyIntervalMinutes,
MaxNotifications: req.MaxNotifications,
}) })
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
@@ -137,6 +140,105 @@ func (h *RechargeHandler) SaveAlertConfig(c *gin.Context) {
response.OK(c, cfg) 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)。 // Upload POST /api/upload 上传凭证图片(multipart 字段名 file)。
func (h *RechargeHandler) Upload(c *gin.Context) { func (h *RechargeHandler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("file") fileHeader, err := c.FormFile("file")
+40 -2
View File
@@ -34,7 +34,9 @@ type RechargeApplication struct {
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"` Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
} }
// MerchantAlertConfig 低余额 Webhook 告警配置(每商户一份)。 // MerchantAlertConfig 低余额告警配置(每商户一份)。
// 通知策略:余额低于阈值期间按 NotifyIntervalMinutes 间隔重复通知,
// 最多 MaxNotifications 次(0 表示不限);余额恢复阈值以上后重置计数。
type MerchantAlertConfig struct { type MerchantAlertConfig struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -43,5 +45,41 @@ type MerchantAlertConfig struct {
MerchantID uint `gorm:"not null;uniqueIndex" json:"merchant_id"` MerchantID uint `gorm:"not null;uniqueIndex" json:"merchant_id"`
Enabled bool `gorm:"not null;default:false" json:"enabled"` Enabled bool `gorm:"not null;default:false" json:"enabled"`
ThresholdPoints int64 `gorm:"not null;default:0" json:"threshold_points"` 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 类渠道用 WebhookURLBark 用 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"`
} }
+5
View File
@@ -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/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.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.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.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.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) merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus)
+234 -33
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -27,6 +28,9 @@ const (
RechargePointsPerCNYCent = 1 // 每 1 分人民币兑换积分 RechargePointsPerCNYCent = 1 // 每 1 分人民币兑换积分
) )
// timeNow 便于测试控制通知频率判断。
var timeNow = time.Now
type RechargeService struct { type RechargeService struct {
db *gorm.DB db *gorm.DB
fulfill *FulfillmentService fulfill *FulfillmentService
@@ -180,9 +184,10 @@ func (s *RechargeService) ReviewRecharge(in ReviewRechargeInput) (*model.Recharg
} }
type SaveAlertInput struct { type SaveAlertInput struct {
Enabled bool Enabled bool
ThresholdPoints int64 ThresholdPoints int64
WebhookURL string NotifyIntervalMinutes int
MaxNotifications int
} }
func (s *RechargeService) GetAlertConfig(merchantID uint) (*model.MerchantAlertConfig, error) { 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 { if in.ThresholdPoints < 0 {
return nil, errors.New("预警阈值不能为负") return nil, errors.New("预警阈值不能为负")
} }
if in.WebhookURL == "" || len(in.WebhookURL) > 1024 { if in.NotifyIntervalMinutes < 1 {
return nil, errors.New("请填写 Webhook URL") return nil, errors.New("通知间隔至少 1 分钟")
} }
if in.Enabled { if in.MaxNotifications < 0 {
if !strings.HasPrefix(in.WebhookURL, "http://") && !strings.HasPrefix(in.WebhookURL, "https://") { return nil, errors.New("最大通知次数不能为负")
return nil, errors.New("Webhook URL 必须以 http(s):// 开头")
}
} }
var cfg model.MerchantAlertConfig var cfg model.MerchantAlertConfig
err := s.db.Where("merchant_id = ?", merchantID).First(&cfg).Error err := s.db.Where("merchant_id = ?", merchantID).First(&cfg).Error
switch { switch {
case errors.Is(err, gorm.ErrRecordNotFound): case errors.Is(err, gorm.ErrRecordNotFound):
cfg = model.MerchantAlertConfig{MerchantID: merchantID, Enabled: in.Enabled, ThresholdPoints: in.ThresholdPoints, WebhookURL: strings.TrimSpace(in.WebhookURL)} cfg = model.MerchantAlertConfig{MerchantID: merchantID, Enabled: in.Enabled, ThresholdPoints: in.ThresholdPoints}
if err := s.db.Create(&cfg).Error; err != nil {
return nil, err
}
case err != nil: case err != nil:
return nil, err return nil, err
default: }
cfg.Enabled = in.Enabled cfg.Enabled = in.Enabled
cfg.ThresholdPoints = in.ThresholdPoints cfg.ThresholdPoints = in.ThresholdPoints
cfg.WebhookURL = strings.TrimSpace(in.WebhookURL) cfg.NotifyIntervalMinutes = in.NotifyIntervalMinutes
if err := s.db.Save(&cfg).Error; err != nil { cfg.MaxNotifications = in.MaxNotifications
return nil, err if err := s.db.Save(&cfg).Error; err != nil {
} return nil, err
} }
return &cfg, nil 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) { func (s *RechargeService) CheckLowBalanceAndNotify(merchantID uint) {
cfg, err := s.GetAlertConfig(merchantID) cfg, err := s.GetAlertConfig(merchantID)
if err != nil || !cfg.Enabled || cfg.ThresholdPoints <= 0 { if err != nil || !cfg.Enabled || cfg.ThresholdPoints <= 0 {
@@ -240,30 +332,139 @@ func (s *RechargeService) CheckLowBalanceAndNotify(merchantID uint) {
if err != nil { if err != nil {
return return
} }
// 余额已恢复:重置通知状态,等待下一轮告警
if wallet.AvailableBalance >= cfg.ThresholdPoints { 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 return
} }
var merchant model.Merchant var merchant model.Merchant
if err := s.db.Select("code", "name").First(&merchant, merchantID).Error; err != nil { 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{}{ content := fmt.Sprintf("【余额告警】商户「%s」当前积分余额 %s,已低于预警阈值 %s,请及时充值以免影响正常业务。", merchant.Name, formatThousands(wallet.AvailableBalance), formatThousands(cfg.ThresholdPoints))
"msgtype": "text",
"text": map[string]string{ now := timeNow()
"content": fmt.Sprintf("余额告警:商户 %s(#%d)当前积分余额 %d,已低于阈值 %d,请及时处理充值。", merchant.Name, merchantID, wallet.AvailableBalance, cfg.ThresholdPoints), 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} if sent {
resp, err := client.Post(cfg.WebhookURL, "application/json", bytes.NewReader(raw)) 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 { if err != nil {
log.Printf("低余额告警推送失败 merchant=%d: %v", merchantID, err) return err
return
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { 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。 // SaveUploadFile 保存上传的图片到 uploadDir,返回可通过 /uploads 访问的 URL。
+238
View File
@@ -1,10 +1,16 @@
package service package service
import ( import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"github.com/google/uuid"
) )
func TestCreateRechargeRequiresVoucher(t *testing.T) { 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) { func TestDetectImageExt(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
+2
View File
@@ -10,6 +10,7 @@ import ApiDebugger from './pages/ApiDebugger'
import Delivery from './pages/Delivery' import Delivery from './pages/Delivery'
import MerchantCenter from './pages/MerchantCenter' import MerchantCenter from './pages/MerchantCenter'
import MerchantRecharge from './pages/MerchantRecharge' import MerchantRecharge from './pages/MerchantRecharge'
import AlertSettings from './pages/AlertSettings'
import PlatformMerchants from './pages/PlatformMerchants' import PlatformMerchants from './pages/PlatformMerchants'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
function PrivateRoute({ children }: { children: ReactNode }) { function PrivateRoute({ children }: { children: ReactNode }) {
@@ -43,6 +44,7 @@ function AppRoutes() {
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} /> <Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} /> <Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
<Route path="merchant-recharge" element={<MerchantRecharge />} /> <Route path="merchant-recharge" element={<MerchantRecharge />} />
<Route path="merchant-alert-settings" element={<AlertSettings />} />
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} /> <Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} /> <Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} /> <Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
+13 -1
View File
@@ -1,6 +1,8 @@
import request from './request' import request from './request'
import type { import type {
DashboardStats, DashboardStats,
AlertChannelInput,
AlertNotifyChannel,
ApiClient, ApiClient,
ApiCredential, ApiCredential,
CallbackCredential, CallbackCredential,
@@ -108,8 +110,18 @@ export const merchantApi = {
request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication), request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication),
alertConfig: () => alertConfig: () =>
request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig), 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), 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 = { export const uploadApi = {
+3
View File
@@ -85,6 +85,7 @@ const adminSections: SidebarSection[] = [
children: [ children: [
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }, { key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' }, { key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
], ],
}, },
{ {
@@ -126,6 +127,7 @@ const merchantSections: SidebarSection[] = [
children: [ children: [
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }, { key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' }, { 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-orders')) return 'fulfillment-orders'
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet' if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge' 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-members')) return 'merchant-members'
if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks' if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks'
if (pathname.startsWith('/merchant-api-keys')) return 'api-keys' if (pathname.startsWith('/merchant-api-keys')) return 'api-keys'
+441
View File
@@ -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: 'BarkiOS', placeholder: '' },
{ value: 'webhook', label: '通用 Webhook', placeholder: 'https://your-server.com/notify' },
]
const channelConfigMap: Record<string, { label: string; color: string; icon: string }> = {
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<AlertNotifyChannel[]>([])
const [wallet, setWallet] = useState<WalletAccount | null>(null)
const [alertConfig, setAlertConfig] = useState<LowBalanceAlertConfig | null>(null)
const [loading, setLoading] = useState(false)
const [alertForm] = Form.useForm()
const [channelForm] = Form.useForm()
const [channelOpen, setChannelOpen] = useState(false)
const [editingChannel, setEditingChannel] = useState<AlertNotifyChannel | null>(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<AlertNotifyChannel> = [
{
title: '渠道类型',
dataIndex: 'channel_type',
width: 160,
render: (v: string) => {
const item = channelConfigMap[v] || { label: v, color: '#475569', icon: '🔔' }
return (
<Tag
style={{
borderRadius: 6,
padding: '2px 10px',
fontWeight: 600,
border: `1px solid ${item.color}30`,
background: `${item.color}10`,
color: item.color,
fontSize: 12.5,
margin: 0,
}}
>
{item.icon} {item.label}
</Tag>
)
},
},
{
title: '渠道名称',
dataIndex: 'name',
width: 180,
render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{v || '默认接收组'}</span>,
},
{
title: '通知地址 / 密钥配置',
dataIndex: 'config',
width: 420,
ellipsis: true,
render: (_, record) => {
const text = configSummary(record)
return text !== '-' ? (
<Text code copyable={{ text }} ellipsis style={{ maxWidth: '100%' }}>
{text}
</Text>
) : (
'-'
)
},
},
{
title: '状态',
dataIndex: 'enabled',
width: 110,
render: (v: boolean) =>
v ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
),
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space size={0}>
<Button
type="link"
size="small"
icon={<SendOutlined />}
onClick={() => testChannel(record)}
>
</Button>
<Button type="link" size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm
title="删除该通知渠道?"
onConfirm={() => deleteChannel(record)}
okText="删除"
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]
const channelType = Form.useWatch('channel_type', channelForm)
return (
<div>
<PageHeader
title="余额告警设置"
subtitle="设置低余额预警阈值,配置钉钉 / 飞书 / 企业微信 / Bark 等通知渠道"
breadcrumbs={[{ title: '积分管理' }, { title: '余额告警' }]}
extra={
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadAll}>
</Button>
}
/>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<Space size={8}>
<BellOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}></span>
</Space>
}
>
<Form form={alertForm} layout="vertical" style={{ padding: '8px 4px 0' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'center' }}>
<div style={{ minWidth: 200 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div style={{ fontSize: 24, fontWeight: 800, color: '#16a34a', marginTop: 2 }}>
{wallet === null ? '-' : wallet.available_balance.toLocaleString('zh-CN')}
<span style={{ fontSize: 13, color: '#64748b', fontWeight: 500, marginLeft: 4 }}></span>
</div>
</div>
<Form.Item name="enabled" label="启用告警" valuePropName="checked" style={{ marginBottom: 0 }}>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item
name="threshold_points"
label="低于多少积分提醒"
rules={[{ required: true, message: '请填写预警阈值' }]}
style={{ marginBottom: 0, minWidth: 240 }}
>
<InputNumber<number> min={0} step={100000} addonAfter="积分" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="notify_interval_minutes"
label="通知间隔"
rules={[{ required: true, message: '请填写通知间隔' }]}
style={{ marginBottom: 0, minWidth: 180 }}
>
<InputNumber<number> min={1} precision={0} addonAfter="分钟" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="max_notifications"
label="最大通知次数"
rules={[{ required: true, message: '请填写最大通知次数' }]}
tooltip="余额低于阈值期间最多通知几次,0 表示不限"
style={{ marginBottom: 0, minWidth: 160 }}
>
<InputNumber<number> min={0} precision={0} addonAfter="次" style={{ width: '100%' }} />
</Form.Item>
<div style={{ alignSelf: 'flex-end', marginBottom: 0 }}>
<Button type="primary" onClick={handleSaveConfig}>
</Button>
</div>
</div>
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed #e2e8f0' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
"通知间隔""最大通知次数"
</Text>
{alertConfig?.last_notified_at && (
<div style={{ marginTop: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(alertConfig.last_notified_at)} · {alertConfig.notification_count ?? 0}
{alertConfig.max_notifications > 0 && ` / 最多 ${alertConfig.max_notifications}`}
</Text>
</div>
)}
</div>
</Form>
</Card>
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 700, color: '#0f172a' }}>{channels.length}</span>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
}
>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={channels}
scroll={{ x: 1100 }}
pagination={false}
/>
<div style={{ marginTop: 12, padding: '4px 0' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
/ / WebhookBarkiOS Webhook
</Text>
</div>
</Card>
</Space>
<Modal
title={editingChannel ? '编辑通知渠道' : '添加通知渠道'}
open={channelOpen}
onOk={submitChannel}
onCancel={() => setChannelOpen(false)}
destroyOnClose
width={560}
confirmLoading={saving}
>
<Form form={channelForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="channel_type" label="渠道类型" rules={[{ required: true }]}>
<Select options={channelOptions.map(({ value, label }) => ({ value, label }))} />
</Form.Item>
<Form.Item name="name" label="名称(可选)" rules={[{ max: 64 }]}>
<Input placeholder="如:财务群 / 老板手机" maxLength={64} />
</Form.Item>
{channelType === 'bark' ? (
<>
<Form.Item name="server" label="Bark 服务器地址(可选)">
<Input placeholder="https://api.day.app(默认官方服务器)" />
</Form.Item>
<Form.Item name="key" label="Bark 设备 Key" rules={[{ required: true, message: '请填写 Bark 设备 Key' }]}>
<Input placeholder="安装 Bark 后生成的设备 Key" />
</Form.Item>
</>
) : (
<Form.Item
name="webhook_url"
label="Webhook URL"
rules={[{ required: true, message: '请填写 Webhook URL' }]}
>
<Input placeholder={channelOptions.find((item) => item.value === channelType)?.placeholder} />
</Form.Item>
)}
<Form.Item name="enabled" label="启用状态" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+3 -114
View File
@@ -12,14 +12,12 @@ import {
Popconfirm, Popconfirm,
Select, Select,
Space, Space,
Switch,
Table, Table,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd' } from 'antd'
import { import {
BellOutlined,
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
EyeOutlined, EyeOutlined,
@@ -31,17 +29,15 @@ import { PageHeader } from '../components/PageHeader'
import ImageUploader from '../components/ImageUploader' import ImageUploader from '../components/ImageUploader'
import { merchantApi, platformApi } from '../api' import { merchantApi, platformApi } from '../api'
import { useAuth } from '../store/auth' import { useAuth } from '../store/auth'
import type { LowBalanceAlertConfig, PageResult, RechargeApplication, WalletAccount } from '../types' import type { PageResult, RechargeApplication } from '../types'
import { formatDateTime } from '../utils/time' import { formatDateTime } from '../utils/time'
const { Text } = Typography const { Text } = Typography
export default function MerchantRecharge() { export default function MerchantRecharge() {
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const [wallet, setWallet] = useState<WalletAccount | null>(null)
const [applications, setApplications] = useState<PageResult<RechargeApplication>>({ list: [], total: 0, page: 1, size: 10 }) const [applications, setApplications] = useState<PageResult<RechargeApplication>>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [alertForm] = Form.useForm()
const [filterForm] = Form.useForm() const [filterForm] = Form.useForm()
const [createForm] = Form.useForm() const [createForm] = Form.useForm()
const [reviewForm] = Form.useForm() const [reviewForm] = Form.useForm()
@@ -54,8 +50,6 @@ export default function MerchantRecharge() {
const [filterParams, setFilterParams] = useState<{ no?: string; status?: string }>({}) const [filterParams, setFilterParams] = useState<{ no?: string; status?: string }>({})
const currentPointsBalance = wallet?.available_balance
const filteredList = useMemo(() => { const filteredList = useMemo(() => {
if (!filterParams.no) { if (!filterParams.no) {
return applications.list return applications.list
@@ -77,45 +71,9 @@ export default function MerchantRecharge() {
} }
}, [applications.page, applications.size, filterParams, isAdmin]) }, [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(() => { useEffect(() => {
loadApplications() loadApplications()
loadAlertConfig() }, [loadApplications])
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)
}
}
}
const handleFilterSubmit = (values: { no?: string; status?: string }) => { const handleFilterSubmit = (values: { no?: string; status?: string }) => {
const params = { const params = {
@@ -176,7 +134,6 @@ export default function MerchantRecharge() {
setReviewItem(null) setReviewItem(null)
reviewForm.resetFields() reviewForm.resetFields()
loadApplications() loadApplications()
loadWallet()
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '审核失败') message.error(e instanceof Error ? e.message : '审核失败')
} finally { } finally {
@@ -335,7 +292,7 @@ export default function MerchantRecharge() {
<div> <div>
<PageHeader <PageHeader
title="积分购买与充值" title="积分购买与充值"
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录及配置低余额 Webhook 自动化告警" subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录"
breadcrumbs={[{ title: '积分管理' }, { title: '积分充值' }]} breadcrumbs={[{ title: '积分管理' }, { title: '积分充值' }]}
extra={ extra={
<Button icon={<ReloadOutlined />} onClick={() => loadApplications()}> <Button icon={<ReloadOutlined />} onClick={() => loadApplications()}>
@@ -345,74 +302,6 @@ export default function MerchantRecharge() {
/> />
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 告警与通知配置面板 */}
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<Space size={8}>
<BellOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}> Webhook </span>
</Space>
}
>
<Form
form={alertForm}
layout="vertical"
style={{ padding: '4px 8px 0' }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'flex-start' }}>
<div style={{ flex: '0 0 200px' }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div style={{ fontSize: 22, fontWeight: 800, color: '#16a34a', marginTop: 2 }}>
{currentPointsBalance === undefined ? '-' : currentPointsBalance.toLocaleString('zh-CN')} <span style={{ fontSize: 13, color: '#64748b', fontWeight: 500 }}></span>
</div>
</div>
<Form.Item
name="enabled"
label="启用告警"
valuePropName="checked"
style={{ marginBottom: 12 }}
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item
name="threshold_points"
label="低于多少积分提醒"
rules={[{ required: true, message: '请填写预警阈值' }]}
style={{ marginBottom: 12, minWidth: 220 }}
>
<InputNumber<number>
min={0}
step={100000}
addonAfter="积分"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="webhook_url"
label="告警 Webhook (钉钉/飞书机器人)"
rules={[{ required: true, message: '请填写 Webhook URL' }]}
style={{ marginBottom: 12, flex: 1, minWidth: 320 }}
>
<Input placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." allowClear />
</Form.Item>
<div style={{ alignSelf: 'flex-end', marginBottom: 12 }}>
<Button type="primary" onClick={handleSaveAlertConfig}>
</Button>
</div>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
/ JSON POST URL
</Typography.Text>
</Form>
</Card>
{/* 申请记录表格 Card */} {/* 申请记录表格 Card */}
<Card <Card
size="small" size="small"
+28
View File
@@ -273,4 +273,32 @@ export interface LowBalanceAlertConfig {
enabled: boolean enabled: boolean
threshold_points: number threshold_points: number
webhook_url: string webhook_url: string
notify_interval_minutes: number
max_notifications: number
last_notified_at?: string
notification_count: number
}
export type AlertChannelType = 'dingtalk' | 'feishu' | 'wecom' | 'bark' | 'webhook'
export interface AlertNotifyChannel {
id: number
merchant_id: number
channel_type: AlertChannelType
name: string
config: {
webhook_url?: string
server?: string
key?: string
}
enabled: boolean
}
export interface AlertChannelInput {
channel_type: AlertChannelType
name?: string
webhook_url?: string
server?: string
key?: string
enabled: boolean
} }