diff --git a/server/internal/reward/handler.go b/server/internal/reward/handler.go index ce2d892..dd4f8bd 100644 --- a/server/internal/reward/handler.go +++ b/server/internal/reward/handler.go @@ -53,3 +53,23 @@ func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { }, }) } + +func (h *Handler) TelegramStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + if !h.tgEnabled { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } + tok, err := h.svc.IssueTelegramToken(ctx, uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"deep_link": h.svc.TelegramDeepLink(tok)}) +} diff --git a/server/internal/reward/service.go b/server/internal/reward/service.go index 65bc574..c0a1a00 100644 --- a/server/internal/reward/service.go +++ b/server/internal/reward/service.go @@ -5,7 +5,10 @@ import ( "crypto/rand" "database/sql" "log/slog" + "sync" "time" + + "github.com/redis/go-redis/v9" ) type Granter interface { @@ -20,6 +23,11 @@ type Service struct { g Granter cfg Config now func() time.Time + + rdb *redis.Client + tg tgConfig + memMu sync.Mutex + memTok map[string]int64 } func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service { @@ -134,3 +142,8 @@ func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64 } 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 +} diff --git a/server/internal/reward/telegram.go b/server/internal/reward/telegram.go new file mode 100644 index 0000000..9155893 --- /dev/null +++ b/server/internal/reward/telegram.go @@ -0,0 +1,67 @@ +package reward + +import ( + "context" + "crypto/rand" + "encoding/hex" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +type tgConfig struct { + botUser, channel, botToken, webhookSecret string +} + +// SetTelegram 注入 TG 配置(经 Service.tg)。 +func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) { + s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret} +} + +// 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 +} diff --git a/server/internal/reward/telegram_token_test.go b/server/internal/reward/telegram_token_test.go new file mode 100644 index 0000000..89838ee --- /dev/null +++ b/server/internal/reward/telegram_token_test.go @@ -0,0 +1,30 @@ +package reward + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestTelegramToken_IssueThenConsumeOnce(t *testing.T) { + db := openDB(t) + seedU(t, db, 5, "u5") + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) + s.SetTelegram("pangolin_reward_bot", "@pangolin_app", "bot-token", "wh-secret") // 内存兜底(rdb nil) + + tok, err := s.IssueTelegramToken(context.Background(), 5) + if err != nil || tok == "" { + t.Fatalf("issue: %q %v", tok, err) + } + uid, ok, _ := s.ConsumeTelegramToken(context.Background(), tok) + if !ok || uid != 5 { + t.Fatalf("consume: %d %v", uid, ok) + } + _, ok2, _ := s.ConsumeTelegramToken(context.Background(), tok) + if ok2 { + t.Fatalf("token consumable twice") + } +}