diff --git a/server/internal/reward/handler.go b/server/internal/reward/handler.go index 5abd6af..636a940 100644 --- a/server/internal/reward/handler.go +++ b/server/internal/reward/handler.go @@ -2,6 +2,7 @@ package reward import ( "encoding/json" + "fmt" "net/http" "strconv" "strings" @@ -10,6 +11,35 @@ import ( "github.com/wangjia/pangolin/server/internal/auth" ) +// tgMsgs 是 TG bot 回执文案表(中/英双语)。key 与 ClaimResult / 失效态对应。 +type tgMsgSet struct { + expired, granted, already, notMember, errored string +} + +var tgMsgsZH = tgMsgSet{ + expired: "链接已失效,请回 App 重新点「验证领取」。", + granted: "已到账 +3 天 Pro,感谢加入!", + already: "你已领取过本奖励(+3 天),无需重复领取。", + notMember: "未检测到你已加入频道 %s,请先加入频道,再回 App 点「验证领取」。", + errored: "验证出错,请稍后重试。", +} + +var tgMsgsEN = tgMsgSet{ + expired: "This link has expired. Please tap \u201cVerify & claim\u201d in the app again.", + granted: "+3 days of Pro credited — thanks for joining!", + already: "You've already claimed this reward (+3 days) — no need to claim again.", + notMember: "You don't seem to be a member of %s yet. Please join the channel first, then tap \u201cVerify & claim\u201d in the app.", + errored: "Verification failed, please try again later.", +} + +// tgMsgSetFor 按 Telegram language_code 选中/英文案表:zh* → 中文,其余(含空)→ 英文。 +func tgMsgSetFor(languageCode string) tgMsgSet { + if strings.HasPrefix(languageCode, "zh") { + return tgMsgsZH + } + return tgMsgsEN +} + const inviteLinkBase = "https://pangolin.yanmeiai.com/i/" type Handler struct { @@ -88,7 +118,8 @@ func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { Message struct { Text string `json:"text"` From struct { - ID int64 `json:"id"` + ID int64 `json:"id"` + LanguageCode string `json:"language_code"` } `json:"from"` } `json:"message"` } @@ -98,21 +129,24 @@ func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(text, "/start ") || fromID == 0 { return } + msgs := tgMsgSetFor(upd.Message.From.LanguageCode) 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 重新点「验证领取」。") + h.svc.SendTelegram(ctx, fromID, msgs.expired) return } - granted, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10)) + result, 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()+" 再点验证;若已加入且领取过则无需重复。") + h.svc.SendTelegram(ctx, fromID, msgs.errored) + case result == ClaimGranted: + h.svc.SendTelegram(ctx, fromID, msgs.granted) + case result == ClaimAlready: + h.svc.SendTelegram(ctx, fromID, msgs.already) + default: // ClaimNotMember + h.svc.SendTelegram(ctx, fromID, fmt.Sprintf(msgs.notMember, h.svc.Channel())) } } diff --git a/server/internal/reward/telegram.go b/server/internal/reward/telegram.go index 864818c..f420633 100644 --- a/server/internal/reward/telegram.go +++ b/server/internal/reward/telegram.go @@ -91,32 +91,44 @@ func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64 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) (bool, error) { +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 || !member { - return false, err + if err != nil { + return ClaimNotMember, err + } + if !member { + return ClaimNotMember, nil } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { - return false, err + 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 false, nil // 已领过(本账户 or 该 tgid) + return ClaimAlready, nil // 已领过(本账户 or 该 tgid) } - return false, err + return ClaimNotMember, err } if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { - return false, err + return ClaimNotMember, err } if err := tx.Commit(); err != nil { - return false, err + return ClaimNotMember, err } - return true, nil + return ClaimGranted, nil } // apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。 diff --git a/server/internal/reward/telegram_webhook_test.go b/server/internal/reward/telegram_webhook_test.go index e07f93d..55c6a3d 100644 --- a/server/internal/reward/telegram_webhook_test.go +++ b/server/internal/reward/telegram_webhook_test.go @@ -25,17 +25,17 @@ 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) + res, err := s.ClaimTelegram(context.Background(), 1, "tg-777") + if err != nil || res != ClaimGranted { + t.Fatalf("claim1: %v %v", res, 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") + // 再领 → 已领取过(唯一守卫),不再发 + res2, err2 := s.ClaimTelegram(context.Background(), 1, "tg-777") + if err2 != nil || res2 != ClaimAlready { + t.Fatalf("claim2: %v %v", res2, err2) } } @@ -43,11 +43,32 @@ 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") + res, err := s.ClaimTelegram(context.Background(), 1, "tg-1") + if err != nil || res != ClaimNotMember { + t.Fatalf("claim: %v %v", res, err) } if d := proDays(t, db, 1); d >= 3 { t.Fatalf("granted days to non-member: %d", d) } } + +func TestTgMsgSetFor_LanguageSelection(t *testing.T) { + cases := []struct { + lang string + zh bool + }{ + {"zh-hans", true}, + {"zh", true}, + {"zh-CN", true}, + {"en", false}, + {"de", false}, + {"", false}, + } + for _, c := range cases { + got := tgMsgSetFor(c.lang) + isZH := got.granted == tgMsgsZH.granted + if isZH != c.zh { + t.Fatalf("lang=%q: expected zh=%v, got zh=%v", c.lang, c.zh, isZH) + } + } +}