- 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道 - 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置 - 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送 - 优化告警话术:去商户ID展示、数字千分位格式化
387 lines
12 KiB
Go
387 lines
12 KiB
Go
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) {
|
|
db := newServiceTestDB(t)
|
|
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-novoucher", 0, -1, 100)
|
|
svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir())
|
|
|
|
if _, err := svc.CreateRecharge(CreateRechargeInput{
|
|
MerchantID: merchantID,
|
|
AmountCNY: 10000,
|
|
}); err == nil || !strings.Contains(err.Error(), "凭证") {
|
|
t.Fatalf("empty voucher should be rejected, got %v", err)
|
|
}
|
|
|
|
if _, err := svc.CreateRecharge(CreateRechargeInput{
|
|
MerchantID: merchantID,
|
|
AmountCNY: 100,
|
|
Vouchers: []string{"/uploads/a.png"},
|
|
}); err == nil || !strings.Contains(err.Error(), "10 元") {
|
|
t.Fatalf("amount below minimum should be rejected, got %v", err)
|
|
}
|
|
|
|
app, err := svc.CreateRecharge(CreateRechargeInput{
|
|
MerchantID: merchantID,
|
|
ActorUserID: 7,
|
|
AmountCNY: 50000,
|
|
Vouchers: []string{"/uploads/a.png"},
|
|
Note: "对公转账",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create recharge: %v", err)
|
|
}
|
|
if app.Status != model.RechargeStatusPending || app.PointsAmount != 50000 {
|
|
t.Fatalf("unexpected application: %+v", app)
|
|
}
|
|
}
|
|
|
|
func TestReviewRechargeCreditsWalletOnce(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-review", 0, -1, 100)
|
|
fulfillment := NewFulfillmentService(db, nil)
|
|
svc := NewRechargeService(db, fulfillment, t.TempDir())
|
|
|
|
app, err := svc.CreateRecharge(CreateRechargeInput{
|
|
MerchantID: merchantID,
|
|
ActorUserID: 7,
|
|
AmountCNY: 100000, // 1000 元 = 100000 积分
|
|
Vouchers: []string{"/uploads/a.png"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create recharge: %v", err)
|
|
}
|
|
|
|
reviewed, err := svc.ReviewRecharge(ReviewRechargeInput{
|
|
ApplicationID: app.ID,
|
|
Approved: true,
|
|
ReviewNote: "已核实到账",
|
|
ActorUserID: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("review approve: %v", err)
|
|
}
|
|
if reviewed.Status != model.RechargeStatusApproved || reviewed.PointsAmount != 100000 {
|
|
t.Fatalf("unexpected reviewed app: %+v", reviewed)
|
|
}
|
|
|
|
wallet, err := fulfillment.GetWallet(merchantID)
|
|
if err != nil {
|
|
t.Fatalf("get wallet: %v", err)
|
|
}
|
|
if wallet.AvailableBalance != 100000 {
|
|
t.Fatalf("approved recharge should credit wallet, got %d", wallet.AvailableBalance)
|
|
}
|
|
|
|
// 重复审核应被拒绝且不再入账
|
|
if _, err := svc.ReviewRecharge(ReviewRechargeInput{
|
|
ApplicationID: app.ID,
|
|
Approved: true,
|
|
ActorUserID: 1,
|
|
}); err == nil || !strings.Contains(err.Error(), "已审核") {
|
|
t.Fatalf("repeat review should be rejected, got %v", err)
|
|
}
|
|
wallet, _ = fulfillment.GetWallet(merchantID)
|
|
if wallet.AvailableBalance != 100000 {
|
|
t.Fatalf("repeat review should not credit again, got %d", wallet.AvailableBalance)
|
|
}
|
|
}
|
|
|
|
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
|
|
data []byte
|
|
want string
|
|
}{
|
|
{"jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00}, "jpg"},
|
|
{"png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, "png"},
|
|
{"gif", []byte{'G', 'I', 'F', '8', '9', 'a'}, "gif"},
|
|
{"webp", []byte{'R', 'I', 'F', 'F', 0x00, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P'}, "webp"},
|
|
{"fake", []byte{'<', 's', 'c', 'r', 'i', 'p', 't', '>'}, ""},
|
|
{"empty", []byte{}, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := detectImageExt(tc.data); got != tc.want {
|
|
t.Fatalf("%s: detectImageExt = %q, want %q", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReviewRechargeReject(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-reject", 0, -1, 100)
|
|
fulfillment := NewFulfillmentService(db, nil)
|
|
svc := NewRechargeService(db, fulfillment, t.TempDir())
|
|
|
|
app, err := svc.CreateRecharge(CreateRechargeInput{
|
|
MerchantID: merchantID,
|
|
ActorUserID: 7,
|
|
AmountCNY: 10000,
|
|
Vouchers: []string{"/uploads/a.png"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create recharge: %v", err)
|
|
}
|
|
|
|
reviewed, err := svc.ReviewRecharge(ReviewRechargeInput{
|
|
ApplicationID: app.ID,
|
|
Approved: false,
|
|
ReviewNote: "凭证不清晰",
|
|
ActorUserID: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("review reject: %v", err)
|
|
}
|
|
if reviewed.Status != model.RechargeStatusRejected {
|
|
t.Fatalf("expected rejected, got %+v", reviewed)
|
|
}
|
|
wallet, _ := fulfillment.GetWallet(merchantID)
|
|
if wallet.AvailableBalance != 0 {
|
|
t.Fatalf("rejected recharge should not credit, got %d", wallet.AvailableBalance)
|
|
}
|
|
}
|