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) } }