余额告警独立页面与多渠道通知:钉钉/飞书/企业微信/Bark,支持通知频率策略
- 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道 - 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置 - 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送 - 优化告警话术:去商户ID展示、数字千分位格式化
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
|
||||
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
|
||||
<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-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
||||
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<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 }}>
|
||||
支持钉钉 / 飞书 / 企业微信群机器人 Webhook、Bark(iOS 推送)与自定义 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<WalletAccount | null>(null)
|
||||
const [applications, setApplications] = useState<PageResult<RechargeApplication>>({ 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() {
|
||||
<div>
|
||||
<PageHeader
|
||||
title="积分购买与充值"
|
||||
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录及配置低余额 Webhook 自动化告警"
|
||||
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录"
|
||||
breadcrumbs={[{ title: '积分管理' }, { title: '积分充值' }]}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => loadApplications()}>
|
||||
@@ -345,74 +302,6 @@ export default function MerchantRecharge() {
|
||||
/>
|
||||
|
||||
<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
|
||||
size="small"
|
||||
|
||||
@@ -273,4 +273,32 @@ export interface LowBalanceAlertConfig {
|
||||
enabled: boolean
|
||||
threshold_points: number
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user