Files
pangolin/server/internal/reward/handler.go
wangjia 2c94b53a9e
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
fix(server/reward): TG bot 回复区分已领取/未加入两态 + 按 Telegram language_code 中英双语
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 11:46:55 +08:00

153 lines
4.9 KiB
Go

package reward
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/wangjia/pangolin/server/internal/apierr"
"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 {
svc *Service
st *Store
tgEnabled bool
channel string
}
func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler {
return &Handler{svc: s, st: st, tgEnabled: tgEnabled, channel: channel}
}
func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid, ok := auth.UserIDFromContext(ctx)
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
code, err := h.svc.EnsureCode(ctx, uid)
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
invited, converted, earned, err := h.st.Summary(ctx, uid)
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
joined, _ := h.st.TelegramClaimed(ctx, uid)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"invite_code": code,
"invite_link": inviteLinkBase + code,
"invited": invited,
"converted": converted,
"earned_days": earned,
"telegram": map[string]any{
"enabled": h.tgEnabled,
"joined": joined,
"channel": h.channel,
},
})
}
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)})
}
// 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"`
LanguageCode string `json:"language_code"`
} `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
}
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, msgs.expired)
return
}
result, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10))
switch {
case err != nil:
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()))
}
}