fix(server/reward): TG bot 回复区分已领取/未加入两态 + 按 Telegram language_code 中英双语
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 23s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 6s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 36s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 34s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 3s
ci-pangolin / Go — build + test (pull_request) Failing after 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Failing after 10m52s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 23s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 6s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 36s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 34s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 3s
ci-pangolin / Go — build + test (pull_request) Failing after 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Failing after 10m52s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
@@ -2,6 +2,7 @@ package reward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,6 +11,35 @@ import (
|
|||||||
"github.com/wangjia/pangolin/server/internal/auth"
|
"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/"
|
const inviteLinkBase = "https://pangolin.yanmeiai.com/i/"
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -88,7 +118,8 @@ func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
Message struct {
|
Message struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
From struct {
|
From struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
LanguageCode string `json:"language_code"`
|
||||||
} `json:"from"`
|
} `json:"from"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
}
|
}
|
||||||
@@ -98,21 +129,24 @@ func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !strings.HasPrefix(text, "/start ") || fromID == 0 {
|
if !strings.HasPrefix(text, "/start ") || fromID == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
msgs := tgMsgSetFor(upd.Message.From.LanguageCode)
|
||||||
token := strings.TrimSpace(strings.TrimPrefix(text, "/start "))
|
token := strings.TrimSpace(strings.TrimPrefix(text, "/start "))
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token)
|
uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.svc.SendTelegram(ctx, fromID, "链接已失效,请回 App 重新点「验证领取」。")
|
h.svc.SendTelegram(ctx, fromID, msgs.expired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
granted, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10))
|
result, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10))
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
h.svc.SendTelegram(ctx, fromID, "验证出错,请稍后重试。")
|
h.svc.SendTelegram(ctx, fromID, msgs.errored)
|
||||||
case granted:
|
case result == ClaimGranted:
|
||||||
h.svc.SendTelegram(ctx, fromID, "已到账 +3 天 Pro,感谢加入!")
|
h.svc.SendTelegram(ctx, fromID, msgs.granted)
|
||||||
default:
|
case result == ClaimAlready:
|
||||||
h.svc.SendTelegram(ctx, fromID, "请先加入频道 "+h.svc.Channel()+" 再点验证;若已加入且领取过则无需重复。")
|
h.svc.SendTelegram(ctx, fromID, msgs.already)
|
||||||
|
default: // ClaimNotMember
|
||||||
|
h.svc.SendTelegram(ctx, fromID, fmt.Sprintf(msgs.notMember, h.svc.Channel()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,32 +91,44 @@ func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64
|
|||||||
return id, ok, nil
|
return id, ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClaimResult 是 ClaimTelegram 的结果三态,供上层(handler)按态回不同文案。
|
||||||
|
type ClaimResult int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClaimGranted ClaimResult = iota // 发放成功
|
||||||
|
ClaimAlready // 已领取过(本账户或该 telegram_id)
|
||||||
|
ClaimNotMember // 不在频道
|
||||||
|
)
|
||||||
|
|
||||||
// ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取
|
// ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取
|
||||||
// (同 userID 或同 telegramID 只能成功一次)。
|
// (同 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)
|
member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID)
|
||||||
if err != nil || !member {
|
if err != nil {
|
||||||
return false, err
|
return ClaimNotMember, err
|
||||||
|
}
|
||||||
|
if !member {
|
||||||
|
return ClaimNotMember, nil
|
||||||
}
|
}
|
||||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return ClaimNotMember, err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
now := s.now()
|
now := s.now()
|
||||||
if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil {
|
if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil {
|
||||||
if err == ErrClaimExists {
|
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 {
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return false, err
|
return ClaimNotMember, err
|
||||||
}
|
}
|
||||||
return true, nil
|
return ClaimGranted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。
|
// apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ func TestClaimTelegram_MemberGrantsOnce(t *testing.T) {
|
|||||||
db := openDB(t)
|
db := openDB(t)
|
||||||
seedU(t, db, 1, "u1")
|
seedU(t, db, 1, "u1")
|
||||||
s := newTgSvc(t, db, true)
|
s := newTgSvc(t, db, true)
|
||||||
ok, err := s.ClaimTelegram(context.Background(), 1, "tg-777")
|
res, err := s.ClaimTelegram(context.Background(), 1, "tg-777")
|
||||||
if err != nil || !ok {
|
if err != nil || res != ClaimGranted {
|
||||||
t.Fatalf("claim1: %v %v", ok, err)
|
t.Fatalf("claim1: %v %v", res, err)
|
||||||
}
|
}
|
||||||
if d := proDays(t, db, 1); d < 3 {
|
if d := proDays(t, db, 1); d < 3 {
|
||||||
t.Fatalf("pro days=%d", d)
|
t.Fatalf("pro days=%d", d)
|
||||||
}
|
}
|
||||||
// 再领 → 不再发(唯一守卫)
|
// 再领 → 已领取过(唯一守卫),不再发
|
||||||
ok2, _ := s.ClaimTelegram(context.Background(), 1, "tg-777")
|
res2, err2 := s.ClaimTelegram(context.Background(), 1, "tg-777")
|
||||||
if ok2 {
|
if err2 != nil || res2 != ClaimAlready {
|
||||||
t.Fatalf("claimed twice")
|
t.Fatalf("claim2: %v %v", res2, err2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +43,32 @@ func TestClaimTelegram_NonMemberNoGrant(t *testing.T) {
|
|||||||
db := openDB(t)
|
db := openDB(t)
|
||||||
seedU(t, db, 1, "u1")
|
seedU(t, db, 1, "u1")
|
||||||
s := newTgSvc(t, db, false)
|
s := newTgSvc(t, db, false)
|
||||||
ok, _ := s.ClaimTelegram(context.Background(), 1, "tg-1")
|
res, err := s.ClaimTelegram(context.Background(), 1, "tg-1")
|
||||||
if ok {
|
if err != nil || res != ClaimNotMember {
|
||||||
t.Fatalf("non-member granted")
|
t.Fatalf("claim: %v %v", res, err)
|
||||||
}
|
}
|
||||||
if d := proDays(t, db, 1); d >= 3 {
|
if d := proDays(t, db, 1); d >= 3 {
|
||||||
t.Fatalf("granted days to non-member: %d", d)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user