Browse Source

feat: 实现 Telegram 用户绑定功能及抢红包绑定校验

- 新增绑定令牌实体 TgBindToken(6位码,5分钟有效,60秒限频)
- 新增 TgBindService(生成绑定码、绑定用户、查询绑定状态,Redis缓存+DB)
- 新增绑定 API(POST telegram/bind、GET telegram/bind/status)
- Bot /bind 命令改造(检查已绑定、生成绑定码、私聊发送)
- 抢红包添加绑定校验(未绑定提示绑定,已绑定用真实userId)
- 欢迎消息更新(强调需先绑定才能抢红包)
- Telegram 配置新增 BindURL 字段
- 数据库迁移注册 TgBindToken 表
urban 3 tuần trước cách đây
mục cha
commit
a8e35350b0

+ 3 - 0
magic_server/apis/pub/routers.go

@@ -17,6 +17,9 @@ func (h Router) Register(group *gin.RouterGroup) {
 	group.GET("auth/info", server().AuthInfo)                  // 获取认证信息
 	group.POST("auth", server().Auth)                          // 授权登陆
 	group.POST("exchange/callback", server().ExchangeCallback) // 交易所充提回调
+	// Telegram绑定接口
+	group.POST("telegram/bind", server().TelegramBind)             // Telegram绑定
+	group.GET("telegram/bind/status", server().TelegramBindStatus) // 查询绑定状态
 	// 开发者接口
 	if config.AppConf().Mod != config.ModEnvProd {
 		group.POST("dev/auth", server().DevAuth)                // 开发者登录

+ 58 - 0
magic_server/apis/pub/telegram_bind.go

@@ -0,0 +1,58 @@
+package pub
+
+import (
+	"app/commons/services"
+	"github.com/gin-gonic/gin"
+)
+
+// TelegramBind 绑定Telegram账户到平台用户
+// POST /api/v1/pub/telegram/bind
+func (s *Server) TelegramBind(ctx *gin.Context) {
+	c := s.FromContext(ctx)
+
+	if c.UserId() <= 0 {
+		c.Fail("请先登录平台账户")
+		return
+	}
+
+	type request struct {
+		Token string `json:"token" binding:"required"`
+	}
+	req := new(request)
+	if err := c.ShouldBindBodyWithJSON(req); err != nil {
+		c.Fail("参数错误:请提供绑定码")
+		return
+	}
+
+	bindService := &services.TgBindService{}
+	bind, err := bindService.BindTelegramUser(c.UserId(), req.Token)
+	if err != nil {
+		c.Fail(err.Error())
+		return
+	}
+
+	c.Resp(gin.H{
+		"telegramId":      bind.TelegramId,
+		"telegramUsername": bind.TelegramUsername,
+		"bindTime":        bind.BindTime,
+	})
+}
+
+// TelegramBindStatus 查询Telegram绑定状态
+// GET /api/v1/pub/telegram/bind/status?telegramId=xxx
+func (s *Server) TelegramBindStatus(ctx *gin.Context) {
+	c := s.FromContext(ctx)
+	telegramId := c.QueryInt64("telegramId", 0)
+	if telegramId <= 0 {
+		c.Fail("参数错误:telegramId无效")
+		return
+	}
+
+	bindService := &services.TgBindService{}
+	bound, userId := bindService.IsUserBound(telegramId)
+
+	c.Resp(gin.H{
+		"bound":  bound,
+		"userId": userId,
+	})
+}

+ 1 - 1
magic_server/cmds/migrate.go

@@ -78,7 +78,7 @@ var allTables = []MigrateTable{
 	entity.NewTgRedPacket(),       // 红包表
 	entity.NewTgRedPacketRecord(), // 抢红包记录
 	entity.NewTgUserBind(),        // Telegram用户绑定
-
+	entity.NewTgBindToken(),       // Telegram绑定令牌
 }
 
 func migrateMain(db *gorm.DB) {

+ 1 - 0
magic_server/commons/config/telegram.go

@@ -4,4 +4,5 @@ type Telegram struct {
 	BotToken   string `mapstructure:"bot-token" json:"botToken" yaml:"bot-token"`       // Bot Token
 	WebhookURL string `mapstructure:"webhook-url" json:"webhookUrl" yaml:"webhook-url"` // Webhook URL (可选)
 	Debug      bool   `mapstructure:"debug" json:"debug" yaml:"debug"`                  // 调试模式
+	BindURL    string `mapstructure:"bind-url" json:"bindUrl" yaml:"bind-url"`          // 绑定页面URL
 }

+ 26 - 0
magic_server/commons/model/entity/tg_bind_token.go

@@ -0,0 +1,26 @@
+package entity
+
+import "time"
+
+// TgBindToken Telegram绑定令牌表
+type TgBindToken struct {
+	MysqlBaseModel
+	Token            string    `json:"token" gorm:"type:varchar(64);uniqueIndex;comment:绑定令牌;NOT NULL"`
+	TelegramId       int64     `json:"telegramId" gorm:"index;comment:Telegram用户ID;NOT NULL"`
+	TelegramUsername string    `json:"telegramUsername" gorm:"type:varchar(255);comment:Telegram用户名"`
+	UserId           int64     `json:"userId" gorm:"type:bigint;default:0;comment:使用该令牌绑定的平台用户ID"`
+	Status           int       `json:"status" gorm:"type:tinyint;default:0;comment:状态: 0=未使用, 1=已使用, 2=已过期"`
+	ExpireAt         time.Time `json:"expireAt" gorm:"type:timestamp;comment:过期时间;NOT NULL"`
+}
+
+func (*TgBindToken) TableName() string {
+	return ModelPrefix + "tg_bind_token"
+}
+
+func (*TgBindToken) Comment() string {
+	return "Telegram绑定令牌表"
+}
+
+func NewTgBindToken() *TgBindToken {
+	return &TgBindToken{}
+}

+ 192 - 0
magic_server/commons/services/tg_bind_service.go

@@ -0,0 +1,192 @@
+package services
+
+import (
+	"app/commons/core/redisclient"
+	"app/commons/model/entity"
+	"context"
+	"crypto/rand"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"math/big"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+type TgBindService struct {
+	CommonService
+}
+
+const (
+	redisKeyBindToken = "tg_bind_token:%s"       // 绑定令牌
+	redisKeyBindLimit = "tg_bind_limit:%d"        // 生成限频
+	redisKeyBindCache = "tg_bind_cache:%d"        // 绑定缓存
+	bindTokenExpire   = 5 * time.Minute           // 令牌有效期
+	bindLimitExpire   = 60 * time.Second          // 限频间隔
+	bindCacheExpire   = 1 * time.Hour             // 缓存有效期
+)
+
+type bindTokenData struct {
+	TelegramId       int64  `json:"telegramId"`
+	TelegramUsername string `json:"telegramUsername"`
+}
+
+// GenerateBindToken 生成绑定令牌
+func (s *TgBindService) GenerateBindToken(telegramId int64, telegramUsername string) (string, error) {
+	ctx := context.Background()
+	rdb := redisclient.DefaultClient()
+
+	// 限频检查
+	limitKey := fmt.Sprintf(redisKeyBindLimit, telegramId)
+	if rdb.Exists(ctx, limitKey).Val() > 0 {
+		return "", errors.New("请稍后再试(60秒内只能生成一次)")
+	}
+
+	// 生成6位码
+	token := s.randomAlphanumeric(6)
+
+	// 存 Redis
+	tokenKey := fmt.Sprintf(redisKeyBindToken, token)
+	data := bindTokenData{
+		TelegramId:       telegramId,
+		TelegramUsername: telegramUsername,
+	}
+	jsonBytes, _ := json.Marshal(data)
+	rdb.Set(ctx, tokenKey, string(jsonBytes), bindTokenExpire)
+
+	// 存 DB
+	bindToken := &entity.TgBindToken{
+		Token:            token,
+		TelegramId:       telegramId,
+		TelegramUsername: telegramUsername,
+		Status:           0,
+		ExpireAt:         time.Now().Add(bindTokenExpire),
+	}
+	s.DB().Create(bindToken)
+
+	// 设置限频
+	rdb.Set(ctx, limitKey, "1", bindLimitExpire)
+
+	return token, nil
+}
+
+// BindTelegramUser 使用令牌绑定 Telegram 用户到平台账户
+func (s *TgBindService) BindTelegramUser(userId int64, token string) (*entity.TgUserBind, error) {
+	ctx := context.Background()
+	rdb := redisclient.DefaultClient()
+
+	// 从 Redis 查令牌
+	tokenKey := fmt.Sprintf(redisKeyBindToken, token)
+	val, err := rdb.Get(ctx, tokenKey).Result()
+
+	var data bindTokenData
+	if err == nil {
+		json.Unmarshal([]byte(val), &data)
+	} else {
+		// Redis 没有,查 DB
+		var dbToken entity.TgBindToken
+		if err := s.DB().Where("token = ? AND status = 0", token).First(&dbToken).Error; err != nil {
+			return nil, errors.New("绑定码无效或已过期")
+		}
+		if time.Now().After(dbToken.ExpireAt) {
+			return nil, errors.New("绑定码已过期")
+		}
+		data.TelegramId = dbToken.TelegramId
+		data.TelegramUsername = dbToken.TelegramUsername
+	}
+
+	// 检查 telegramId 是否已绑定其他用户
+	var existBind entity.TgUserBind
+	err = s.DB().Where("telegram_id = ? AND bind_status = 1", data.TelegramId).First(&existBind).Error
+	if err == nil && existBind.UserId != userId {
+		return nil, errors.New("该Telegram账户已绑定其他平台账户")
+	}
+	if err == nil && existBind.UserId == userId {
+		// 已绑定同一用户,直接返回
+		return &existBind, nil
+	}
+
+	// 检查 userId 是否已绑定其他 telegramId
+	err = s.DB().Where("user_id = ? AND bind_status = 1", userId).First(&existBind).Error
+	if err == nil && existBind.TelegramId != data.TelegramId {
+		return nil, errors.New("该平台账户已绑定其他Telegram账户")
+	}
+
+	// 创建绑定记录
+	bind := &entity.TgUserBind{
+		UserId:            userId,
+		TelegramId:        data.TelegramId,
+		TelegramUsername:  data.TelegramUsername,
+		TelegramFirstName: "",
+		BindStatus:        1,
+		BindTime:          time.Now(),
+	}
+	if err := s.DB().Create(bind).Error; err != nil {
+		return nil, fmt.Errorf("绑定失败: %w", err)
+	}
+
+	// 更新令牌状态
+	s.DB().Model(&entity.TgBindToken{}).Where("token = ?", token).Updates(map[string]interface{}{
+		"status":  1,
+		"user_id": userId,
+	})
+
+	// 删除 Redis 令牌,设置绑定缓存
+	rdb.Del(ctx, tokenKey)
+	cacheKey := fmt.Sprintf(redisKeyBindCache, data.TelegramId)
+	rdb.Set(ctx, cacheKey, fmt.Sprintf("%d", userId), bindCacheExpire)
+
+	return bind, nil
+}
+
+// IsUserBound 检查 telegramId 是否已绑定,返回 (是否绑定, userId)
+func (s *TgBindService) IsUserBound(telegramId int64) (bool, int64) {
+	ctx := context.Background()
+	rdb := redisclient.DefaultClient()
+
+	// 查 Redis 缓存
+	cacheKey := fmt.Sprintf(redisKeyBindCache, telegramId)
+	val, err := rdb.Get(ctx, cacheKey).Result()
+	if err == nil && val != "" {
+		var userId int64
+		fmt.Sscanf(val, "%d", &userId)
+		if userId > 0 {
+			return true, userId
+		}
+	}
+
+	// 查 DB
+	var bind entity.TgUserBind
+	err = s.DB().Where("telegram_id = ? AND bind_status = 1", telegramId).First(&bind).Error
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return false, 0
+		}
+		return false, 0
+	}
+
+	// 写入缓存
+	rdb.Set(ctx, cacheKey, fmt.Sprintf("%d", bind.UserId), bindCacheExpire)
+	return true, bind.UserId
+}
+
+// GetBindByUserId 根据平台 userId 查询绑定记录
+func (s *TgBindService) GetBindByUserId(userId int64) (*entity.TgUserBind, error) {
+	var bind entity.TgUserBind
+	if err := s.DB().Where("user_id = ? AND bind_status = 1", userId).First(&bind).Error; err != nil {
+		return nil, err
+	}
+	return &bind, nil
+}
+
+// randomAlphanumeric 生成 n 位大写字母+数字随机码
+func (s *TgBindService) randomAlphanumeric(n int) string {
+	const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	result := make([]byte, n)
+	for i := range result {
+		num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
+		result[i] = charset[num.Int64()]
+	}
+	return string(result)
+}

+ 47 - 31
magic_server/telegram/commands.go

@@ -1,7 +1,9 @@
 package telegram
 
 import (
+	"app/commons/config"
 	"app/commons/core"
+	"app/commons/services"
 	"fmt"
 	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
 )
@@ -39,21 +41,18 @@ func handleStartCommand(msg *tgbotapi.Message) {
 • 群组定期发放红包福利
 • 支持普通红包(固定金额)
 • 支持手气红包(随机金额)
-• 查看抢红包记录
-• 查看余额
 
 📝 使用步骤:
-1️⃣ 使用 /bind 绑定平台账户
-2️⃣ 等待群内红包发放
-3️⃣ 点击按钮抢红包
+1️⃣ 前往平台注册账户
+2️⃣ 使用 /bind 绑定平台账户
+3️⃣ 等待群内红包发放
+4️⃣ 点击按钮抢红包
 
 💡 快速开始:
 /help - 查看帮助
 /bind - 绑定账户
-/balance - 查看余额
-/records - 查看记录
 
-祝你抢红包开心!🎊`
+⚠️ 未绑定账户无法抢红包`
 
 	sendTextMessage(msg.Chat.ID, welcomeText)
 }
@@ -70,8 +69,6 @@ func handleHelpCommand(msg *tgbotapi.Message) {
 
 👤 账户相关:
 /bind - 绑定平台账户
-/balance - 查看余额
-/records - 查看抢红包记录
 
 ℹ️ 其他:
 /help - 显示此帮助
@@ -81,58 +78,75 @@ func handleHelpCommand(msg *tgbotapi.Message) {
 💡 提示:
 • 抢红包需要先绑定账户
 • 每个红包只能抢一次
-• 红包24小时内有效
-• 使用 /groupinfo 可以查看群组ID`
+• 红包24小时内有效`
 
 	sendTextMessage(msg.Chat.ID, helpText)
 }
 
 // handleBindCommand 处理 /bind 命令
 func handleBindCommand(msg *tgbotapi.Message) {
-	// 生成绑定链接
-	bindURL := generateBindURL(msg.From.ID)
+	telegramId := msg.From.ID
+	telegramUsername := msg.From.UserName
 
-	text := fmt.Sprintf(`🔗 绑定平台账户
+	bindService := &services.TgBindService{}
 
-请点击下方链接完成绑定:
-%s
+	// 检查是否已绑定
+	bound, _ := bindService.IsUserBound(telegramId)
+	if bound {
+		sendTextMessage(msg.Chat.ID, "✅ 您已绑定平台账户,无需重复绑定。")
+		return
+	}
+
+	// 生成绑定码
+	token, err := bindService.GenerateBindToken(telegramId, telegramUsername)
+	if err != nil {
+		sendTextMessage(msg.Chat.ID, fmt.Sprintf("⚠️ %s", err.Error()))
+		return
+	}
 
-💡 提示:
-• 如果未注册,请先注册账户
-• 绑定后即可抢红包
-• 绑定链接仅供本人使用`, bindURL)
+	bindURL := generateBindURL(telegramId, token)
+
+	// 如果在群组中,提示查看私聊
+	if msg.Chat.Type == "group" || msg.Chat.Type == "supergroup" {
+		sendTextMessage(msg.Chat.ID, "📩 绑定信息已通过私信发送,请查看机器人私聊消息。")
+	}
+
+	// 发送绑定信息到私聊
+	text := fmt.Sprintf("🔗 绑定平台账户\n\n"+
+		"您的绑定码: *%s*\n"+
+		"⏰ 有效期: 5分钟\n\n"+
+		"📝 绑定方式:\n"+
+		"1. 登录平台后点击下方链接\n"+
+		"2. 或在平台输入绑定码\n\n"+
+		"⚠️ 请先注册平台账户", token)
 
-	// 创建按钮
 	keyboard := tgbotapi.NewInlineKeyboardMarkup(
 		tgbotapi.NewInlineKeyboardRow(
 			tgbotapi.NewInlineKeyboardButtonURL("🔗 立即绑定", bindURL),
 		),
 	)
 
-	sendMessageWithKeyboard(msg.Chat.ID, text, keyboard)
+	// 私聊发送(telegramId 就是私聊 chatId)
+	sendMessageWithKeyboard(telegramId, text, keyboard)
 }
 
 // handleBalanceCommand 处理 /balance 命令
 func handleBalanceCommand(msg *tgbotapi.Message) {
-	// TODO: 调用 API 获取余额
 	sendTextMessage(msg.Chat.ID, "💰 余额查询功能开发中...")
 }
 
 // handleRecordsCommand 处理 /records 命令
 func handleRecordsCommand(msg *tgbotapi.Message) {
-	// TODO: 调用 API 获取抢红包记录
 	sendTextMessage(msg.Chat.ID, "📊 记录查询功能开发中...")
 }
 
 // handleGroupInfoCommand 处理 /groupinfo 命令
 func handleGroupInfoCommand(msg *tgbotapi.Message) {
-	// 检查是否在群组中
 	if msg.Chat.Type != "group" && msg.Chat.Type != "supergroup" {
 		sendTextMessage(msg.Chat.ID, "⚠️ 此命令只能在群组中使用")
 		return
 	}
 
-	// 获取群组信息
 	chatType := msg.Chat.Type
 	if chatType == "group" {
 		chatType = "普通群组"
@@ -163,13 +177,15 @@ func handleGroupInfoCommand(msg *tgbotapi.Message) {
 		chatType,
 	)
 
-	// 使用 HTML 解析模式发送消息,这样可以点击复制
 	sendHTMLMessage(msg.Chat.ID, infoText)
 }
 
 // generateBindURL 生成绑定链接
-func generateBindURL(telegramID int64) string {
-	// TODO: 生成真实的绑定链接
+func generateBindURL(telegramID int64, token string) string {
 	baseURL := "https://your-domain.com/bind"
-	return fmt.Sprintf("%s?telegramId=%d", baseURL, telegramID)
+	telegramConfig := config.EnvConf().Telegram
+	if telegramConfig != nil && telegramConfig.BindURL != "" {
+		baseURL = telegramConfig.BindURL
+	}
+	return fmt.Sprintf("%s?telegramId=%d&token=%s", baseURL, telegramID, token)
 }

+ 9 - 9
magic_server/telegram/messages.go

@@ -89,13 +89,13 @@ func sendGroupWelcome(chatID int64) {
 🎉 功能介绍:
 • 定期发放群组红包福利
 • 支持普通红包和手气红包
-• 查看抢红包记录
-• 余额管理
 
 📝 使用方法:
-1. 发送 /bind 绑定账户
-2. 等待群内红包
-3. 点击按钮抢红包
+1. 前往平台注册账户
+2. 私聊我发送 /bind 绑定账户
+3. 绑定后即可抢群内红包
+
+⚠️ 未绑定账户无法抢红包
 
 💡 使用 /help 查看详细帮助`
 
@@ -114,11 +114,11 @@ func sendNewMemberWelcome(chatID int64, username, firstName string) {
 🧧 本群定期发放红包福利!
 
 📝 快速开始:
-1. 发送 /bind 绑定账户
-2. 等待群内红包
-3. 点击按钮抢红包
+1. 前往平台注册账户
+2. 私聊我发送 /bind 绑定账户
+3. 绑定后即可抢红包
 
-💡 使用 /help 查看更多功能`, displayName)
+⚠️ 未绑定账户无法抢红包`, displayName)
 
 	sendTextMessage(chatID, text)
 }

+ 18 - 7
magic_server/telegram/redpacket.go

@@ -28,20 +28,31 @@ func grabRedPacket(callback *tgbotapi.CallbackQuery, packetNo string) {
 	telegramID := callback.From.ID
 	username := callback.From.UserName
 
-	// 直接调用 service 层抢红包
-	redPacketService := &services.RedPacketService{}
+	// 检查绑定状态
+	bindService := &services.TgBindService{}
+	bound, userId := bindService.IsUserBound(telegramID)
 
+	if !bound {
+		answerCallback(callback.ID, "⚠️ 请先绑定账户!私聊我发送 /bind")
+		// 私聊发送绑定提示
+		sendTextMessage(telegramID, "⚠️ 您尚未绑定平台账户,无法抢红包。\n\n请发送 /bind 获取绑定链接。")
+		core.Log.Infof("抢红包 - 用户: %s, 红包: %s, 结果: 未绑定", username, packetNo)
+		return
+	}
+
+	// 用真实 userId 抢红包
+	redPacketService := &services.RedPacketService{}
 	record, err := redPacketService.GrabRedPacket(
 		packetNo,
-		0, // Telegram用户暂无平台userId
+		userId,
 		telegramID,
 		username,
 	)
 
 	if err != nil {
 		answerCallback(callback.ID, err.Error())
-		core.Log.Infof("抢红包 - 用户: %s, 红包: %s, 结果: %s",
-			username, packetNo, err.Error())
+		core.Log.Infof("抢红包 - 用户: %s(userId:%d), 红包: %s, 结果: %s",
+			username, userId, packetNo, err.Error())
 		return
 	}
 
@@ -54,6 +65,6 @@ func grabRedPacket(callback *tgbotapi.CallbackQuery, packetNo string) {
 		callback.Message.Text, username, amount)
 	editMessage(callback.Message.Chat.ID, callback.Message.MessageID, editText)
 
-	core.Log.Infof("抢红包 - 用户: %s, 红包: %s, 金额: %s, 结果: 成功",
-		username, packetNo, record.Amount.String())
+	core.Log.Infof("抢红包 - 用户: %s(userId:%d), 红包: %s, 金额: %s, 结果: 成功",
+		username, userId, packetNo, record.Amount.String())
 }