package reward import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "log/slog" "net/http" "net/url" "strconv" "strings" "time" "github.com/redis/go-redis/v9" ) type tgConfig struct { botUser, channel, botToken, webhookSecret string } // ChatMemberChecker 判定某 telegramID 是否是频道/群成员。默认实现打 Telegram Bot API, // 测试注入 fake 以避免真实网络调用。 type ChatMemberChecker interface { IsMember(ctx context.Context, channel, telegramID string) (bool, error) } // SetMemberChecker 注入自定义成员校验器(测试用);生产走 SetTelegram 设置的默认 apiChecker。 func (s *Service) SetMemberChecker(c ChatMemberChecker) { s.checker = c } // SetTelegram 注入 TG 配置(经 Service.tg)。若尚未注入 checker(测试未 SetMemberChecker), // 默认设为打 Telegram Bot API 的 apiChecker。 func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) { s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret} if s.checker == nil { s.checker = apiChecker{botToken: botToken, hc: &http.Client{Timeout: 8 * time.Second}} } } // WebhookSecret 返回 TG webhook 校验用的 secret token。 func (s *Service) WebhookSecret() string { return s.tg.webhookSecret } // Channel 返回配置的 TG 频道(@ 形式)。 func (s *Service) Channel() string { return s.tg.channel } // SetRedis 注入 redis 客户端(经 Service.rdb);为 nil 时 token 走内存兜底(测试/未配 redis)。 func (s *Service) SetRedis(rdb *redis.Client) { s.rdb = rdb } func randToken() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } // IssueTelegramToken 生成随机 token,Redis SETEX tg_bind: 600 ; // Redis 为 nil 时用内存 map 兜底(测试/未配 redis)。 func (s *Service) IssueTelegramToken(ctx context.Context, userID int64) (string, error) { tok := randToken() if s.rdb != nil { return tok, s.rdb.Set(ctx, "tg_bind:"+tok, userID, 10*time.Minute).Err() } s.memMu.Lock() if s.memTok == nil { s.memTok = map[string]int64{} } s.memTok[tok] = userID s.memMu.Unlock() return tok, nil } // ConsumeTelegramToken 一次性消费(GETDEL 语义)。 func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64, bool, error) { if s.rdb != nil { v, err := s.rdb.GetDel(ctx, "tg_bind:"+token).Result() if err == redis.Nil { return 0, false, nil } if err != nil { return 0, false, err } id, _ := strconv.ParseInt(v, 10, 64) return id, true, nil } s.memMu.Lock() defer s.memMu.Unlock() id, ok := s.memTok[token] if ok { delete(s.memTok, token) } return id, ok, nil } // ClaimResult 是 ClaimTelegram 的结果三态,供上层(handler)按态回不同文案。 type ClaimResult int const ( ClaimGranted ClaimResult = iota // 发放成功 ClaimAlready // 已领取过(本账户或该 telegram_id) ClaimNotMember // 不在频道 ) // ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取 // (同 userID 或同 telegramID 只能成功一次)。 func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (ClaimResult, error) { member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID) if err != nil { return ClaimNotMember, err } if !member { return ClaimNotMember, nil } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { return ClaimNotMember, err } defer func() { _ = tx.Rollback() }() now := s.now() if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil { if err == ErrClaimExists { return ClaimAlready, nil // 已领过(本账户 or 该 tgid) } return ClaimNotMember, err } if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { return ClaimNotMember, err } if s.noticer != nil { zh, en := rewardNoticeTitles("task_tg", s.cfg.TgDays) if err := s.noticer.InsertNoticeTx(ctx, tx, userID, "reward", zh, en, "", "", "", now); err != nil { return ClaimNotMember, err } } if err := tx.Commit(); err != nil { return ClaimNotMember, err } return ClaimGranted, nil } // apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。 type apiChecker struct { botToken string hc *http.Client } func (c apiChecker) IsMember(ctx context.Context, channel, telegramID string) (bool, error) { u := "https://api.telegram.org/bot" + c.botToken + "/getChatMember" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return false, err } q := req.URL.Query() q.Set("chat_id", channel) q.Set("user_id", telegramID) req.URL.RawQuery = q.Encode() resp, err := c.hc.Do(req) if err != nil { return false, err } defer resp.Body.Close() var out struct { OK bool `json:"ok"` Result struct { Status string `json:"status"` } `json:"result"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return false, err } st := out.Result.Status return out.OK && (st == "member" || st == "administrator" || st == "creator"), nil } // SendTelegram 打 Bot API sendMessage 给 chatID 发文案;失败只 log,不影响主流程。 func (s *Service) SendTelegram(ctx context.Context, chatID int64, text string) { u := "https://api.telegram.org/bot" + s.tg.botToken + "/sendMessage" body := url.Values{} body.Set("chat_id", strconv.FormatInt(chatID, 10)) body.Set("text", text) req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(body.Encode())) if err != nil { slog.Warn("reward: send telegram build request failed", "err", err) return } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") hc := &http.Client{Timeout: 8 * time.Second} resp, err := hc.Do(req) if err != nil { slog.Warn("reward: send telegram failed", "err", err) return } defer resp.Body.Close() if resp.StatusCode >= 300 { slog.Warn("reward: send telegram non-2xx", "status", resp.StatusCode) } }