From e1e198e1695193ea1a8d72afd89565199bb332c7 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 13:49:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=88=B0=E8=B4=A6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=9B=9B=E6=8E=A5=E7=BC=9D(=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E6=B3=A8=E5=86=8C/=E9=A6=96=E5=85=85/TG/=E8=B4=AD=E4=B9=B0?= =?UTF-8?q?=E5=BC=80=E9=80=9A)=E2=80=94=E2=80=94=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E6=94=BE=E5=90=8C=E4=BA=8B=E5=8A=A1=E9=9B=B6=E5=AD=A4=E5=84=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/pay/webhook.go | 16 ++++ .../pay/webhook_notice_sqlite_test.go | 28 +++++++ .../reward/notices_hook_sqlite_test.go | 78 +++++++++++++++++++ server/internal/reward/service.go | 53 +++++++++++-- server/internal/reward/telegram.go | 6 ++ 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 server/internal/pay/webhook_notice_sqlite_test.go create mode 100644 server/internal/reward/notices_hook_sqlite_test.go diff --git a/server/internal/pay/webhook.go b/server/internal/pay/webhook.go index 7cfe6f0..5759b50 100644 --- a/server/internal/pay/webhook.go +++ b/server/internal/pay/webhook.go @@ -36,6 +36,7 @@ type WebhookHandler struct { nonceTTL time.Duration now func() time.Time // 测试注入 rewarder Rewarder // 可选:首充邀请奖励钩子(nil 则跳过) + noticer Noticer // 可选:购买开通到账通知钩子(nil 则跳过) } func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client, @@ -52,6 +53,14 @@ type Rewarder interface { // SetRewarder 挂载首充邀请奖励钩子(reward.Service 满足此接口)。 func (h *WebhookHandler) SetRewarder(r Rewarder) { h.rewarder = r } +// Noticer 抽象 notices.Store 的事务内插入入口(测试可替身;生产传 notices.NewStore(db))。 +type Noticer interface { + InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error +} + +// SetNoticer 挂载购买开通到账通知钩子;为 nil 时跳过(装配前兼容)。 +func (h *WebhookHandler) SetNoticer(n Noticer) { h.noticer = n } + // webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload // (注意:payment.succeeded 无 refund_id 字段)。 type webhookEvent struct { @@ -186,5 +195,12 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error { return err // 同事务:发奖失败则整笔回滚,webhook 重试 } } + if h.noticer != nil { + zh := fmt.Sprintf("已开通 Pro · %d 天", item.Days) + en := fmt.Sprintf("Pro activated · %d days", item.Days) + if err := h.noticer.InsertNoticeTx(ctx, tx, userID, "reward", zh, en, "", "", "", h.now().UTC()); err != nil { + return err // 同事务:通知插入失败则整笔回滚,webhook 重试 + } + } return tx.Commit() } diff --git a/server/internal/pay/webhook_notice_sqlite_test.go b/server/internal/pay/webhook_notice_sqlite_test.go new file mode 100644 index 0000000..42ad1cb --- /dev/null +++ b/server/internal/pay/webhook_notice_sqlite_test.go @@ -0,0 +1,28 @@ +package pay + +import ( + "context" + "testing" + + "github.com/wangjia/pangolin/server/internal/notices" +) + +func TestSettle_InsertsPurchaseNotice(t *testing.T) { + h, db, st := newWebhookRig(t) + h.SetNoticer(notices.NewStore(db)) + ctx := context.Background() + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay-notice-1", "crypto", 4990000, "USDT"); err != nil { + t.Fatal(err) + } + w := deliver(t, h, succeededPayload("pay-notice-1", "pro_month")) + if w.Code != 200 { + t.Fatalf("code=%d body=%s", w.Code, w.Body) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id=1`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("购买开通应插入一条到账通知, got %d", n) + } +} diff --git a/server/internal/reward/notices_hook_sqlite_test.go b/server/internal/reward/notices_hook_sqlite_test.go new file mode 100644 index 0000000..fa2f645 --- /dev/null +++ b/server/internal/reward/notices_hook_sqlite_test.go @@ -0,0 +1,78 @@ +package reward + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/notices" +) + +// failingGranter 总是拒绝发放,用于验证「发放失败 → 通知(与本事务其余写入)一并回滚」。 +type failingGranter struct{} + +func (failingGranter) GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) { + return 0, time.Time{}, fmt.Errorf("boom") +} + +func TestOnRegister_InsertsRewardNotices(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inviter") + seedU(t, db, 2, "invitee") + s := newSvc(t, db) + s.SetNoticer(notices.NewStore(db)) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id IN (1,2)`).Scan(&n) + if n != 2 { + t.Fatalf("注册段应双方各一条到账通知, got %d", n) + } +} + +func TestClaimTelegram_NoticeRollsBackWithGrantFailure(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := NewService(db, NewStore(db), failingGranter{}, Config{TgDays: 3}, time.Now) + s.SetTelegram("bot", "@ch", "tok", "sec") + s.SetMemberChecker(fakeChecker{member: true}) + s.SetNoticer(notices.NewStore(db)) + + if _, err := s.ClaimTelegram(context.Background(), 1, "tg-1"); err == nil { + t.Fatalf("expected grant failure error, got nil") + } + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n) + if n != 0 { + t.Fatalf("grant 失败应整事务回滚,notices should be 0, got %d", n) + } +} + +func TestOnFirstPaidTx_InsertsRewardNotices(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inviter") + seedU(t, db, 2, "invitee") + s := newSvc(t, db) + s.SetNoticer(notices.NewStore(db)) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + if err := s.OnFirstPaidTx(context.Background(), tx, 2, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id IN (1,2)`).Scan(&n) + // 注册段 2 条 + 首充段 2 条 = 4 条 + if n != 4 { + t.Fatalf("注册+首充共应 4 条到账通知, got %d", n) + } +} diff --git a/server/internal/reward/service.go b/server/internal/reward/service.go index 15eb6fc..dfded6b 100644 --- a/server/internal/reward/service.go +++ b/server/internal/reward/service.go @@ -16,14 +16,21 @@ type Granter interface { GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) } +// Noticer 抽象 notices.Store 的事务内插入入口(测试可替身;生产传 notices.NewStore(db))。 +// reward → notices 单向依赖,不引入 import cycle,故不需要适配器,直接实现即可。 +type Noticer interface { + InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error +} + type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int } type Service struct { - db *sql.DB - st *Store - g Granter - cfg Config - now func() time.Time + db *sql.DB + st *Store + g Granter + cfg Config + now func() time.Time + noticer Noticer rdb *redis.Client tg tgConfig @@ -32,6 +39,22 @@ type Service struct { memTok map[string]int64 } +// SetNoticer 注入到账通知钩子(notices.Store 满足此接口);为 nil 时四接缝跳过(装配前兼容)。 +func (s *Service) SetNoticer(n Noticer) { s.noticer = n } + +// rewardNoticeTitles 按事件渲染双语标题(reward 类通知无正文,一行标题即可)。 +func rewardNoticeTitles(kind string, days int) (zh, en string) { + switch kind { + case "invite_reg": + return fmt.Sprintf("邀请奖励 +%d 天已到账", days), fmt.Sprintf("Invite reward +%d days credited", days) + case "invite_paid": + return fmt.Sprintf("好友首购奖励 +%d 天已到账", days), fmt.Sprintf("Friend's first purchase: +%d days credited", days) + case "task_tg": + return fmt.Sprintf("任务奖励 +%d 天已到账", days), fmt.Sprintf("Task reward +%d days credited", days) + } + return "", "" +} + func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service { if now == nil { now = func() time.Time { return time.Now().UTC() } @@ -119,6 +142,17 @@ func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, d slog.Warn("reward: grant invitee reg failed", "err", err) return } + if s.noticer != nil { + zh, en := rewardNoticeTitles("invite_reg", s.cfg.RegDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, inviterID, "reward", zh, en, "", "", "", now); err != nil { + slog.Warn("reward: insert inviter notice failed", "err", err) + return + } + if err := s.noticer.InsertNoticeTx(ctx, tx, inviteeID, "reward", zh, en, "", "", "", now); err != nil { + slog.Warn("reward: insert invitee notice failed", "err", err) + return + } + } } if err := tx.Commit(); err != nil { slog.Warn("reward: commit failed", "err", err) @@ -142,6 +176,15 @@ func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64 if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.PaidDays, "invite", "invite_paid_invitee", fmt.Sprintf("invite:paid:inviter=%d", inviterID)); err != nil { return err } + if s.noticer != nil { + zh, en := rewardNoticeTitles("invite_paid", s.cfg.PaidDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, inviterID, "reward", zh, en, "", "", "", now); err != nil { + return err + } + if err := s.noticer.InsertNoticeTx(ctx, tx, inviteeID, "reward", zh, en, "", "", "", now); err != nil { + return err + } + } return s.st.MarkPaidRewardedTx(ctx, tx, inviteeID, now) } diff --git a/server/internal/reward/telegram.go b/server/internal/reward/telegram.go index f420633..c61045a 100644 --- a/server/internal/reward/telegram.go +++ b/server/internal/reward/telegram.go @@ -125,6 +125,12 @@ func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID st if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { return ClaimNotMember, err } + if s.noticer != nil { + zh, en := rewardNoticeTitles("task_tg", s.cfg.TgDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, userID, "reward", zh, en, "", "", "", now); err != nil { + return ClaimNotMember, err + } + } if err := tx.Commit(); err != nil { return ClaimNotMember, err }