feat(server/reward): TG 绑定 token 签发/消费 + GET /v1/tasks/telegram/start
This commit is contained in:
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:<token> 600 <userID>;
|
||||
// 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user