From de1117b0e6afc99c6c2985fdcfa5b736577dd612 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 07:41:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(server/pay):=20=E9=A6=96=E5=85=85=E9=92=A9?= =?UTF-8?q?=E5=AD=90=20=E2=80=94=20settle=20=E5=86=85=E6=8E=A5=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E9=A6=96=E5=85=85=E6=AE=B5=E5=8F=91=E5=A5=96(first=5F?= =?UTF-8?q?paid=5Fat=20=E5=B9=82=E7=AD=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/pay/webhook.go | 14 ++++++ .../pay/webhook_referral_sqlite_test.go | 48 +++++++++++++++++++ server/internal/reward/service.go | 20 ++++++++ 3 files changed, 82 insertions(+) create mode 100644 server/internal/pay/webhook_referral_sqlite_test.go diff --git a/server/internal/pay/webhook.go b/server/internal/pay/webhook.go index a91de4d..7cfe6f0 100644 --- a/server/internal/pay/webhook.go +++ b/server/internal/pay/webhook.go @@ -35,6 +35,7 @@ type WebhookHandler struct { tolerance time.Duration nonceTTL time.Duration now func() time.Time // 测试注入 + rewarder Rewarder // 可选:首充邀请奖励钩子(nil 则跳过) } 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} } +// 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 // (注意:payment.succeeded 无 refund_id 字段)。 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 { 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() } diff --git a/server/internal/pay/webhook_referral_sqlite_test.go b/server/internal/pay/webhook_referral_sqlite_test.go new file mode 100644 index 0000000..4f41129 --- /dev/null +++ b/server/internal/pay/webhook_referral_sqlite_test.go @@ -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") + } +} diff --git a/server/internal/reward/service.go b/server/internal/reward/service.go index 48a0298..65bc574 100644 --- a/server/internal/reward/service.go +++ b/server/internal/reward/service.go @@ -114,3 +114,23 @@ func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, d 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) +}