From 05d6cc013ab5b64253a2e1c1ca54171d155f3f71 Mon Sep 17 00:00:00 2001 From: iskanye Date: Sun, 1 Mar 2026 16:14:02 +0300 Subject: [PATCH 1/5] feat: sorted list --- internal/models/queue.go | 12 +++- internal/repositories/redis/queue.go | 88 +++++++++++++++++++--------- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/internal/models/queue.go b/internal/models/queue.go index 336a764..64005f3 100644 --- a/internal/models/queue.go +++ b/internal/models/queue.go @@ -3,6 +3,8 @@ package models import ( "fmt" "strings" + + "github.com/redis/go-redis/v9" ) type Queue struct { @@ -23,5 +25,13 @@ func (q *Queue) Key() string { } type QueueEntry struct { - ChatID string + Position int + ChatID string +} + +func (e *QueueEntry) ToRedis() redis.Z { + return redis.Z{ + Score: float64(e.Position), + Member: e.ChatID, + } } diff --git a/internal/repositories/redis/queue.go b/internal/repositories/redis/queue.go index 6bffe9b..2893736 100644 --- a/internal/repositories/redis/queue.go +++ b/internal/repositories/redis/queue.go @@ -19,14 +19,33 @@ func (s *Storage) Push( // Пытаемся найти данный айди в очереди // Если есть, значит пользователь уже есть в очереди - _, err := s.cl.LPos(ctx, queue.Key(), entry.ChatID, redis.LPosArgs{}).Result() + _, err := s.cl.ZRank(ctx, queue.Key(), entry.ChatID).Result() if err == nil { return fmt.Errorf("%s: %w", op, repositories.ErrAlreadyInQueue) } else if !errors.Is(err, redis.Nil) { return fmt.Errorf("%s: %w", op, err) } - _, err = s.cl.RPush(ctx, queue.Key(), entry.ChatID).Result() + if entry.Position == 0 { + // Ищем последнюю позицию на которую можно поставить элемент + entries, err := s.cl.ZRangeWithScores(ctx, queue.Key(), 0, -1).Result() + if err == nil { + pos := 1 + // Проходимся по списку позиций, пока не находим пустое место + for _, e := range entries { + if e.Score != float64(pos) { + entry.Position = pos + break + } + pos++ + } + entry.Position = pos + } else { + return fmt.Errorf("%s: %w", op, err) + } + } + + _, err = s.cl.ZAdd(ctx, queue.Key(), entry.ToRedis()).Result() if err != nil { return fmt.Errorf("%s: %w", op, err) } @@ -40,7 +59,7 @@ func (s *Storage) Pop( ) (models.QueueEntry, error) { const op = "redis.Pop" - student, err := s.cl.LPop(ctx, queue.Key()).Result() + student, err := s.cl.ZPopMin(ctx, queue.Key()).Result() if err != nil { if errors.Is(err, redis.Nil) { return models.QueueEntry{}, fmt.Errorf("%s: %w", op, repositories.ErrNotFound) @@ -48,8 +67,21 @@ func (s *Storage) Pop( return models.QueueEntry{}, fmt.Errorf("%s: %w", op, err) } + // Уменьшаем позиции в очереди на 1 + entries, err := s.cl.ZRange(ctx, queue.Key(), 0, -1).Result() + if err != nil { + return models.QueueEntry{}, fmt.Errorf("%s: %w", op, err) + } + + for _, entry := range entries { + _, err := s.cl.ZIncrBy(ctx, queue.Key(), -1, entry).Result() + if err != nil { + return models.QueueEntry{}, fmt.Errorf("%s: %w", op, err) + } + } + return models.QueueEntry{ - ChatID: student, + ChatID: student[0].Member.(string), }, nil } @@ -60,7 +92,7 @@ func (s *Storage) Range( ) ([]models.QueueEntry, error) { const op = "redis.Range" - students, err := s.cl.LRange(ctx, queue.Key(), 0, n-1).Result() + students, err := s.cl.ZRangeWithScores(ctx, queue.Key(), 0, n-1).Result() // Очередь не создана if len(students) == 0 { return nil, fmt.Errorf("%s: %w", op, repositories.ErrNotFound) @@ -72,7 +104,8 @@ func (s *Storage) Range( entries := make([]models.QueueEntry, 0, n) for _, student := range students { entries = append(entries, models.QueueEntry{ - ChatID: student, + Position: int(student.Score), + ChatID: student.Member.(string), }) } @@ -103,7 +136,7 @@ func (s *Storage) GetPosition( ) (int64, error) { const op = "redis.GetPosition" - pos, err := s.cl.LPos(ctx, queue.Key(), entry.ChatID, redis.LPosArgs{}).Result() + pos, err := s.cl.ZScore(ctx, queue.Key(), entry.ChatID).Result() if err != nil { if errors.Is(err, redis.Nil) { return 0, fmt.Errorf("%s: %w", op, repositories.ErrNotFound) @@ -111,8 +144,7 @@ func (s *Storage) GetPosition( return 0, fmt.Errorf("%s: %w", op, err) } - // Отсчёт позиции должен начинаться с 1 - return pos + 1, nil + return int64(pos), nil } func (s *Storage) Len( @@ -121,7 +153,7 @@ func (s *Storage) Len( ) (int64, error) { const op = "redis.Len" - len, err := s.cl.LLen(ctx, queue.Key()).Result() + len, err := s.cl.ZCard(ctx, queue.Key()).Result() if err != nil { return 0, fmt.Errorf("%s: %w", op, err) } @@ -136,7 +168,7 @@ func (s *Storage) LetAhead( ) error { const op = "redis.LetAhead" - pos, err := s.cl.LPos(ctx, queue.Key(), entry.ChatID, redis.LPosArgs{}).Result() + pos, err := s.cl.ZRank(ctx, queue.Key(), entry.ChatID).Result() if err != nil { // Списка нет или элемента нет в списке if errors.Is(err, redis.Nil) { @@ -145,23 +177,25 @@ func (s *Storage) LetAhead( return fmt.Errorf("%s: %w", op, err) } - ahead, err := s.cl.LIndex(ctx, queue.Key(), pos+1).Result() - if err != nil { + ahead, err := s.cl.ZRangeWithScores(ctx, queue.Key(), pos+1, pos+1).Result() + if errors.Is(err, redis.Nil) { // Элемент не найден так как изначальный элемент в конце списка - if errors.Is(err, redis.Nil) { - return fmt.Errorf("%s: %w", op, repositories.ErrNotFound) + // или перед ним дырка => можем увеличить ранг без последствий + _, err := s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() + if err != nil { + return fmt.Errorf("%s: %w", op, err) } - return fmt.Errorf("%s: %w", op, err) - } - - // Свапаем элементы - _, err = s.cl.LSet(ctx, queue.Key(), pos, ahead).Result() - if err != nil { - return fmt.Errorf("%s: %w", op, err) - } - - _, err = s.cl.LSet(ctx, queue.Key(), pos+1, entry.ChatID).Result() - if err != nil { + } else if err == nil { + // Элемент спереди найден => меняем им ранги + _, err := s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + _, err = s.cl.ZIncrBy(ctx, queue.Key(), -1, ahead[0].Member.(string)).Result() + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + } else { return fmt.Errorf("%s: %w", op, err) } @@ -175,7 +209,7 @@ func (s *Storage) Remove( ) error { const op = "redis.Remove" - _, err := s.cl.LRem(ctx, queue.Key(), 1, entry.ChatID).Result() + _, err := s.cl.ZRem(ctx, queue.Key(), entry.ChatID).Result() if err != nil { return fmt.Errorf("%s: %w", op, err) } From f800f1b7c8f2a552982e173ad02748965a537be7 Mon Sep 17 00:00:00 2001 From: iskanye Date: Sun, 1 Mar 2026 18:37:25 +0300 Subject: [PATCH 2/5] feat: working let ahead --- internal/handlers/bot/queue.go | 3 -- internal/repositories/redis/queue.go | 4 +- internal/services/errors.go | 1 - internal/services/queue/queue.go | 28 +---------- internal/services/queue/queue_test.go | 67 +-------------------------- 5 files changed, 6 insertions(+), 97 deletions(-) diff --git a/internal/handlers/bot/queue.go b/internal/handlers/bot/queue.go index ea96bbd..24e38ff 100644 --- a/internal/handlers/bot/queue.go +++ b/internal/handlers/bot/queue.go @@ -107,9 +107,6 @@ func (b *Bot) LetAhead(c telebot.Context) error { if errors.Is(err, services.ErrNotFound) { return c.Send("Вы не записаны в очередь") } - if errors.Is(err, services.ErrQueueEnd) { - return c.Send("Вы последний в очереди") - } return err } diff --git a/internal/repositories/redis/queue.go b/internal/repositories/redis/queue.go index 2893736..62a951e 100644 --- a/internal/repositories/redis/queue.go +++ b/internal/repositories/redis/queue.go @@ -34,7 +34,6 @@ func (s *Storage) Push( // Проходимся по списку позиций, пока не находим пустое место for _, e := range entries { if e.Score != float64(pos) { - entry.Position = pos break } pos++ @@ -170,7 +169,7 @@ func (s *Storage) LetAhead( pos, err := s.cl.ZRank(ctx, queue.Key(), entry.ChatID).Result() if err != nil { - // Списка нет или элемента нет в списке + // Элемента нет в списке if errors.Is(err, redis.Nil) { return fmt.Errorf("%s: %w", op, repositories.ErrNotFound) } @@ -191,6 +190,7 @@ func (s *Storage) LetAhead( if err != nil { return fmt.Errorf("%s: %w", op, err) } + _, err = s.cl.ZIncrBy(ctx, queue.Key(), -1, ahead[0].Member.(string)).Result() if err != nil { return fmt.Errorf("%s: %w", op, err) diff --git a/internal/services/errors.go b/internal/services/errors.go index 2b15a33..a355385 100644 --- a/internal/services/errors.go +++ b/internal/services/errors.go @@ -5,5 +5,4 @@ import "errors" var ( ErrNotFound = errors.New("resource not found") ErrAlreadyInQueue = errors.New("user already in queue") - ErrQueueEnd = errors.New("entry is at the end of the queue") ) diff --git a/internal/services/queue/queue.go b/internal/services/queue/queue.go index 2cddf50..357063e 100644 --- a/internal/services/queue/queue.go +++ b/internal/services/queue/queue.go @@ -196,9 +196,9 @@ func (q *Queue) LetAhead( log.Info("Trying to let someone go ahead in queue") - pos, err := q.queuePos.GetPosition(ctx, queue, entry) + err := q.queueSwap.LetAhead(ctx, queue, entry) if err != nil { - log.Error("Failed to get entry position", + log.Error("Failed to let someone go ahead", slog.String("err", err.Error()), ) @@ -208,30 +208,6 @@ func (q *Queue) LetAhead( return fmt.Errorf("%s: %w", op, err) } - len, err := q.queueLength.Len(ctx, queue) - if err != nil { - // Нет смысла проверять на ErrNotFound, так как - // на данный момент мы уже получили позицию в очереди - log.Error("Failed to get queue length", - slog.String("err", err.Error()), - ) - return fmt.Errorf("%s: %w", op, err) - } - - if pos == len { - // Пользователь в конце очереди - не сможет пропустить - log.Warn("User is at the queue end") - return fmt.Errorf("%s: %w", op, services.ErrQueueEnd) - } - - err = q.queueSwap.LetAhead(ctx, queue, entry) - if err != nil { - log.Error("Failed to let someone go ahead", - slog.String("err", err.Error()), - ) - return fmt.Errorf("%s: %w", op, err) - } - log.Info("Successfully swapped with person ahead") return nil diff --git a/internal/services/queue/queue_test.go b/internal/services/queue/queue_test.go index 49d27a7..cddb8e9 100644 --- a/internal/services/queue/queue_test.go +++ b/internal/services/queue/queue_test.go @@ -339,8 +339,6 @@ func TestQueueLetAhead_Success(t *testing.T) { ChatID: gofakeit.ID(), } - queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(2, nil) - queueLength.EXPECT().Len(ctx, subjectQueue).Return(5, nil) queueSwap.EXPECT().LetAhead(ctx, subjectQueue, entry).Return(nil) err := service.LetAhead(ctx, subjectQueue, entry) @@ -359,72 +357,13 @@ func TestQueueLetAhead_NotFound(t *testing.T) { ChatID: gofakeit.ID(), } - queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(0, repositories.ErrNotFound) + queueSwap.EXPECT().LetAhead(ctx, subjectQueue, entry).Return(repositories.ErrNotFound) err := service.LetAhead(ctx, subjectQueue, entry) require.ErrorIs(t, err, services.ErrNotFound) } -func TestQueueLetAhead_QueueEnd(t *testing.T) { - service, ctx := newService(t) - - subjectQueue := models.Queue{ - Group: gofakeit.ID(), - Subject: gofakeit.Noun(), - } - - entry := models.QueueEntry{ - ChatID: gofakeit.ID(), - } - - queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(5, nil) - queueLength.EXPECT().Len(ctx, subjectQueue).Return(5, nil) - - err := service.LetAhead(ctx, subjectQueue, entry) - require.NotEmpty(t, err) - assert.ErrorIs(t, err, services.ErrQueueEnd) -} - -func TestQueueLetAhead_Failure1(t *testing.T) { - service, ctx := newService(t) - - subjectQueue := models.Queue{ - Group: gofakeit.ID(), - Subject: gofakeit.Noun(), - } - - entry := models.QueueEntry{ - ChatID: gofakeit.ID(), - } - - expectedErr := errors.New("внезапная ошибка на стороне базы данных") - queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(0, expectedErr) - - err := service.LetAhead(ctx, subjectQueue, entry) - require.ErrorIs(t, err, expectedErr) -} - -func TestQueueLetAhead_Failure2(t *testing.T) { - service, ctx := newService(t) - - subjectQueue := models.Queue{ - Group: gofakeit.ID(), - Subject: gofakeit.Noun(), - } - - entry := models.QueueEntry{ - ChatID: gofakeit.ID(), - } - - expectedErr := errors.New("внезапная ошибка на стороне базы данных") - queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(1, nil) - queueLength.EXPECT().Len(ctx, subjectQueue).Return(0, expectedErr) - - err := service.LetAhead(ctx, subjectQueue, entry) - require.ErrorIs(t, err, expectedErr) -} - -func TestQueueLetAhead_Failure3(t *testing.T) { +func TestQueueLetAhead_Failure(t *testing.T) { service, ctx := newService(t) subjectQueue := models.Queue{ @@ -437,8 +376,6 @@ func TestQueueLetAhead_Failure3(t *testing.T) { } expectedErr := errors.New("внезапная ошибка на стороне базы данных") - queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(2, nil) - queueLength.EXPECT().Len(ctx, subjectQueue).Return(5, nil) queueSwap.EXPECT().LetAhead(ctx, subjectQueue, entry).Return(expectedErr) err := service.LetAhead(ctx, subjectQueue, entry) From 550dcb3e644d0e8c5b648c11716a08ade5c9057d Mon Sep 17 00:00:00 2001 From: iskanye Date: Mon, 2 Mar 2026 11:42:13 +0300 Subject: [PATCH 3/5] feat: priority btn --- internal/bot/bot.go | 42 ++++++++++-------- internal/handlers/bot/queue.go | 58 +++++++++++++++++++++++-- internal/handlers/bot/users.go | 5 +-- internal/interfaces/bot.go | 1 + internal/repositories/errors.go | 2 +- internal/repositories/redis/cache.go | 2 +- internal/repositories/redis/queue.go | 61 +++++++++++++++++---------- internal/services/errors.go | 1 + internal/services/queue/cache.go | 2 +- internal/services/queue/cache_test.go | 2 +- internal/services/queue/queue.go | 3 ++ 11 files changed, 129 insertions(+), 50 deletions(-) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index cb6a8bd..720cda6 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -16,15 +16,16 @@ type Bot struct { b *tele.Bot // Кнопки - editBtn *tele.Btn - chooseBtn *tele.Btn - returnBtn *tele.Btn - refreshBtn *tele.Btn - pushBtn *tele.Btn - letAheadBtn *tele.Btn - popBtn *tele.Btn - clearBtn *tele.Btn - removeBtn *tele.Btn + editBtn *tele.Btn + chooseBtn *tele.Btn + returnBtn *tele.Btn + refreshBtn *tele.Btn + pushBtn *tele.Btn + pushPriorityBtn *tele.Btn + letAheadBtn *tele.Btn + popBtn *tele.Btn + clearBtn *tele.Btn + removeBtn *tele.Btn // Менюшки startMenu *tele.ReplyMarkup @@ -65,6 +66,7 @@ func New( returnBtn := markup.Data("Назад", "return") refreshBtn := markup.Data("Обновить", "update") pushBtn := markup.Data("Записаться", "push") + pushPriorityBtn := markup.Data("Записаться на место", "push-priority") popBtn := markup.Data("Позвать на сдачу", "pop") clearBtn := markup.Data("Очистить очередь", "clear") letAheadBtn := markup.Data("Пропустить в очереди", "let-ahead") @@ -81,6 +83,7 @@ func New( subjectMenu.Inline( markup.Row(returnBtn, refreshBtn), markup.Row(pushBtn), + markup.Row(pushPriorityBtn), markup.Row(letAheadBtn), markup.Row(removeBtn), ) @@ -90,6 +93,7 @@ func New( subjectAdminMenu.Inline( markup.Row(returnBtn, refreshBtn), markup.Row(pushBtn), + markup.Row(pushPriorityBtn), markup.Row(letAheadBtn), markup.Row(removeBtn), markup.Row(popBtn), @@ -99,15 +103,16 @@ func New( return &Bot{ b: b, - editBtn: &editBtn, - chooseBtn: &chooseBtn, - returnBtn: &returnBtn, - refreshBtn: &refreshBtn, - pushBtn: &pushBtn, - letAheadBtn: &letAheadBtn, - popBtn: &popBtn, - clearBtn: &clearBtn, - removeBtn: &removeBtn, + editBtn: &editBtn, + chooseBtn: &chooseBtn, + returnBtn: &returnBtn, + refreshBtn: &refreshBtn, + pushBtn: &pushBtn, + pushPriorityBtn: &pushPriorityBtn, + letAheadBtn: &letAheadBtn, + popBtn: &popBtn, + clearBtn: &clearBtn, + removeBtn: &removeBtn, startMenu: startMenu, subjectMenu: subjectMenu, @@ -162,6 +167,7 @@ func (b *Bot) Register( // Требует получить очередь из кеша authorized.Handle(b.refreshBtn, handlers.Refresh, middlewares.GetQueue) authorized.Handle(b.pushBtn, handlers.Push, middlewares.GetQueue) + authorized.Handle(b.pushPriorityBtn, handlers.PushPriority, middlewares.GetQueue) authorized.Handle(b.letAheadBtn, handlers.LetAhead, middlewares.GetQueue) authorized.Handle(b.removeBtn, handlers.Remove, middlewares.GetQueue) authorized.Handle(b.popBtn, handlers.Pop, middlewares.GetQueue) diff --git a/internal/handlers/bot/queue.go b/internal/handlers/bot/queue.go index 24e38ff..5780f17 100644 --- a/internal/handlers/bot/queue.go +++ b/internal/handlers/bot/queue.go @@ -40,6 +40,58 @@ func (b *Bot) Push(c telebot.Context) error { return b.showSubject(c, queue, entry) } +// Пушает в очередь с указанием на конкретное место +func (b *Bot) PushPriority(c telebot.Context) error { + queue := c.Get("queue").(models.Queue) + + entry := models.QueueEntry{ + ChatID: fmt.Sprint(c.Chat().ID), + } + + // Пробуем пока не получится встать в очередь +getPos: + if err := c.Send("Введите на какую позицию в очереди хотите встать"); err != nil { + return nil + } + + // Получаем от пользователя ввод числа + if err := b.dialogue(c, func(ch <-chan string, c telebot.Context) error { + for pos := range ch { + if posInt, err := strconv.Atoi(pos); err == nil { + entry.Position = posInt + break + } + + if err := c.Send("Невозможно привести к числу, попробуйте снова"); err != nil { + return nil + } + } + return nil + }); err != nil { + return err + } + + err := b.queueService.Push(b.ctx, queue, entry) + if err != nil { + if errors.Is(err, services.ErrAlreadyInQueue) { + return c.Send("Вы уже в очереди") + } + if errors.Is(err, services.ErrPlaceTaken) { + // Если место занято продолжаем цикл, пока пользователь не введёт + // доступную позицию в очереди + err = c.Send("Место уже занято") + if err != nil { + return err + } + goto getPos + } + + return err + } + + return b.showSubject(c, queue, entry) +} + // Попает из очереди func (b *Bot) Pop(c telebot.Context) error { queue := c.Get("queue").(models.Queue) @@ -251,7 +303,7 @@ func (b *Bot) showSubject( sb.WriteString("\nОчередь пуста") } else if err == nil { // Находим имена пользователей - for i, entry := range entries { + for _, entry := range entries { chatID, err := strconv.ParseInt(entry.ChatID, 10, 64) if err != nil { return err @@ -264,9 +316,9 @@ func (b *Bot) showSubject( // Если это текущий пользователь, то выделяем жирным для видимости if chatID == c.Chat().ID { - fmt.Fprintf(&sb, "\n*%3d. %s*", i+1, user.Name) + fmt.Fprintf(&sb, "\n*%3d. %s*", entry.Position, user.Name) } else { - fmt.Fprintf(&sb, "\n%3d. %s", i+1, user.Name) + fmt.Fprintf(&sb, "\n%3d. %s", entry.Position, user.Name) } } diff --git a/internal/handlers/bot/users.go b/internal/handlers/bot/users.go index d88893d..066b0e1 100644 --- a/internal/handlers/bot/users.go +++ b/internal/handlers/bot/users.go @@ -131,7 +131,7 @@ func (b *Bot) getUser(c tele.Context) (models.User, error) { } // Читаем оставшиеся данные - err = b.dialogue(c, func(ch <-chan string, c tele.Context) error { + if err = b.dialogue(c, func(ch <-chan string, c tele.Context) error { var err error menu.Reply( @@ -165,8 +165,7 @@ func (b *Bot) getUser(c tele.Context) (models.User, error) { } return nil - }) - if err != nil { + }); err != nil { return models.User{}, err } diff --git a/internal/interfaces/bot.go b/internal/interfaces/bot.go index 18771dc..a13cbb1 100644 --- a/internal/interfaces/bot.go +++ b/internal/interfaces/bot.go @@ -18,6 +18,7 @@ type BotHandlers interface { ChooseSubjectButton(telebot.Context) error Refresh(telebot.Context) error Push(telebot.Context) error + PushPriority(telebot.Context) error Pop(telebot.Context) error LetAhead(telebot.Context) error Clear(telebot.Context) error diff --git a/internal/repositories/errors.go b/internal/repositories/errors.go index 2353b9d..6c6e276 100644 --- a/internal/repositories/errors.go +++ b/internal/repositories/errors.go @@ -5,5 +5,5 @@ import "errors" var ( ErrNotFound = errors.New("resource not found") ErrAlreadyInQueue = errors.New("user already in queue") - ErrCacheMiss = errors.New("cache miss") + ErrPlaceTaken = errors.New("place in queue already taken") ) diff --git a/internal/repositories/redis/cache.go b/internal/repositories/redis/cache.go index 01d92c0..364bf8a 100644 --- a/internal/repositories/redis/cache.go +++ b/internal/repositories/redis/cache.go @@ -35,7 +35,7 @@ func (s *Storage) Get( val, err := s.cl.Get(ctx, key).Result() if err != nil { if errors.Is(err, redis.Nil) { - return "", fmt.Errorf("%s: %w", op, repositories.ErrCacheMiss) + return "", fmt.Errorf("%s: %w", op, repositories.ErrNotFound) } return "", fmt.Errorf("%s: %w", op, err) diff --git a/internal/repositories/redis/queue.go b/internal/repositories/redis/queue.go index 62a951e..6bdefc6 100644 --- a/internal/repositories/redis/queue.go +++ b/internal/repositories/redis/queue.go @@ -29,19 +29,33 @@ func (s *Storage) Push( if entry.Position == 0 { // Ищем последнюю позицию на которую можно поставить элемент entries, err := s.cl.ZRangeWithScores(ctx, queue.Key(), 0, -1).Result() - if err == nil { - pos := 1 - // Проходимся по списку позиций, пока не находим пустое место - for _, e := range entries { - if e.Score != float64(pos) { - break - } - pos++ + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + pos := 1 + // Проходимся по списку позиций, пока не находим пустое место + for _, e := range entries { + if e.Score != float64(pos) { + break } - entry.Position = pos - } else { + pos++ + } + entry.Position = pos + } else { + // Проверяем что на данное место можно встать + entry, err := s.cl.ZRangeByScore(ctx, queue.Key(), &redis.ZRangeBy{ + Min: fmt.Sprint(entry.Position), + Max: fmt.Sprint(entry.Position), + }).Result() + if err != nil { return fmt.Errorf("%s: %w", op, err) } + + if len(entry) != 0 { + // На данное место уже можно встать => неверно выбрана позиция + return fmt.Errorf("%s: %w", op, repositories.ErrPlaceTaken) + } } _, err = s.cl.ZAdd(ctx, queue.Key(), entry.ToRedis()).Result() @@ -176,7 +190,10 @@ func (s *Storage) LetAhead( return fmt.Errorf("%s: %w", op, err) } - ahead, err := s.cl.ZRangeWithScores(ctx, queue.Key(), pos+1, pos+1).Result() + ahead, err := s.cl.ZRangeByScore(ctx, queue.Key(), &redis.ZRangeBy{ + Min: fmt.Sprint(pos + 1), + Max: fmt.Sprint(pos + 1), + }).Result() if errors.Is(err, redis.Nil) { // Элемент не найден так как изначальный элемент в конце списка // или перед ним дырка => можем увеличить ранг без последствий @@ -184,18 +201,18 @@ func (s *Storage) LetAhead( if err != nil { return fmt.Errorf("%s: %w", op, err) } - } else if err == nil { - // Элемент спереди найден => меняем им ранги - _, err := s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() - if err != nil { - return fmt.Errorf("%s: %w", op, err) - } + } else if err != nil { + return fmt.Errorf("%s: %w", op, err) + } - _, err = s.cl.ZIncrBy(ctx, queue.Key(), -1, ahead[0].Member.(string)).Result() - if err != nil { - return fmt.Errorf("%s: %w", op, err) - } - } else { + // Элемент спереди найден => меняем им ранги + _, err = s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + _, err = s.cl.ZIncrBy(ctx, queue.Key(), -1, ahead[0]).Result() + if err != nil { return fmt.Errorf("%s: %w", op, err) } diff --git a/internal/services/errors.go b/internal/services/errors.go index a355385..cb79545 100644 --- a/internal/services/errors.go +++ b/internal/services/errors.go @@ -5,4 +5,5 @@ import "errors" var ( ErrNotFound = errors.New("resource not found") ErrAlreadyInQueue = errors.New("user already in queue") + ErrPlaceTaken = errors.New("place in queue already taken") ) diff --git a/internal/services/queue/cache.go b/internal/services/queue/cache.go index a6e8787..ecce821 100644 --- a/internal/services/queue/cache.go +++ b/internal/services/queue/cache.go @@ -58,7 +58,7 @@ func (q *Queue) GetFromCache( slog.String("err", err.Error()), ) - if errors.Is(err, repositories.ErrCacheMiss) { + if errors.Is(err, repositories.ErrNotFound) { return models.Queue{}, fmt.Errorf("%s: %w", op, services.ErrNotFound) } return models.Queue{}, fmt.Errorf("%s: %w", op, err) diff --git a/internal/services/queue/cache_test.go b/internal/services/queue/cache_test.go index c9c1094..7cfebc1 100644 --- a/internal/services/queue/cache_test.go +++ b/internal/services/queue/cache_test.go @@ -97,7 +97,7 @@ func TestGetFromCache_CacheMiss(t *testing.T) { chatID := gofakeit.Int64() - cache.EXPECT().Get(ctx, fmt.Sprint(chatID)).Return("", repositories.ErrCacheMiss) + cache.EXPECT().Get(ctx, fmt.Sprint(chatID)).Return("", repositories.ErrNotFound) queue, err := service.GetFromCache(ctx, chatID) require.ErrorIs(t, err, services.ErrNotFound) diff --git a/internal/services/queue/queue.go b/internal/services/queue/queue.go index 357063e..bd855aa 100644 --- a/internal/services/queue/queue.go +++ b/internal/services/queue/queue.go @@ -79,6 +79,9 @@ func (q *Queue) Push( if errors.Is(err, repositories.ErrAlreadyInQueue) { return fmt.Errorf("%s: %w", op, services.ErrAlreadyInQueue) } + if errors.Is(err, repositories.ErrPlaceTaken) { + return fmt.Errorf("%s: %w", op, services.ErrPlaceTaken) + } return fmt.Errorf("%s: %w", op, err) } From 44a4e79418d72d9b1d3585d6c1ef6bc53307529d Mon Sep 17 00:00:00 2001 From: iskanye Date: Tue, 3 Mar 2026 09:30:18 +0300 Subject: [PATCH 4/5] test: queue priority --- internal/services/queue/queue_test.go | 76 ++++++++++++++++++++------- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/internal/services/queue/queue_test.go b/internal/services/queue/queue_test.go index cddb8e9..eaa76c3 100644 --- a/internal/services/queue/queue_test.go +++ b/internal/services/queue/queue_test.go @@ -81,9 +81,9 @@ func TestQueuePush_Success(t *testing.T) { Subject: gofakeit.Noun(), } - chatID := gofakeit.ID() entry := models.QueueEntry{ - ChatID: chatID, + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } queueBase.EXPECT().Push(ctx, subjectQueue, entry).Return(nil) @@ -100,9 +100,9 @@ func TestQueuePush_AlreadyInQueue(t *testing.T) { Subject: gofakeit.Noun(), } - chatID := gofakeit.ID() entry := models.QueueEntry{ - ChatID: chatID, + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } queueBase.EXPECT().Push(ctx, subjectQueue, entry).Return(repositories.ErrAlreadyInQueue) @@ -111,6 +111,36 @@ func TestQueuePush_AlreadyInQueue(t *testing.T) { require.ErrorIs(t, err, services.ErrAlreadyInQueue) } +func TestQueuePush_PlaceTaken(t *testing.T) { + service, ctx := newService(t) + + subjectQueue := models.Queue{ + Group: gofakeit.ID(), + Subject: gofakeit.Noun(), + } + + pos := gofakeit.Int() + + entry1 := models.QueueEntry{ + Position: pos, + ChatID: gofakeit.ID(), + } + + entry2 := models.QueueEntry{ + Position: pos, + ChatID: gofakeit.ID(), + } + + queueBase.EXPECT().Push(ctx, subjectQueue, entry1).Return(nil) + queueBase.EXPECT().Push(ctx, subjectQueue, entry2).Return(repositories.ErrPlaceTaken) + + err := service.Push(ctx, subjectQueue, entry1) + require.Empty(t, err) + + err = service.Push(ctx, subjectQueue, entry2) + require.ErrorIs(t, err, services.ErrPlaceTaken) +} + func TestQueuePush_Failure(t *testing.T) { service, ctx := newService(t) @@ -119,9 +149,9 @@ func TestQueuePush_Failure(t *testing.T) { Subject: gofakeit.Noun(), } - chatID := gofakeit.ID() entry := models.QueueEntry{ - ChatID: chatID, + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } expectedErr := errors.New("внезапная ошибка на стороне базы данных") @@ -141,9 +171,9 @@ func TestQueuePop_Success(t *testing.T) { Subject: gofakeit.Noun(), } - chatID := gofakeit.ID() entry := models.QueueEntry{ - ChatID: chatID, + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } // Попаем айдишник @@ -243,9 +273,9 @@ func TestQueueRange_Success(t *testing.T) { } entries := []models.QueueEntry{ - {ChatID: gofakeit.ID()}, - {ChatID: gofakeit.ID()}, - {ChatID: gofakeit.ID()}, + {Position: gofakeit.Int(), ChatID: gofakeit.ID()}, + {Position: gofakeit.Int(), ChatID: gofakeit.ID()}, + {Position: gofakeit.Int(), ChatID: gofakeit.ID()}, } queueViewer.EXPECT().Range(ctx, subjectQueue, queueRange).Return(entries, nil) @@ -297,7 +327,8 @@ func TestQueueRemove_Success(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } queueRemover.EXPECT().Remove(ctx, subjectQueue, entry).Return(nil) @@ -315,7 +346,8 @@ func TestQueueRemove_Failure(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } expectedErr := errors.New("внезапная ошибка на стороне базы данных") @@ -336,7 +368,8 @@ func TestQueueLetAhead_Success(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } queueSwap.EXPECT().LetAhead(ctx, subjectQueue, entry).Return(nil) @@ -354,7 +387,8 @@ func TestQueueLetAhead_NotFound(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } queueSwap.EXPECT().LetAhead(ctx, subjectQueue, entry).Return(repositories.ErrNotFound) @@ -372,7 +406,8 @@ func TestQueueLetAhead_Failure(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } expectedErr := errors.New("внезапная ошибка на стороне базы данных") @@ -393,7 +428,8 @@ func TestQueueGetPosition_Success(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } expectedPos := int64(3) @@ -413,7 +449,8 @@ func TestQueueGetPosition_NotFound(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } queuePos.EXPECT().GetPosition(ctx, subjectQueue, entry).Return(0, repositories.ErrNotFound) @@ -432,7 +469,8 @@ func TestQueueGetPosition_Failure(t *testing.T) { } entry := models.QueueEntry{ - ChatID: gofakeit.ID(), + Position: gofakeit.Int(), + ChatID: gofakeit.ID(), } expectedErr := errors.New("внезапная ошибка на стороне базы данных") From 2e81d5d81ed55c02e8faff2044f47c4dcaaae46e Mon Sep 17 00:00:00 2001 From: iskanye Date: Tue, 3 Mar 2026 09:59:21 +0300 Subject: [PATCH 5/5] fix: wrong pos --- internal/handlers/bot/queue.go | 5 ++-- internal/repositories/redis/queue.go | 42 +++++++++++++++------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/internal/handlers/bot/queue.go b/internal/handlers/bot/queue.go index 5780f17..61e8e38 100644 --- a/internal/handlers/bot/queue.go +++ b/internal/handlers/bot/queue.go @@ -57,12 +57,12 @@ getPos: // Получаем от пользователя ввод числа if err := b.dialogue(c, func(ch <-chan string, c telebot.Context) error { for pos := range ch { - if posInt, err := strconv.Atoi(pos); err == nil { + if posInt, err := strconv.Atoi(pos); err == nil && posInt > 0 { entry.Position = posInt break } - if err := c.Send("Невозможно привести к числу, попробуйте снова"); err != nil { + if err := c.Send("Невозможно привести к числу или неверное число, попробуйте снова"); err != nil { return nil } } @@ -149,7 +149,6 @@ func (b *Bot) Pop(c telebot.Context) error { // Пропускает следующего в очереди func (b *Bot) LetAhead(c telebot.Context) error { queue := c.Get("queue").(models.Queue) - entry := models.QueueEntry{ ChatID: fmt.Sprint(c.Chat().ID), } diff --git a/internal/repositories/redis/queue.go b/internal/repositories/redis/queue.go index 6bdefc6..ec5db69 100644 --- a/internal/repositories/redis/queue.go +++ b/internal/repositories/redis/queue.go @@ -106,13 +106,13 @@ func (s *Storage) Range( const op = "redis.Range" students, err := s.cl.ZRangeWithScores(ctx, queue.Key(), 0, n-1).Result() + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } // Очередь не создана if len(students) == 0 { return nil, fmt.Errorf("%s: %w", op, repositories.ErrNotFound) } - if err != nil { - return nil, fmt.Errorf("%s: %w", op, err) - } entries := make([]models.QueueEntry, 0, n) for _, student := range students { @@ -181,7 +181,7 @@ func (s *Storage) LetAhead( ) error { const op = "redis.LetAhead" - pos, err := s.cl.ZRank(ctx, queue.Key(), entry.ChatID).Result() + pos, err := s.cl.ZScore(ctx, queue.Key(), entry.ChatID).Result() if err != nil { // Элемента нет в списке if errors.Is(err, redis.Nil) { @@ -190,30 +190,34 @@ func (s *Storage) LetAhead( return fmt.Errorf("%s: %w", op, err) } + // Проверяем есть ли на позиции выше элемент очереди + aheadPos := fmt.Sprint(pos + 1) ahead, err := s.cl.ZRangeByScore(ctx, queue.Key(), &redis.ZRangeBy{ - Min: fmt.Sprint(pos + 1), - Max: fmt.Sprint(pos + 1), + Min: aheadPos, + Max: aheadPos, }).Result() - if errors.Is(err, redis.Nil) { + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + if len(ahead) == 0 { // Элемент не найден так как изначальный элемент в конце списка // или перед ним дырка => можем увеличить ранг без последствий _, err := s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() if err != nil { return fmt.Errorf("%s: %w", op, err) } - } else if err != nil { - return fmt.Errorf("%s: %w", op, err) - } - - // Элемент спереди найден => меняем им ранги - _, err = s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() - if err != nil { - return fmt.Errorf("%s: %w", op, err) - } + } else { + // Элемент спереди найден => меняем им ранги + _, err = s.cl.ZIncrBy(ctx, queue.Key(), 1, entry.ChatID).Result() + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } - _, err = s.cl.ZIncrBy(ctx, queue.Key(), -1, ahead[0]).Result() - if err != nil { - return fmt.Errorf("%s: %w", op, err) + _, err = s.cl.ZIncrBy(ctx, queue.Key(), -1, ahead[0]).Result() + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } } return nil