From 2fb7391a178cbf5599d8088a5404cbfe0884dd23 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 07:27:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(server/reward):=20OnRegister=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=20+=20=E6=B3=A8=E5=86=8C=E6=AE=B5=E5=8F=8C=E6=96=B9?= =?UTF-8?q?=E5=8F=91=E5=A5=96=20+=20=E8=87=AA=E9=82=80=E8=AF=B7/=E8=AE=BE?= =?UTF-8?q?=E5=A4=87/=E6=9C=88=E5=B0=81=E9=A1=B6=E9=98=B2=E5=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- server/internal/reward/service.go | 116 ++++++++++++++++++ server/internal/reward/service_sqlite_test.go | 80 ++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 server/internal/reward/service.go create mode 100644 server/internal/reward/service_sqlite_test.go diff --git a/server/internal/reward/service.go b/server/internal/reward/service.go new file mode 100644 index 0000000..48a0298 --- /dev/null +++ b/server/internal/reward/service.go @@ -0,0 +1,116 @@ +package reward + +import ( + "context" + "crypto/rand" + "database/sql" + "log/slog" + "time" +) + +type Granter interface { + GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, 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 +} + +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() } + } + return &Service{db: db, st: st, g: g, cfg: cfg, now: now} +} + +const inviteAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去 0/O/1/I,32 字符 + +// GenInviteCode 返回 8 位随机码。crypto/rand 失败时用 now 派生兜底(避免 panic)。 +func GenInviteCode() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + t := time.Now().UnixNano() + for i := range b { + b[i] = byte(t >> (8 * i)) + } + } + out := make([]byte, 8) + for i, x := range b { + out[i] = inviteAlphabet[int(x)%len(inviteAlphabet)] + } + return string(out) +} + +func (s *Service) EnsureCode(ctx context.Context, userID int64) (string, error) { + return s.st.EnsureInviteCode(ctx, userID, GenInviteCode) +} + +// OnRegister best-effort:与建号解耦(自有事务,失败只 log,不回滚注册)。 +func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) { + if inviteCode == "" { + return + } + inviterID, ok, err := s.st.ResolveInviteCode(ctx, inviteCode) + if err != nil || !ok { + if err != nil { + slog.Warn("reward: resolve invite code failed", "err", err) + } + return // 无效码:静默,不报错(注册已成功) + } + if inviterID == inviteeID { + return // 自邀请:不建关系 + } + now := s.now() + + // 防刷判定:设备复用 or 月度封顶 → 记关系但 status=rejected、不发注册奖。 + rejected := false + if used, e := s.st.DeviceUsedByOther(ctx, deviceUUID, inviteeID); e == nil && used { + rejected = true + } + if !rejected { + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + if cnt, e := s.st.RegRewardCountThisMonth(ctx, inviterID, monthStart); e == nil && cnt >= s.cfg.RegMonthlyCap { + rejected = true + } + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + slog.Warn("reward: begin tx failed", "err", err) + return + } + defer func() { _ = tx.Rollback() }() + + status := "reg_rewarded" + var regAt *time.Time + if rejected { + status = "rejected" + } else { + regAt = &now + } + if err := s.st.InsertReferralTx(ctx, tx, inviterID, inviteeID, deviceUUID, status, regAt, now); err != nil { + if err != ErrClaimExists { + slog.Warn("reward: insert referral failed", "err", err) + } + return // invitee 已绑定过(唯一约束)→ 放弃 + } + if !rejected { + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.RegDays, "invite", "invite_reg_inviter", "ref-inviter"); err != nil { + slog.Warn("reward: grant inviter reg failed", "err", err) + return + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.RegDays, "invite", "invite_reg_invitee", "ref-invitee"); err != nil { + slog.Warn("reward: grant invitee reg failed", "err", err) + return + } + } + if err := tx.Commit(); err != nil { + slog.Warn("reward: commit failed", "err", err) + } +} diff --git a/server/internal/reward/service_sqlite_test.go b/server/internal/reward/service_sqlite_test.go new file mode 100644 index 0000000..18e13cb --- /dev/null +++ b/server/internal/reward/service_sqlite_test.go @@ -0,0 +1,80 @@ +package reward + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func newSvc(t *testing.T, db *sql.DB) *Service { + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + return NewService(db, NewStore(db), g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) +} + +func proDays(t *testing.T, db *sql.DB, userID int64) int { // 该用户 pro 剩余天数(粗算) + var exp sql.NullTime + db.QueryRow(`SELECT expires_at FROM subscriptions s JOIN plans p ON p.id=s.plan_id + WHERE s.user_id=? AND p.code='pro' ORDER BY expires_at DESC LIMIT 1`, userID).Scan(&exp) + if !exp.Valid { + return 0 + } + return int(time.Until(exp.Time).Hours()/24) + 1 +} + +func TestOnRegister_BothGetThreeDays(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inviter") + seedU(t, db, 2, "invitee") + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status) + if status != "reg_rewarded" { + t.Fatalf("status=%q", status) + } + if d := proDays(t, db, 1); d < 3 { + t.Fatalf("inviter pro days=%d, want>=3", d) + } + if d := proDays(t, db, 2); d < 3 { + t.Fatalf("invitee pro days=%d, want>=3", d) + } +} + +func TestOnRegister_SelfInviteRejected(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 1, code, "dev-1") // 自邀请 + var n int + db.QueryRow(`SELECT COUNT(*) FROM referrals`).Scan(&n) + if n != 0 { + t.Fatalf("self-invite created %d referrals, want 0", n) + } +} + +func TestOnRegister_DeviceReusedRejectsReward(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inv") + seedU(t, db, 2, "old") + seedU(t, db, 3, "new") + // old(2) 的设备 dev-X 已在 devices + db.Exec(`INSERT INTO devices (uuid,user_id,name,platform,last_seen,created_at) + VALUES ('dev-X',2,'n','android',?,?)`, time.Now().UTC(), time.Now().UTC()) + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 3, code, "dev-X") // 新用户复用老设备 + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=3`).Scan(&status) + if status != "rejected" { + t.Fatalf("status=%q, want rejected", status) + } + if d := proDays(t, db, 1); d >= 3 { + t.Fatalf("inviter got reward despite device reuse: %d", d) + } +}