diff --git a/server/internal/reward/handler.go b/server/internal/reward/handler.go index dd4f8bd..5abd6af 100644 --- a/server/internal/reward/handler.go +++ b/server/internal/reward/handler.go @@ -3,6 +3,8 @@ package reward import ( "encoding/json" "net/http" + "strconv" + "strings" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/auth" @@ -73,3 +75,44 @@ func (h *Handler) TelegramStart(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"deep_link": h.svc.TelegramDeepLink(tok)}) } + +// TelegramWebhook 处理 Telegram Bot API 回调:校验 secret 头 → 解析 `/start ` +// → ConsumeTelegramToken → ClaimTelegram → sendMessage 回执。Telegram 只要求 200 即不重投, +// 业务结果通过 sendMessage 异步告知用户。 +func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { + if !h.tgEnabled || r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != h.svc.WebhookSecret() { + w.WriteHeader(http.StatusNotFound) + return + } + var upd struct { + Message struct { + Text string `json:"text"` + From struct { + ID int64 `json:"id"` + } `json:"from"` + } `json:"message"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&upd) + w.WriteHeader(http.StatusOK) // Telegram 只要 200;业务异步在下方 + text, fromID := upd.Message.Text, upd.Message.From.ID + if !strings.HasPrefix(text, "/start ") || fromID == 0 { + return + } + token := strings.TrimSpace(strings.TrimPrefix(text, "/start ")) + ctx := r.Context() + uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token) + if !ok { + h.svc.SendTelegram(ctx, fromID, "链接已失效,请回 App 重新点「验证领取」。") + return + } + granted, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10)) + switch { + case err != nil: + h.svc.SendTelegram(ctx, fromID, "验证出错,请稍后重试。") + case granted: + h.svc.SendTelegram(ctx, fromID, "已到账 +3 天 Pro,感谢加入!") + default: + h.svc.SendTelegram(ctx, fromID, "请先加入频道 "+h.svc.Channel()+" 再点验证;若已加入且领取过则无需重复。") + } +} + diff --git a/server/internal/reward/service.go b/server/internal/reward/service.go index c0a1a00..48921dd 100644 --- a/server/internal/reward/service.go +++ b/server/internal/reward/service.go @@ -24,10 +24,11 @@ type Service struct { cfg Config now func() time.Time - rdb *redis.Client - tg tgConfig - memMu sync.Mutex - memTok map[string]int64 + rdb *redis.Client + tg tgConfig + checker ChatMemberChecker + memMu sync.Mutex + memTok map[string]int64 } func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service { diff --git a/server/internal/reward/telegram.go b/server/internal/reward/telegram.go index 9155893..864818c 100644 --- a/server/internal/reward/telegram.go +++ b/server/internal/reward/telegram.go @@ -3,8 +3,14 @@ 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" @@ -14,11 +20,30 @@ type tgConfig struct { botUser, channel, botToken, webhookSecret string } -// SetTelegram 注入 TG 配置(经 Service.tg)。 +// 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 } @@ -65,3 +90,90 @@ func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64 } return id, ok, nil } + +// ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取 +// (同 userID 或同 telegramID 只能成功一次)。 +func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (bool, error) { + member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID) + if err != nil || !member { + return false, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return false, 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 false, nil // 已领过(本账户 or 该 tgid) + } + return false, err + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, 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) + } +} + diff --git a/server/internal/reward/telegram_webhook_test.go b/server/internal/reward/telegram_webhook_test.go new file mode 100644 index 0000000..e07f93d --- /dev/null +++ b/server/internal/reward/telegram_webhook_test.go @@ -0,0 +1,53 @@ +package reward + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +type fakeChecker struct{ member bool } + +func (f fakeChecker) IsMember(_ context.Context, _, _ string) (bool, error) { return f.member, nil } + +func newTgSvc(t *testing.T, db *sql.DB, member bool) *Service { + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) + s.SetTelegram("bot", "@ch", "tok", "sec") + s.SetMemberChecker(fakeChecker{member: member}) + return s +} + +func TestClaimTelegram_MemberGrantsOnce(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := newTgSvc(t, db, true) + ok, err := s.ClaimTelegram(context.Background(), 1, "tg-777") + if err != nil || !ok { + t.Fatalf("claim1: %v %v", ok, err) + } + if d := proDays(t, db, 1); d < 3 { + t.Fatalf("pro days=%d", d) + } + // 再领 → 不再发(唯一守卫) + ok2, _ := s.ClaimTelegram(context.Background(), 1, "tg-777") + if ok2 { + t.Fatalf("claimed twice") + } +} + +func TestClaimTelegram_NonMemberNoGrant(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := newTgSvc(t, db, false) + ok, _ := s.ClaimTelegram(context.Background(), 1, "tg-1") + if ok { + t.Fatalf("non-member granted") + } + if d := proDays(t, db, 1); d >= 3 { + t.Fatalf("granted days to non-member: %d", d) + } +}