feat(server): 到账通知四接缝(邀请注册/首充/TG/购买开通)——与发放同事务零孤儿
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user