package reward import ( "context" "crypto/rand" "database/sql" "fmt" "log/slog" "sync" "time" "github.com/redis/go-redis/v9" ) 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 noticer Noticer rdb *redis.Client tg tgConfig checker ChatMemberChecker memMu sync.Mutex 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() } } 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", fmt.Sprintf("invite:reg:invitee=%d", inviteeID)); 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", fmt.Sprintf("invite:reg:inviter=%d", inviterID)); err != nil { 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) } } // 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", fmt.Sprintf("invite:paid:invitee=%d", inviteeID)); err != nil { return err } 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) } // TelegramDeepLink 拼 t.me 深链,携带绑定 token。 func (s *Service) TelegramDeepLink(tok string) string { return "https://t.me/" + s.tg.botUser + "?start=" + tok }