feat(server/reward): TG getChatMember 真校验 + ClaimTelegram + POST /tg/webhook
This commit is contained in:
@@ -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 <token>`
|
||||
// → 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()+" 再点验证;若已加入且领取过则无需重复。")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user