Files
affiliate_dash/backend/internal/service/recharge.go
T
yml2213 ca58c2bcaa 余额告警独立页面与多渠道通知:钉钉/飞书/企业微信/Bark,支持通知频率策略
- 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道
- 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置
- 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送
- 优化告警话术:去商户ID展示、数字千分位格式化
2026-08-04 13:56:26 +08:00

517 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 充值约束:最小 10 元;1 元 = 100 积分,即 1 分人民币 = 1 积分。
const (
RechargeMinAmountCNYCents = 1000 // 10 元(单位:分)
RechargePointsPerCNYCent = 1 // 每 1 分人民币兑换积分
)
// timeNow 便于测试控制通知频率判断。
var timeNow = time.Now
type RechargeService struct {
db *gorm.DB
fulfill *FulfillmentService
uploadDir string
}
func NewRechargeService(db *gorm.DB, fulfill *FulfillmentService, uploadDir string) *RechargeService {
return &RechargeService{db: db, fulfill: fulfill, uploadDir: uploadDir}
}
type CreateRechargeInput struct {
MerchantID uint
ActorUserID uint
AmountCNY int64 // 人民币,单位:分
Vouchers []string
Note string
}
func (s *RechargeService) CreateRecharge(in CreateRechargeInput) (*model.RechargeApplication, error) {
if in.MerchantID == 0 {
return nil, errors.New("无效的商户")
}
if in.AmountCNY < RechargeMinAmountCNYCents {
return nil, errors.New("最小充值金额为 10 元")
}
if len(in.Vouchers) == 0 {
return nil, errors.New("请上传打款凭证截图")
}
cleaned := make([]string, 0, len(in.Vouchers))
for _, v := range in.Vouchers {
v = strings.TrimSpace(v)
if v != "" {
cleaned = append(cleaned, v)
}
}
if len(cleaned) == 0 {
return nil, errors.New("请上传打款凭证截图")
}
if len(cleaned) > 5 {
return nil, errors.New("凭证截图最多 5 张")
}
if len(in.Note) > 512 {
return nil, errors.New("备注最长 512 个字符")
}
app := &model.RechargeApplication{
MerchantID: in.MerchantID,
ApplicationNo: newRechargeApplicationNo(),
AmountCNY: in.AmountCNY,
PointsAmount: in.AmountCNY * RechargePointsPerCNYCent,
Status: model.RechargeStatusPending,
Vouchers: cleaned,
Note: strings.TrimSpace(in.Note),
}
if err := s.db.Create(app).Error; err != nil {
return nil, err
}
_ = writeAudit(s.db, &app.MerchantID, &in.ActorUserID, nil, "recharge.create", "recharge_application", fmt.Sprint(app.ID), map[string]interface{}{"application_no": app.ApplicationNo, "amount_cny": app.AmountCNY})
return app, nil
}
func (s *RechargeService) ListRechargeApplications(merchantID uint, page, size int, status string) ([]model.RechargeApplication, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.RechargeApplication{}).Where("merchant_id = ?", merchantID)
if status != "" {
tx = tx.Where("status = ?", status)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.RechargeApplication
err := tx.Preload("Merchant").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
func (s *RechargeService) ListAllRechargeApplications(page, size int, status string) ([]model.RechargeApplication, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.RechargeApplication{})
if status != "" {
tx = tx.Where("status = ?", status)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.RechargeApplication
err := tx.Preload("Merchant").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
type ReviewRechargeInput struct {
ApplicationID uint
Approved bool
ReviewNote string
ActorUserID uint
}
// ReviewRecharge 审核充值申请;通过时在申请与入账共用一个事务内调用 AdjustWallet
// (幂等键 = 申请单号),保证审核状态与钱包入账一致。
func (s *RechargeService) ReviewRecharge(in ReviewRechargeInput) (*model.RechargeApplication, error) {
var out model.RechargeApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
var app model.RechargeApplication
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&app, in.ApplicationID).Error; err != nil {
return errors.New("充值申请不存在")
}
if app.Status != model.RechargeStatusPending {
return errors.New("该申请已审核,不能重复操作")
}
status := model.RechargeStatusRejected
if in.Approved {
status = model.RechargeStatusApproved
}
now := time.Now()
updates := map[string]interface{}{
"status": status,
"review_note": strings.TrimSpace(in.ReviewNote),
"reviewed_at": now,
"reviewed_by": in.ActorUserID,
}
if err := tx.Model(&app).Updates(updates).Error; err != nil {
return err
}
if in.Approved {
wallet, err := s.fulfill.AdjustWallet(WalletAdjustInput{
MerchantID: app.MerchantID,
ActorUserID: in.ActorUserID,
Amount: app.PointsAmount,
IdempotencyKey: app.ApplicationNo,
Note: fmt.Sprintf("充值入账 %s%s", app.ApplicationNo, app.Note),
})
if err != nil {
return fmt.Errorf("积分入账失败:%w", err)
}
_ = wallet
}
out = app
out.Status = status
out.ReviewNote = strings.TrimSpace(in.ReviewNote)
out.ReviewedAt = &now
return writeAudit(tx, &app.MerchantID, &in.ActorUserID, nil, "recharge.review", "recharge_application", fmt.Sprint(app.ID), map[string]interface{}{"application_no": app.ApplicationNo, "approved": in.Approved, "points_amount": app.PointsAmount})
})
if err != nil {
return nil, err
}
return &out, nil
}
type SaveAlertInput struct {
Enabled bool
ThresholdPoints int64
NotifyIntervalMinutes int
MaxNotifications int
}
func (s *RechargeService) GetAlertConfig(merchantID uint) (*model.MerchantAlertConfig, error) {
var cfg model.MerchantAlertConfig
err := s.db.Where("merchant_id = ?", merchantID).First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &model.MerchantAlertConfig{MerchantID: merchantID}, nil
}
if err != nil {
return nil, err
}
return &cfg, nil
}
func (s *RechargeService) SaveAlertConfig(merchantID uint, in SaveAlertInput) (*model.MerchantAlertConfig, error) {
if in.ThresholdPoints < 0 {
return nil, errors.New("预警阈值不能为负")
}
if in.NotifyIntervalMinutes < 1 {
return nil, errors.New("通知间隔至少 1 分钟")
}
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}
case 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
}
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 {
return
}
wallet, err := s.fulfill.GetWallet(merchantID)
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 = "未知商户"
}
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
}
// 至少一个渠道推送成功才推进通知状态,避免失败时漏掉后续重试
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 {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("推送返回非 2xx 状态码 %d%s", resp.StatusCode, string(body))
}
return nil
}
// SaveUploadFile 保存上传的图片到 uploadDir,返回可通过 /uploads 访问的 URL。
// 通过文件魔数校验真实图片格式(jpg/png/gif/webp),存储扩展名以实际格式为准。
func (s *RechargeService) SaveUploadFile(r io.Reader, _ string, maxBytes int64) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return "", errors.New("读取上传文件失败")
}
if len(data) == 0 {
return "", errors.New("上传文件为空")
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("图片大小不能超过 %dMB", maxBytes/1024/1024)
}
ext := detectImageExt(data)
if ext == "" {
return "", errors.New("仅支持 jpg/png/gif/webp 图片")
}
name := uuid.NewString() + "." + ext
if err := os.MkdirAll(s.uploadDir, 0o755); err != nil {
return "", errors.New("创建上传目录失败")
}
if err := os.WriteFile(filepath.Join(s.uploadDir, name), data, 0o644); err != nil {
return "", errors.New("保存上传文件失败")
}
return "/uploads/" + name, nil
}
// detectImageExt 根据文件头魔数识别图片格式;非法文件返回空串。
func detectImageExt(data []byte) string {
switch {
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
return "jpg"
case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G':
return "png"
case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8':
return "gif"
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P':
return "webp"
default:
return ""
}
}
func newRechargeApplicationNo() string {
return "RC" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
}