feat(server/pay): 首充钩子 — settle 内接邀请首充段发奖(first_paid_at 幂等)
This commit is contained in:
@@ -35,6 +35,7 @@ type WebhookHandler struct {
|
|||||||
tolerance time.Duration
|
tolerance time.Duration
|
||||||
nonceTTL time.Duration
|
nonceTTL time.Duration
|
||||||
now func() time.Time // 测试注入
|
now func() time.Time // 测试注入
|
||||||
|
rewarder Rewarder // 可选:首充邀请奖励钩子(nil 则跳过)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client,
|
func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client,
|
||||||
@@ -43,6 +44,14 @@ func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Cli
|
|||||||
system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now}
|
system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rewarder(可选)在首充同事务内发放邀请首充奖励;nil 则跳过。
|
||||||
|
type Rewarder interface {
|
||||||
|
OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRewarder 挂载首充邀请奖励钩子(reward.Service 满足此接口)。
|
||||||
|
func (h *WebhookHandler) SetRewarder(r Rewarder) { h.rewarder = r }
|
||||||
|
|
||||||
// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload
|
// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload
|
||||||
// (注意:payment.succeeded 无 refund_id 字段)。
|
// (注意:payment.succeeded 无 refund_id 字段)。
|
||||||
type webhookEvent struct {
|
type webhookEvent struct {
|
||||||
@@ -172,5 +181,10 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error {
|
|||||||
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
|
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if h.rewarder != nil {
|
||||||
|
if err := h.rewarder.OnFirstPaidTx(ctx, tx, userID, h.now().UTC()); err != nil {
|
||||||
|
return err // 同事务:发奖失败则整笔回滚,webhook 重试
|
||||||
|
}
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package pay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wangjia/pangolin/server/internal/codes"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/reward"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettle_FirstPaidGrantsReferralBonus(t *testing.T) {
|
||||||
|
h, db, _ := newWebhookRig(t) // 复用本包 rig;rig 里 seedUser(1)
|
||||||
|
seedUser(t, db, 2, "invitee-uuid") // 被邀请人 = 付费者
|
||||||
|
// 建 referral: inviter=1, invitee=2, 已 reg_rewarded
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if _, err := db.Exec(`INSERT INTO referrals (inviter_id,invitee_id,status,reg_rewarded_at,created_at) VALUES (1,2,'reg_rewarded',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatalf("seed referral: %v", err)
|
||||||
|
}
|
||||||
|
// 装 rewarder
|
||||||
|
g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||||
|
rw := reward.NewService(db, reward.NewStore(db), g, reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now)
|
||||||
|
h.SetRewarder(rw)
|
||||||
|
|
||||||
|
// 触发 settle(照本包已有 webhook 测试构造 ev:invitee=2 的一笔 paid;biz_ref=其 uuid)
|
||||||
|
err := h.settle(context.Background(), &webhookEvent{
|
||||||
|
ProductBizCode: "pro_month", OutTradeNo: "ot-1", BizRef: "invitee-uuid",
|
||||||
|
Channel: "nezha", AmountMinor: 600, Currency: "CNY", PaidAt: now.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status string
|
||||||
|
if err := db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status); err != nil {
|
||||||
|
t.Fatalf("query referral status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "paid_rewarded" {
|
||||||
|
t.Fatalf("status=%q, want paid_rewarded", status)
|
||||||
|
}
|
||||||
|
var fp int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE id=2 AND first_paid_at IS NOT NULL`).Scan(&fp); err != nil {
|
||||||
|
t.Fatalf("query first_paid_at: %v", err)
|
||||||
|
}
|
||||||
|
if fp != 1 {
|
||||||
|
t.Fatalf("first_paid_at not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,3 +114,23 @@ func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, d
|
|||||||
slog.Warn("reward: commit failed", "err", err)
|
slog.Warn("reward: commit failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnFirstPaidTx 在 pay webhook 的同一事务内调用:标记 first_paid_at,若确为首充且该用户
|
||||||
|
// 是被邀请人(referral 命中且未发过首充奖)→ 发首充段双方各 PaidDays 天。幂等。
|
||||||
|
func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error {
|
||||||
|
first, err := s.st.MarkFirstPaidTx(ctx, tx, inviteeID, now)
|
||||||
|
if err != nil || !first {
|
||||||
|
return err // 非首充 → 不发
|
||||||
|
}
|
||||||
|
inviterID, status, ok, err := s.st.ReferralByInvitee(ctx, tx, inviteeID)
|
||||||
|
if err != nil || !ok || status == "paid_rewarded" || status == "rejected" {
|
||||||
|
return err // 无邀请关系 / 已发过 / 被拒 → 不发
|
||||||
|
}
|
||||||
|
if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.PaidDays, "invite", "invite_paid_inviter", "ref-paid-inviter"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.PaidDays, "invite", "invite_paid_invitee", "ref-paid-invitee"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.st.MarkPaidRewardedTx(ctx, tx, inviteeID, now)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user