/start 用户名 密码\n或:/start 用户名-密码"}
}
+ existingBinding := s.telegramBinding(ctx, msg.From.ID)
user, err := s.repo.User.FindByUsername(ctx, username)
if err != nil || user == nil {
+ if existingBinding != nil {
+ _ = s.unbindTelegramUser(ctx, msg.From.ID)
+ return telegramCommandReply{Text: "当前绑定的媒体账号信息已失效,已自动解绑。请使用新的用户名和密码重新绑定。"}
+ }
return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"}
}
if !user.IsActive {
return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"}
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
+ if existingBinding != nil && existingBinding.UserID == user.ID {
+ _ = s.unbindTelegramUser(ctx, msg.From.ID)
+ return telegramCommandReply{Text: "当前绑定账号的密码已失效,已自动解绑。请使用新密码重新绑定。"}
+ }
return telegramCommandReply{Text: "账号或密码错误。"}
}
if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
@@ -774,16 +786,18 @@ func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL
// ── Message Sending ──
+const defaultTelegramMessageDeleteDelay = 120 * time.Second
+
+type telegramSendMessageResponse struct {
+ OK bool `json:"ok"`
+ Result struct {
+ MessageID int `json:"message_id"`
+ } `json:"result"`
+}
+
// reply 通过 Telegram Bot API 发送回复消息。
func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error {
- cfg := map[string]string{}
- if channel != nil {
- configStr := channel.Config
- if s.crypto != nil && configStr != "" {
- configStr = s.crypto.Decrypt(configStr)
- }
- _ = json.Unmarshal([]byte(configStr), &cfg)
- }
+ cfg := s.telegramChannelConfig(channel)
if strings.TrimSpace(cfg["bot_token"]) == "" {
return fmt.Errorf("bot_token not configured")
}
@@ -807,7 +821,73 @@ func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyCha
}
payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard}
}
- return telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second)
+ var sent telegramSendMessageResponse
+ if err := telegramPostJSONDecode(ctx, cfg, "sendMessage", payload, 15*time.Second, &sent); err != nil {
+ return err
+ }
+ if sent.Result.MessageID > 0 {
+ s.scheduleTelegramMessageDelete(cfg, chatID, sent.Result.MessageID)
+ }
+ return nil
+}
+
+func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) {
+ if messageID <= 0 {
+ return
+ }
+ s.scheduleTelegramMessageDelete(s.telegramChannelConfig(channel), chatID, messageID)
+}
+
+func (s *TelegramBotService) scheduleTelegramMessageDelete(cfg map[string]string, chatID, messageID int) {
+ if chatID == 0 || messageID <= 0 || strings.TrimSpace(cfg["bot_token"]) == "" {
+ return
+ }
+ delay := telegramMessageDeleteDelay(cfg)
+ if delay < 0 {
+ return
+ }
+ cfgCopy := make(map[string]string, len(cfg))
+ for k, v := range cfg {
+ cfgCopy[k] = v
+ }
+ go func() {
+ if delay > 0 {
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ <-timer.C
+ }
+ deleteCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ err := telegramPostJSON(deleteCtx, cfgCopy, "deleteMessage", map[string]interface{}{
+ "chat_id": strconv.Itoa(chatID),
+ "message_id": messageID,
+ }, 10*time.Second)
+ if err != nil && s.log != nil {
+ s.log.Debug("telegram deleteMessage failed",
+ zap.Int("chat_id", chatID),
+ zap.Int("message_id", messageID),
+ zap.Error(sanitizeTelegramError(err)),
+ )
+ }
+ }()
+}
+
+func telegramMessageDeleteDelay(cfg map[string]string) time.Duration {
+ for _, key := range []string{"auto_delete_seconds", "message_delete_seconds", "delete_after_seconds"} {
+ raw := strings.TrimSpace(cfg[key])
+ if raw == "" {
+ continue
+ }
+ seconds, err := strconv.Atoi(raw)
+ if err != nil {
+ continue
+ }
+ if seconds < 0 {
+ return -1
+ }
+ return time.Duration(seconds) * time.Second
+ }
+ return defaultTelegramMessageDeleteDelay
}
// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。
@@ -881,13 +961,17 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal
if data == "adult_toggle" {
reply := s.cmdHideAdult(ctx, &msg, nil)
if reply.Text != "" {
- return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
+ err := s.reply(ctx, channel, cb.Message.Chat.ID, reply)
+ s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID)
+ return err
}
return nil
}
if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled {
if reply.Text != "" {
- return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
+ err := s.reply(ctx, channel, cb.Message.Chat.ID, reply)
+ s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID)
+ return err
}
}
return nil
@@ -921,6 +1005,15 @@ func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID
return &binding
}
+func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUserID int) error {
+ if s == nil || s.repo == nil || s.repo.DB == nil || telegramUserID == 0 {
+ return nil
+ }
+ return s.repo.DB.WithContext(ctx).Unscoped().
+ Where("telegram_user_id = ?", int64(telegramUserID)).
+ Delete(&model.TelegramBinding{}).Error
+}
+
func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool {
if s.telegramUserIDConfigured(channel, telegramUserID) {
return true
@@ -1072,35 +1165,47 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
if msg.From.Username != "" {
name = "@" + strings.TrimSpace(msg.From.Username)
}
- var existing model.TelegramBinding
- err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(msg.From.ID)).First(&existing).Error
- if err == nil {
- if existing.UserID != userID {
- if err := s.ensureTelegramAccountBindingAvailable(ctx, userID, int64(msg.From.ID)); err != nil {
+ telegramUserID := int64(msg.From.ID)
+ return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var existing model.TelegramBinding
+ err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error
+ if err == nil {
+ if existing.UserID != userID {
+ if err := s.ensureTelegramAccountBindingAvailableTx(ctx, tx, userID, telegramUserID); err != nil {
+ return err
+ }
+ }
+ if err := tx.Model(&existing).Updates(map[string]any{
+ "telegram_name": name,
+ "chat_id": telegramBindingChatIDForMessage(msg, &existing),
+ "user_id": userID,
+ }).Error; telegramBindingUniqueErr(err) {
+ return errTelegramAccountAlreadyBound
+ } else if err != nil {
return err
}
+ return nil
}
- return s.repo.DB.WithContext(ctx).Model(&existing).Updates(map[string]any{
- "telegram_name": name,
- "chat_id": telegramBindingChatIDForMessage(msg, &existing),
- "user_id": userID,
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil {
+ return err
+ }
+ if err := s.ensureTelegramAccountBindingAvailableTx(ctx, tx, userID, telegramUserID); err != nil {
+ return err
+ }
+ err = tx.Create(&model.TelegramBinding{
+ TelegramUserID: telegramUserID,
+ TelegramName: name,
+ ChatID: telegramBindingChatIDForMessage(msg, nil),
+ UserID: userID,
}).Error
- }
- if err != nil && err != gorm.ErrRecordNotFound {
+ if telegramBindingUniqueErr(err) {
+ return errTelegramAccountAlreadyBound
+ }
return err
- }
- if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil {
- return err
- }
- if err := s.ensureTelegramAccountBindingAvailable(ctx, userID, int64(msg.From.ID)); err != nil {
- return err
- }
- return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{
- TelegramUserID: int64(msg.From.ID),
- TelegramName: name,
- ChatID: telegramBindingChatIDForMessage(msg, nil),
- UserID: userID,
- }).Error
+ })
}
func telegramBindingChatIDForMessage(msg *TelegramMessage, existing *model.TelegramBinding) int64 {
@@ -1127,8 +1232,12 @@ func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 {
}
func (s *TelegramBotService) ensureTelegramAccountBindingAvailable(ctx context.Context, userID string, telegramUserID int64) error {
+ return s.ensureTelegramAccountBindingAvailableTx(ctx, s.repo.DB.WithContext(ctx), userID, telegramUserID)
+}
+
+func (s *TelegramBotService) ensureTelegramAccountBindingAvailableTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
var bound model.TelegramBinding
- err := s.repo.DB.WithContext(ctx).
+ err := tx.WithContext(ctx).
Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID).
First(&bound).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1137,13 +1246,26 @@ func (s *TelegramBotService) ensureTelegramAccountBindingAvailable(ctx context.C
if err != nil {
return err
}
- if user, _ := s.repo.User.FindByID(ctx, bound.UserID); user == nil {
- _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", bound.ID).Error
+ var user model.User
+ if err := tx.WithContext(ctx).Where("id = ?", bound.UserID).First(&user).Error; errors.Is(err, gorm.ErrRecordNotFound) {
+ _ = tx.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", bound.ID).Error
return nil
+ } else if err != nil {
+ return err
}
return errTelegramAccountAlreadyBound
}
+func telegramBindingUniqueErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "idx_telegram_bindings_user_id_active") ||
+ strings.Contains(msg, "telegram_bindings.user_id") ||
+ (strings.Contains(msg, "unique") && strings.Contains(msg, "telegram_bindings"))
+}
+
func parseStartCredentials(args []string) (string, string) {
if len(args) >= 2 {
return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " "))
diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go
index 5f76cf7..ca92824 100644
--- a/internal/service/telegram_bot_user_test.go
+++ b/internal/service/telegram_bot_user_test.go
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
+ "errors"
"strings"
"testing"
@@ -207,6 +208,142 @@ func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T)
}
}
+func TestTelegramStartUnbindsWhenBoundPasswordChanged(t *testing.T) {
+ ctx := t.Context()
+ repos, auth, _, _ := newAuthTestServices(t)
+ user, _, err := auth.Register(ctx, "viewer", "old-password")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 20003,
+ TelegramName: "@viewer",
+ ChatID: 20003,
+ UserID: user.ID,
+ }).Error; err != nil {
+ t.Fatalf("create binding: %v", err)
+ }
+ if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil {
+ t.Fatalf("reset password: %v", err)
+ }
+ if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
+ t.Fatalf("migrate notify channel: %v", err)
+ }
+ cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20003"})
+ if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil {
+ t.Fatalf("create notify channel: %v", err)
+ }
+
+ bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
+ msg := &TelegramMessage{
+ From: TelegramUser{ID: 20003, Username: "viewer", FirstName: "Viewer"},
+ Chat: TelegramChat{ID: 20003, Type: "private"},
+ }
+ reply := bot.cmdStart(ctx, msg, []string{"viewer", "old-password"})
+
+ if !strings.Contains(reply.Text, "已自动解绑") {
+ t.Fatalf("expected auto unbind reply, got %q", reply.Text)
+ }
+ if binding := bot.telegramBinding(ctx, 20003); binding != nil {
+ t.Fatal("stale binding should be removed after password mismatch")
+ }
+}
+
+func TestTelegramSelfSetNameRequiresCurrentPassword(t *testing.T) {
+ ctx := t.Context()
+ repos, auth, _, _ := newAuthTestServices(t)
+ user, _, err := auth.Register(ctx, "viewer", "old-password")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 20004,
+ TelegramName: "@viewer",
+ ChatID: 20004,
+ UserID: user.ID,
+ }).Error; err != nil {
+ t.Fatalf("create binding: %v", err)
+ }
+
+ bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
+ msg := &TelegramMessage{From: TelegramUser{ID: 20004, Username: "viewer"}, Chat: TelegramChat{ID: 20004, Type: "private"}}
+ if reply := bot.selfSetName(ctx, msg, "renamed"); !strings.Contains(reply.Text, "当前密码 新用户名") {
+ t.Fatalf("expected usage reply, got %q", reply.Text)
+ }
+ if reply := bot.selfSetName(ctx, msg, "old-password renamed"); !strings.Contains(reply.Text, "用户名已修改") {
+ t.Fatalf("expected rename success, got %q", reply.Text)
+ }
+ updated, _ := repos.User.FindByID(ctx, user.ID)
+ if updated == nil || updated.Username != "renamed" {
+ t.Fatalf("username not updated: %#v", updated)
+ }
+}
+
+func TestTelegramSelfSetPassWrongCurrentPasswordUnbinds(t *testing.T) {
+ ctx := t.Context()
+ repos, auth, _, _ := newAuthTestServices(t)
+ user, _, err := auth.Register(ctx, "viewer", "old-password")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 20005,
+ TelegramName: "@viewer",
+ ChatID: 20005,
+ UserID: user.ID,
+ }).Error; err != nil {
+ t.Fatalf("create binding: %v", err)
+ }
+
+ bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
+ msg := &TelegramMessage{From: TelegramUser{ID: 20005, Username: "viewer"}, Chat: TelegramChat{ID: 20005, Type: "private"}}
+ reply := bot.selfSetPass(ctx, msg, "wrong-password new-password")
+
+ if !strings.Contains(reply.Text, "已自动解绑") {
+ t.Fatalf("expected auto unbind reply, got %q", reply.Text)
+ }
+ if binding := bot.telegramBinding(ctx, 20005); binding != nil {
+ t.Fatal("binding should be removed after wrong current password")
+ }
+ if _, err := auth.Login(ctx, "viewer", "old-password"); err != nil {
+ t.Fatalf("old password should remain valid after failed change: %v", err)
+ }
+}
+
+func TestTelegramSelfSetPassChangesPasswordWithCurrentPassword(t *testing.T) {
+ ctx := t.Context()
+ repos, auth, _, _ := newAuthTestServices(t)
+ user, _, err := auth.Register(ctx, "viewer", "old-password")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 20006,
+ TelegramName: "@viewer",
+ ChatID: 20006,
+ UserID: user.ID,
+ }).Error; err != nil {
+ t.Fatalf("create binding: %v", err)
+ }
+
+ bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
+ msg := &TelegramMessage{From: TelegramUser{ID: 20006, Username: "viewer"}, Chat: TelegramChat{ID: 20006, Type: "private"}}
+ reply := bot.selfSetPass(ctx, msg, "old-password new-password")
+
+ if !strings.Contains(reply.Text, "密码已修改") {
+ t.Fatalf("expected password change success, got %q", reply.Text)
+ }
+ if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) {
+ t.Fatalf("old password should fail, got %v", err)
+ }
+ if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
+ t.Fatalf("new password should login: %v", err)
+ }
+ if binding := bot.telegramBinding(ctx, 20006); binding == nil {
+ t.Fatal("successful password change should keep telegram binding")
+ }
+}
+
func TestTelegramBindingFromGroupStoresPrivateUserChatID(t *testing.T) {
ctx := t.Context()
repos, auth, _, _ := newAuthTestServices(t)
diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go
index 853d9a4..6873b8b 100644
--- a/internal/service/telegram_menu.go
+++ b/internal/service/telegram_menu.go
@@ -3,17 +3,24 @@ package service
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
+ "gorm.io/gorm"
)
// pendingTTL bounds how long a button-initiated text prompt stays valid.
const pendingTTL = 5 * time.Minute
+var (
+ errRegistrationCodeAlreadyUsed = errors.New("registration code already used")
+ errRegistrationCodeExpired = errors.New("registration code expired")
+)
+
func (s *TelegramBotService) setPending(userID int64, kind string) {
s.pendingMu.Lock()
s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()}
@@ -130,10 +137,10 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
return s.replyDevices(ctx, msg), true
case data == "act_setname":
s.setPending(int64(msg.From.ID), "setname")
- return telegramCommandReply{Text: "请发送新的用户名。"}, true
+ return telegramCommandReply{Text: "请发送:当前密码 新用户名。"}, true
case data == "act_setpass":
s.setPending(int64(msg.From.ID), "setpass")
- return telegramCommandReply{Text: "请发送新的密码(至少 6 位)。"}, true
+ return telegramCommandReply{Text: "请发送:当前密码 新密码(新密码至少 6 位)。"}, true
case strings.HasPrefix(data, "kick:"):
return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true
}
@@ -261,15 +268,15 @@ func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage,
}
func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "请发送:/setname 新用户名"}
+ if len(args) < 2 {
+ return telegramCommandReply{Text: "请发送:/setname 当前密码 新用户名"}
}
return s.selfSetName(ctx, msg, strings.Join(args, " "))
}
func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "请发送:/setpass 新密码"}
+ if len(args) < 2 {
+ return telegramCommandReply{Text: "请发送:/setpass 当前密码 新密码"}
}
return s.selfSetPass(ctx, msg, strings.Join(args, " "))
}
@@ -373,15 +380,22 @@ func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage
return s.replyDevices(ctx, msg)
}
-func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, newName string) telegramCommandReply {
+func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
+ currentPassword, newName := splitCurrentPasswordAndValue(input)
+ if currentPassword == "" || newName == "" {
+ return telegramCommandReply{Text: "请发送:当前密码 新用户名。"}
+ }
newName = strings.TrimSpace(newName)
if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") {
return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"}
}
+ if reply, ok := s.verifyTelegramSelfPassword(ctx, msg, user, currentPassword); !ok {
+ return reply
+ }
if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID {
return telegramCommandReply{Text: "该用户名已被占用,请换一个。"}
}
@@ -391,16 +405,24 @@ func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessa
return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 %s。请用新用户名登录。", newName)}
}
-func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, newPass string) telegramCommandReply {
+func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
+ currentPassword, newPass := splitCurrentPasswordAndValue(input)
+ if currentPassword == "" || newPass == "" {
+ return telegramCommandReply{Text: "请发送:当前密码 新密码。"}
+ }
newPass = strings.TrimSpace(newPass)
if s.auth == nil {
return telegramCommandReply{Text: "服务暂不可用。"}
}
- if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil {
+ if err := s.auth.ChangePassword(ctx, user.ID, currentPassword, newPass); err != nil {
+ if errors.Is(err, ErrInvalidCredentials) {
+ _ = s.unbindTelegramUser(ctx, msg.From.ID)
+ return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}
+ }
return telegramCommandReply{Text: "修改失败:" + err.Error()}
}
if s.device != nil {
@@ -409,6 +431,28 @@ func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessa
return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
}
+func splitCurrentPasswordAndValue(input string) (string, string) {
+ fields := strings.Fields(strings.TrimSpace(input))
+ if len(fields) < 2 {
+ return "", ""
+ }
+ return fields[0], strings.TrimSpace(strings.Join(fields[1:], " "))
+}
+
+func (s *TelegramBotService) verifyTelegramSelfPassword(ctx context.Context, msg *TelegramMessage, user *model.User, currentPassword string) (telegramCommandReply, bool) {
+ if s.auth == nil {
+ return telegramCommandReply{Text: "服务暂不可用。"}, false
+ }
+ if err := s.auth.VerifyPassword(ctx, user.ID, currentPassword); err != nil {
+ if errors.Is(err, ErrInvalidCredentials) {
+ _ = s.unbindTelegramUser(ctx, msg.From.ID)
+ return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}, false
+ }
+ return telegramCommandReply{Text: "验证失败:" + err.Error()}, false
+ }
+ return telegramCommandReply{}, true
+}
+
// ── 兑换码流程 ───────────────────────────────────────────────────────────────
func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply {
@@ -430,30 +474,98 @@ func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *mo
return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 %s,无需再用注册码。", u.Username)}
}
}
- // Generate a memorable default account from the code; users can rename via
- //「改用户名/改密码」afterwards. We avoid asking for two more text turns here.
- username := "u" + strings.ToLower(rc.Code[:8])
- password := randomCode(10)
- user, _, err := s.auth.Register(ctx, username, password)
+ user, password, claimedCode, err := s.createUserFromRegistrationCode(ctx, rc.Code)
if err != nil {
+ if errors.Is(err, errRegistrationCodeAlreadyUsed) {
+ return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
+ }
+ if errors.Is(err, errRegistrationCodeExpired) {
+ return telegramCommandReply{Text: "兑换码已过期。"}
+ }
+ if errors.Is(err, ErrUserLimitReached) {
+ return telegramCommandReply{Text: "注册失败:用户数量已达授权上限。"}
+ }
return telegramCommandReply{Text: "注册失败:" + err.Error()}
}
- if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
- // Code was raced; roll back the just-created account to avoid free signups.
- _ = s.repo.User.Delete(ctx, user.ID)
+ if claimedCode == nil {
return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
}
- if rc.DurationDays > 0 {
- _ = s.applyRenewal(ctx, user.ID, rc.DurationDays)
- }
_ = s.upsertTelegramBinding(ctx, msg, user.ID)
return telegramCommandReply{
Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。",
- username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
+ user.Username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}},
}
}
+func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context, rawCode string) (*model.User, string, *model.RegistrationCode, error) {
+ code := strings.TrimSpace(rawCode)
+ if code == "" {
+ return nil, "", nil, errRegistrationCodeAlreadyUsed
+ }
+ password := randomCode(10)
+ var created model.User
+ var claimed model.RegistrationCode
+ err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL", code, model.RegistrationCodeRegister).
+ First(&claimed).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errRegistrationCodeAlreadyUsed
+ }
+ return err
+ }
+ if claimed.IsExpired() {
+ return errRegistrationCodeExpired
+ }
+ var count int64
+ if err := tx.Model(&model.User{}).Count(&count).Error; err != nil {
+ return err
+ }
+ if count >= LicensedMaxUsers(ctx, s.repo) {
+ return ErrUserLimitReached
+ }
+ hash, err := hashPassword(password)
+ if err != nil {
+ return err
+ }
+ codePrefix := strings.ToLower(claimed.Code)
+ if len(codePrefix) > 8 {
+ codePrefix = codePrefix[:8]
+ }
+ created = model.User{
+ Username: "u" + codePrefix,
+ PasswordHash: hash,
+ Role: "user",
+ Tier: "free",
+ HideAdult: true,
+ ExpiredAt: renewExpiry(nil, claimed.DurationDays),
+ }
+ if err := tx.Create(&created).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(DefaultPermissions(created.ID)).Error; err != nil {
+ return err
+ }
+ now := time.Now()
+ res := tx.Model(&model.RegistrationCode{}).
+ Where("id = ? AND used_at IS NULL", claimed.ID).
+ Updates(map[string]any{"used_by_user_id": created.ID, "used_at": &now})
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return errRegistrationCodeAlreadyUsed
+ }
+ claimed.UsedByUserID = created.ID
+ claimed.UsedAt = &now
+ return nil
+ })
+ if err != nil {
+ return nil, "", nil, err
+ }
+ return &created, password, &claimed, nil
+}
+
func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 280a7d0..4ab14ca 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -12,19 +12,31 @@ export const api = axios.create({
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false
-let refreshSubscribers: Array<(token: string) => void> = []
+let refreshSubscribers: Array<{
+ resolve: (token: string) => void
+ reject: (error: unknown) => void
+}> = []
// Subscribe to token refresh
-function subscribeTokenRefresh(callback: (token: string) => void) {
- refreshSubscribers.push(callback)
+function subscribeTokenRefresh(resolve: (token: string) => void, reject: (error: unknown) => void) {
+ refreshSubscribers.push({ resolve, reject })
}
// Notify all subscribers about new token
function onTokenRefreshed(newToken: string) {
- refreshSubscribers.forEach(callback => callback(newToken))
+ refreshSubscribers.forEach((subscriber) => subscriber.resolve(newToken))
refreshSubscribers = []
}
+function onTokenRefreshFailed(error: unknown) {
+ refreshSubscribers.forEach((subscriber) => subscriber.reject(error))
+ refreshSubscribers = []
+}
+
+function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
+ return Boolean(config?.url?.includes('/auth/refresh'))
+}
+
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
@@ -51,16 +63,21 @@ api.interceptors.response.use(
const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
// If 401 and not already retried
- if (err.response?.status === 401 && originalRequest && !originalRequest._retry) {
+ if (
+ err.response?.status === 401 &&
+ originalRequest &&
+ !originalRequest._retry &&
+ !isRefreshRequest(originalRequest)
+ ) {
if (isRefreshing) {
// Wait for token refresh to complete
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
subscribeTokenRefresh((token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
resolve(api(originalRequest))
- })
+ }, reject)
})
}
@@ -80,10 +97,17 @@ api.interceptors.response.use(
}
} catch (refreshError) {
isRefreshing = false
- refreshSubscribers = []
+ onTokenRefreshFailed(refreshError)
+ useAuthStore.getState().logout()
+ if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
+ window.location.href = '/login'
+ }
+ return Promise.reject(refreshError)
}
// Refresh failed, logout
+ isRefreshing = false
+ onTokenRefreshFailed(err)
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx
index a784a0f..f0f5041 100644
--- a/web/src/pages/AdminPage.tsx
+++ b/web/src/pages/AdminPage.tsx
@@ -1,7 +1,7 @@
import { FormEvent, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
-import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
+import { KeyRound, Loader2, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
@@ -188,6 +188,7 @@ function UsersPanel() {
const [password, setPassword] = useState('')
const [editingID, setEditingID] = useState