diff --git a/api/main.proto b/api/main.proto index df9eb97..6a67f12 100644 --- a/api/main.proto +++ b/api/main.proto @@ -1875,7 +1875,7 @@ message SetScreenSoundRsp { message MapItem { string kind = 1; - int32 itemID = 2; + string itemID = 2; string label = 3; int32 x = 4; int32 y = 5; @@ -1895,7 +1895,7 @@ message GetGameMapRsp { message UpdateGameMapItemReq { int32 gameID = 1; string kind = 2; - int32 itemID = 3; + string itemID = 3; int32 x = 4; int32 y = 5; bool hidden = 6; @@ -2008,7 +2008,7 @@ message ParticipationRsp { } message Team { - int32 ID = 1; + string ID = 1; string name = 2; string status = 3; string password = 4; @@ -2076,7 +2076,7 @@ message GetGameResultsRsp { message GameReview { int32 ID = 1; int32 gameID = 2; - int32 teamID = 3; + string teamID = 3; string team_name = 4; int32 rating = 5; string text = 6; @@ -2088,7 +2088,7 @@ message GameReview { message CreateGameReviewReq { int32 gameID = 1; - int32 teamID = 2; + string teamID = 2; string password = 3; int32 rating = 4; string text = 5; @@ -2187,12 +2187,12 @@ message AddTeamReq { } message AddTeamRsp { - int32 ID = 1; + string ID = 1; string password = 2; } message UpdateTeamReq { - int32 ID = 1; + string ID = 1; string name = 2; } @@ -2200,14 +2200,14 @@ message UpdateTeamRsp { } message DeleteTeamReq { - int64 ID = 1; + string ID = 1; } message DeleteTeamRsp { } message GiveTeamApplicationsReq { - int64 ID = 1; + string ID = 1; string name = 2; } @@ -2215,7 +2215,7 @@ message GiveTeamApplicationsRsp { } message GetTeamStoryReq { - int64 ID = 1; + string ID = 1; string password = 2; } @@ -2231,7 +2231,7 @@ message TeamAction { } message AddTeamActionReq { - int64 ID = 1; + string ID = 1; string password = 2; string code = 3; } @@ -2240,14 +2240,14 @@ message AddTeamActionRsp { } message DeleteLastTeamActionReq { - int64 ID = 1; + string ID = 1; } message DeleteLastTeamActionRsp { } message GetTeamAnswersReq { - int64 ID = 1; + string ID = 1; string password = 2; } @@ -2256,7 +2256,7 @@ message GetTeamAnswersRsp { } message SubmitTeamAnswersReq { - int64 ID = 1; + string ID = 1; string password = 2; repeated Answer answers = 3; } @@ -2265,7 +2265,7 @@ message SubmitTeamAnswersRsp { } message SetTeamAnswerScoreReq { - int64 ID = 1; + string ID = 1; string questionCode = 2; // Балл; отсутствует — сбросить выставленный балл optional int32 score = 3; @@ -2404,7 +2404,7 @@ message DeviceHeartbeatReq { string deviceID = 1; string connectionID = 2; optional int32 organizerGameID = 3; - optional int32 teamID = 4; + optional string teamID = 4; string password = 5; string screenID = 6; string accessToken = 7; @@ -2439,7 +2439,7 @@ message DeviceConnection { string userAgent = 5; bool connected = 6; optional int32 gameID = 7; - optional int32 teamID = 8; + optional string teamID = 8; optional int32 screenID = 9; string kind = 10; // browser, organizer, team, screen } diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 2808a34..a982192 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index 7218d32..7196ee0 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -1135,8 +1135,7 @@ "name": "itemID", "in": "path", "required": true, - "type": "integer", - "format": "int32" + "type": "string" }, { "name": "body", @@ -3235,8 +3234,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" } ], "tags": [ @@ -3265,8 +3263,7 @@ "name": "ID", "in": "path", "required": true, - "type": "integer", - "format": "int32" + "type": "string" }, { "name": "body", @@ -3305,8 +3302,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" }, { "name": "body", @@ -3350,8 +3346,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" }, { "name": "password", @@ -3391,8 +3386,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" }, { "name": "body", @@ -3436,8 +3430,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" }, { "name": "questionCode", @@ -3482,8 +3475,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" }, { "name": "body", @@ -3522,8 +3514,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" } ], "tags": [ @@ -3554,8 +3545,7 @@ "name": "ID", "in": "path", "required": true, - "type": "string", - "format": "int64" + "type": "string" }, { "name": "password", @@ -4022,8 +4012,7 @@ "type": "object", "properties": { "teamID": { - "type": "integer", - "format": "int32" + "type": "string" }, "password": { "type": "string" @@ -4517,8 +4506,7 @@ "type": "object", "properties": { "ID": { - "type": "integer", - "format": "int32" + "type": "string" }, "password": { "type": "string" @@ -4796,8 +4784,7 @@ "format": "int32" }, "teamID": { - "type": "integer", - "format": "int32" + "type": "string" }, "screenID": { "type": "integer", @@ -4823,8 +4810,7 @@ "format": "int32" }, "teamID": { - "type": "integer", - "format": "int32" + "type": "string" }, "password": { "type": "string" @@ -5006,8 +4992,7 @@ "format": "int32" }, "teamID": { - "type": "integer", - "format": "int32" + "type": "string" }, "teamName": { "type": "string" @@ -5482,8 +5467,7 @@ "type": "string" }, "itemID": { - "type": "integer", - "format": "int32" + "type": "string" }, "label": { "type": "string" @@ -6067,8 +6051,7 @@ "type": "object", "properties": { "ID": { - "type": "integer", - "format": "int32" + "type": "string" }, "name": { "type": "string" diff --git a/docs/GAME_PACKAGE.md b/docs/GAME_PACKAGE.md index ae4b336..fc02977 100644 --- a/docs/GAME_PACKAGE.md +++ b/docs/GAME_PACKAGE.md @@ -1,4 +1,4 @@ -# Пакет игры, версия 1 — ED-029, этап 4 +# Пакет игры, версия 2 — ED-031 Пакет — самостоятельный ZIP для одной игры до старта. Сборка получает готовый снимок данных и файлы; чтение не обращается к сети, БД или хранилищу приложения. @@ -8,7 +8,7 @@ | Запись | Содержание | | --- | --- | -| `manifest.json` | `version=1`, случайный `package_id` (32 hex-символа), `created_at` в UTC, `source_server`, исходный `game_id`, массив `files` | +| `manifest.json` | `version=2`, случайный `package_id` (32 hex-символа), `created_at` в UTC, `source_server`, исходный `game_id`, массив `files` | | `game.json` | Объект `Data`: `game`, `scenario`, `teams`, `tickets`, `playlists`, необязательный `screen_plan`, `map` | | `files/.` | Медиа с именем по SHA-256 содержимого; одинаковые байты хранятся один раз | @@ -347,7 +347,7 @@ RustFS и создать `.continue` в течение минуты. Buc Интерфейс передачи остаётся этапом 7; полный билетный и игровой прогон — этапом 8. До готовности возврата результатов используются только тестовые игры. -## Пакет результатов, версия 1 (ED-029, этап 9) +## Пакет результатов, версия 2 (ED-031) Это отдельный контракт: ZIP содержит ровно `manifest.json` и `results.json`, без сценария и файлов. Серверное применение реализовано этапом 10; API/UI описаны в этапе 11. @@ -370,7 +370,7 @@ gameID, output)` доступен только в `venue`, для существ | Поле | Значение | | --- | --- | -| `version` | `1` | +| `version` | `2` | | `package_id`, `created_at` | ID именно пакета результатов, время подготовки | | `source_server`, `game_id` | Онлайн-источник и исходный ID игры | | `source_package_id`, `source_sha256` | ID и SHA-256 всего исходного ZIP игры из реестра импорта | @@ -395,7 +395,8 @@ SHA-256 защищает от повреждения; это не подпись | `applications` | Локальные `id`, `team_id`, исходные `name`/`image`/`file_type`, `status` (`need`/`done`), `created_at` | | `answers` | `team_id`, `question_code`, `answer`, nullable `score`, `created_at`, `updated_at` | -`source_id` совпадает с `id` для исходной записи и равен `0` для новой локальной. +`source_id` совпадает с `id` для исходной записи. У новой локальной команды +это пустая строка, у нового билета — `0`. ID команд и ссылки на них — UUID-строки. Все ссылки прогресса используют итоговый локальный `team_id`. Этап 10 уточняет выделение ID: новые команды сохраняют venue-ID и пароль для напечатанных QR; занятый чужой командой ID отклоняет весь возврат. Новым билетам, ходам и уликам @@ -473,7 +474,8 @@ venue-пароль. Новые билеты получают онлайн-ID и ID или хеша с чужой записью отклоняет весь пакет. Ходы и улики получают свежие онлайн-ID в порядке пакета, включая исправленный снимок. Реестр хранит соответствия каждого возврата; соответствия новых билетов сохраняются между -исправлениями. Последовательности продвигаются монотонным `nextval`, без `setval` +исправлениями. Команды сохраняют UUID v7 без последовательности. +Последовательности билетов, ходов и материалов продвигаются монотонным `nextval`, без `setval` и глобальных блокировок обычных операций. Материалы переводятся из portable-троек исходного ZIP в исходные онлайн-ссылки @@ -557,3 +559,21 @@ prod-игры блок «Возврат результатов с площадк страницу prod, исправленный балл, перезапись онлайн-ответа, исходные ссылки и билеты, сохранение оформления, снятие офлайн-отметки, повтор после перезапуска. ZIP остаётся неподписанным; нагрузка, Wi-Fi и фактический ZIP 1 ГиБ не проверяются. + +## Переход на UUID v7 (ED-031) + +Все способы создания команды генерируют UUID v7 в Go. API передаёт ID строкой, +PostgreSQL хранит `uuid`. Несколько независимых площадок создают команды без +общего счётчика; перенос игры и возврат результатов сохраняют UUID и ссылки. + +Перед обновлением завершите игры на площадках и верните результаты, остановите +запись во всех окружениях и сохраните резервные копии БД. Обновите backend и +frontend согласованно во всех окружениях и примените миграции. Миграция заменяет +числовые ID существующих команд на UUID v7, сохраняя пароли, игровые данные, +связи и карту. Старые числовые ссылки и QR нужно сформировать заново. + +Пакеты игры и результатов версии 1 больше не принимаются. Миграция очищает +старые реестры экспорта, импорта и возврата; игровые данные и материалы остаются. +Старые ZIP-объекты в хранилище остаются, но недоступны через очищенный реестр. +После обновления создайте новые экспорты версии 2. Откат требует восстановления +резервной копии БД и предыдущей версии приложения; обратного преобразования ID нет. diff --git a/internal/app/answers.go b/internal/app/answers.go index 1f9d591..5f98587 100644 --- a/internal/app/answers.go +++ b/internal/app/answers.go @@ -13,7 +13,7 @@ import ( func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersReq) (*proto.GetTeamAnswersRsp, error) { answers, err := s.answerService.GetTeamAnswers( ctx, - int(req.ID), + req.ID, req.Password, ) if err != nil { @@ -28,7 +28,7 @@ func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersRe func (s *server) SubmitTeamAnswers(ctx context.Context, req *proto.SubmitTeamAnswersReq) (*proto.SubmitTeamAnswersRsp, error) { err := s.answerService.SubmitTeamAnswers( ctx, - int(req.ID), + req.ID, req.Password, convertAnswers(req.Answers), ) @@ -56,7 +56,7 @@ func (s *server) SetTeamAnswerScore(ctx context.Context, req *proto.SetTeamAnswe err := s.answerService.SetTeamAnswerScore( ctx, - int(req.ID), + req.ID, req.QuestionCode, score, ) diff --git a/internal/app/devices.go b/internal/app/devices.go index 9a5f1a8..43b6cb1 100644 --- a/internal/app/devices.go +++ b/internal/app/devices.go @@ -21,7 +21,7 @@ func (s *server) DeviceHeartbeat(ctx context.Context, req *proto.DeviceHeartbeat DeviceID: req.DeviceID, ConnectionID: req.ConnectionID, OrganizerGameID: deviceOptionalID(req.OrganizerGameID), - TeamID: deviceOptionalID(req.TeamID), + TeamID: req.TeamID, Password: req.Password, ScreenID: req.ScreenID, IP: ip, diff --git a/internal/app/devices_mapper.go b/internal/app/devices_mapper.go index 9b66104..6865d0c 100644 --- a/internal/app/devices_mapper.go +++ b/internal/app/devices_mapper.go @@ -37,7 +37,7 @@ func mapVenueDevices(devices []device_presence_service.Device) []*proto.VenueDev UserAgent: connection.UserAgent, Connected: connection.Connected, GameID: devicePublicID(connection.GameID), - TeamID: devicePublicID(connection.TeamID), + TeamID: &connection.TeamID, ScreenID: devicePublicID(connection.ScreenID), Kind: connection.Kind, }) diff --git a/internal/app/game_map.go b/internal/app/game_map.go index ac7cf2d..bb8152b 100644 --- a/internal/app/game_map.go +++ b/internal/app/game_map.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "strconv" "evening_detective_server/internal/services/map_service" proto "evening_detective_server/proto" @@ -22,7 +23,7 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro if err != nil { return nil, status.Error(codes.NotFound, "игра не найдена") } - if err := s.mapService.Ensure(ctx, int(req.GameID), "organizer_desk", 1); err != nil { + if err := s.mapService.Ensure(ctx, int(req.GameID), "organizer_desk", "1"); err != nil { return nil, status.Error(codes.Internal, "не удалось сохранить карту") } for _, team := range game.Teams { @@ -38,7 +39,7 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro return nil, status.Error(codes.Internal, "не удалось получить экраны") } for _, screen := range screens { - if err := s.mapService.Ensure(ctx, int(req.GameID), "screen", screen.ID); err != nil { + if err := s.mapService.Ensure(ctx, int(req.GameID), "screen", strconv.Itoa(screen.ID)); err != nil { return nil, status.Error(codes.Internal, "не удалось сохранить карту") } } @@ -46,16 +47,16 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro if err != nil { return nil, status.Error(codes.Internal, "не удалось получить карту") } - teamNames := map[int]string{} + teamNames := map[string]string{} for _, team := range game.Teams { if team.ParticipationStatus == "cancelled" { continue } teamNames[team.ID] = team.Name } - screenStates := map[int]*proto.ScreenState{} + screenStates := map[string]*proto.ScreenState{} for _, screen := range screens { - screenStates[screen.ID] = screenState(screen) + screenStates[strconv.Itoa(screen.ID)] = screenState(screen) } result := make([]*proto.MapItem, 0, len(items)) for _, item := range items { @@ -66,7 +67,7 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro } continue } - mapped := &proto.MapItem{Kind: item.Kind, ItemID: int32(item.ItemID), X: int32(item.X), Y: int32(item.Y), Hidden: item.Hidden} + mapped := &proto.MapItem{Kind: item.Kind, ItemID: item.ItemID, X: int32(item.X), Y: int32(item.Y), Hidden: item.Hidden} if item.Kind == "team" { mapped.Label = teamNames[item.ItemID] } @@ -87,13 +88,13 @@ func (s *server) UpdateGameMapItem(ctx context.Context, req *proto.UpdateGameMap if !canManageScreens(ctx) { return nil, status.Error(codes.PermissionDenied, "permission denied") } - if req == nil || req.GameID <= 0 || req.ItemID <= 0 || (req.Kind != "team" && req.Kind != "screen" && req.Kind != "organizer_desk") { + if req == nil || req.GameID <= 0 || req.ItemID == "" || (req.Kind != "team" && req.Kind != "screen" && req.Kind != "organizer_desk") { return nil, status.Error(codes.InvalidArgument, "некорректный объект карты") } if err := s.mapService.Update(ctx, &map_service.MapItem{ GameID: int(req.GameID), Kind: req.Kind, - ItemID: int(req.ItemID), + ItemID: req.ItemID, X: int(req.X), Y: int(req.Y), Hidden: req.Hidden, diff --git a/internal/app/game_reviews.go b/internal/app/game_reviews.go index 63cd060..95b1aa7 100644 --- a/internal/app/game_reviews.go +++ b/internal/app/game_reviews.go @@ -15,7 +15,7 @@ func mapGameReview(r *game_review_service.Review) *proto.GameReview { return &proto.GameReview{ ID: int32(r.ID), GameID: int32(r.GameID), - TeamID: int32(r.TeamID), + TeamID: r.TeamID, TeamName: r.TeamName, Rating: r.Rating, Difficulty: r.Difficulty, @@ -35,7 +35,7 @@ func mapGameReviews(reviews []*game_review_service.Review) []*proto.GameReview { } func (s *server) CreateGameReview(ctx context.Context, req *proto.CreateGameReviewReq) (*proto.CreateGameReviewRsp, error) { - review, err := s.reviewService.Create(ctx, int(req.GameID), int(req.TeamID), req.Password, req.Rating, req.Difficulty, req.Text) + review, err := s.reviewService.Create(ctx, int(req.GameID), req.TeamID, req.Password, req.Rating, req.Difficulty, req.Text) if err != nil { return nil, reviewErrorStatus(err, "не удалось создать отзыв") } diff --git a/internal/app/results_mapper_test.go b/internal/app/results_mapper_test.go index 2dcde36..f34ed29 100644 --- a/internal/app/results_mapper_test.go +++ b/internal/app/results_mapper_test.go @@ -12,7 +12,7 @@ func TestMapTeamResults(t *testing.T) { results := []answer_service.TeamResult{ { Team: &game_model.Team{ - ID: 1, + ID: "019a1234-5678-7abc-8def-000000000001", Name: "Команда А", Status: "play", ActionsCount: 7, @@ -30,7 +30,7 @@ func TestMapTeamResults(t *testing.T) { t.Fatalf("len(mapTeamResults) = %d, want 1", len(got)) } row := got[0] - if row.Team == nil || row.Team.ID != 1 || row.Team.Name != "Команда А" { + if row.Team == nil || row.Team.ID != "019a1234-5678-7abc-8def-000000000001" || row.Team.Name != "Команда А" { t.Fatalf("row.Team = %+v, want id 1, name Команда А", row.Team) } if row.ActionsCount != 7 { diff --git a/internal/app/team_mapper.go b/internal/app/team_mapper.go index aa81db2..d185dee 100644 --- a/internal/app/team_mapper.go +++ b/internal/app/team_mapper.go @@ -9,7 +9,7 @@ func mapTeam( o *game_model.Team, ) *proto.Team { return &proto.Team{ - ID: int32(o.ID), + ID: o.ID, Name: o.Name, Status: o.Status, Password: o.Password, diff --git a/internal/app/team_registration.go b/internal/app/team_registration.go index 7e60b0e..288dd81 100644 --- a/internal/app/team_registration.go +++ b/internal/app/team_registration.go @@ -14,7 +14,7 @@ import ( "time" ) -func TeamRegistrationHandler(token string, register func(context.Context, int, string, team_service.Registration) (int, error)) http.Handler { +func TeamRegistrationHandler(token string, register func(context.Context, int, string, team_service.Registration) (string, error)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -68,9 +68,9 @@ func TeamRegistrationHandler(token string, register func(context.Context, int, s fail(http.StatusInternalServerError, "Не удалось зарегистрировать команду") default: if isRPC { - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "result": map[string]int{"id": id}, "id": rpcID}) + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "result": map[string]string{"id": id}, "id": rpcID}) } else { - _ = json.NewEncoder(w).Encode(map[string]int{"id": id}) + _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) } } }) diff --git a/internal/app/team_registration_test.go b/internal/app/team_registration_test.go index 9d838e5..0a47475 100644 --- a/internal/app/team_registration_test.go +++ b/internal/app/team_registration_test.go @@ -38,7 +38,7 @@ func TestTeamRegistrationHandler(t *testing.T) { t.Run(tc.name, func(t *testing.T) { called := false mux := http.NewServeMux() - mux.Handle("POST /api/games/{id}/registrations", TeamRegistrationHandler(tc.token, func(ctx context.Context, gameID int, deliveryID string, registration team_service.Registration) (int, error) { + mux.Handle("POST /api/games/{id}/registrations", TeamRegistrationHandler(tc.token, func(ctx context.Context, gameID int, deliveryID string, registration team_service.Registration) (string, error) { called = true if gameID != 42 || deliveryID != "delivery" || registration.Name != "Команда" || registration.Email != "captain@example.org" || registration.ParticipantsCount != 5 { t.Fatal("incorrect registration passed to service") @@ -46,7 +46,7 @@ func TestTeamRegistrationHandler(t *testing.T) { if _, ok := ctx.Deadline(); !ok { t.Fatal("missing deadline") } - return 7, tc.serviceErr + return "019a1234-5678-7abc-8def-000000000007", tc.serviceErr })) req := httptest.NewRequest(http.MethodPost, "/api/games/"+tc.gameID+"/registrations", strings.NewReader(tc.body)) req.Header.Set("Authorization", tc.auth) @@ -56,7 +56,7 @@ func TestTeamRegistrationHandler(t *testing.T) { if res.Code != tc.want || called != tc.called { t.Fatalf("status=%d called=%v body=%s", res.Code, called, res.Body.String()) } - if tc.name == "success" && res.Body.String() != "{\"id\":7}\n" { + if tc.name == "success" && res.Body.String() != "{\"id\":\"019a1234-5678-7abc-8def-000000000007\"}\n" { t.Fatal(res.Body.String()) } if strings.Contains(res.Body.String(), "private database detail") { @@ -85,7 +85,7 @@ func TestTeamRegistrationHandler(t *testing.T) { } func TestCaptainContactOnlyInGameDetails(t *testing.T) { - team := &game_model.Team{ID: 1, CaptainEmail: "captain@example.org", ParticipantsCount: 5} + team := &game_model.Team{ID: "019a1234-5678-7abc-8def-000000000001", CaptainEmail: "captain@example.org", ParticipantsCount: 5} details := mapGame(&game_model.Game{Teams: []*game_model.Team{team}}) if details.Teams[0].CaptainEmail != team.CaptainEmail || details.Teams[0].ParticipantsCount != 5 { t.Fatal("registration details missing from organizer view") diff --git a/internal/app/teams.go b/internal/app/teams.go index 6607f69..c5d560e 100644 --- a/internal/app/teams.go +++ b/internal/app/teams.go @@ -34,7 +34,7 @@ func (s *server) AddTeam(ctx context.Context, req *proto.AddTeamReq) (*proto.Add } return &proto.AddTeamRsp{ - ID: int32(team.ID), + ID: team.ID, Password: team.Password, }, nil } @@ -50,7 +50,7 @@ func (s *server) UpdateTeam(ctx context.Context, req *proto.UpdateTeamReq) (*pro err := s.teamService.UpdateTeam( ctx, - int(req.ID), + req.ID, req.Name, ) if err != nil { @@ -71,7 +71,7 @@ func (s *server) DeleteTeam(ctx context.Context, req *proto.DeleteTeamReq) (*pro err := s.teamService.DeleteTeam( ctx, - int(req.ID), + req.ID, ) if err != nil { return nil, teamErrorStatus(err, "не удалось удалить команду") @@ -104,7 +104,7 @@ func (s *server) GiveTeamApplications(ctx context.Context, req *proto.GiveTeamAp err := s.gameplayService.GiveTeamApplications( ctx, - int(req.ID), + req.ID, req.Name, ) if err != nil { @@ -117,7 +117,7 @@ func (s *server) GiveTeamApplications(ctx context.Context, req *proto.GiveTeamAp func (s *server) GetTeamStory(ctx context.Context, req *proto.GetTeamStoryReq) (*proto.GetTeamStoryRsp, error) { story, game, actions, err := s.gameplayService.GetTeamActions( ctx, - int(req.ID), + req.ID, req.Password, ) if err != nil { @@ -138,7 +138,7 @@ func (s *server) GetTeamStory(ctx context.Context, req *proto.GetTeamStoryReq) ( func (s *server) AddTeamAction(ctx context.Context, req *proto.AddTeamActionReq) (*proto.AddTeamActionRsp, error) { err := s.gameplayService.AddTeamAction( ctx, - int(req.ID), + req.ID, req.Password, req.Code, false, @@ -161,7 +161,7 @@ func (s *server) DeleteLastTeamAction(ctx context.Context, req *proto.DeleteLast err := s.gameplayService.DeleteLastTeamAction( ctx, - int(req.ID), + req.ID, ) if err != nil { return nil, gameplayErrorStatus(err, "не удалось удалить последнее действие команды") diff --git a/internal/repos/actions_repo/repo.go b/internal/repos/actions_repo/repo.go index 2dd3ef9..5304435 100644 --- a/internal/repos/actions_repo/repo.go +++ b/internal/repos/actions_repo/repo.go @@ -32,7 +32,7 @@ func NewActionsRepo(pool *pgxpool.Pool) *ActionsRepo { } // AddAction stores an action for a team. -func (r *ActionsRepo) AddAction(ctx context.Context, teamID int, code string, viaMCP bool) (int, error) { +func (r *ActionsRepo) AddAction(ctx context.Context, teamID string, code string, viaMCP bool) (int, error) { id := 0 err := r.pool.QueryRow( ctx, @@ -51,7 +51,7 @@ func (r *ActionsRepo) AddAction(ctx context.Context, teamID int, code string, vi } // GetActionsByTeamID returns actions for a team. -func (r *ActionsRepo) GetActionsByTeamID(ctx context.Context, teamID int) ([]Action, error) { +func (r *ActionsRepo) GetActionsByTeamID(ctx context.Context, teamID string) ([]Action, error) { rows, err := r.pool.Query( ctx, `SELECT @@ -87,7 +87,7 @@ func (r *ActionsRepo) GetActionsByTeamID(ctx context.Context, teamID int) ([]Act } // GetActionsCountByTeamIDs returns action counts for teams. -func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []int) (map[int]int, error) { +func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []string) (map[string]int, error) { rows, err := r.pool.Query( ctx, `SELECT @@ -103,9 +103,9 @@ func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []in } defer rows.Close() - res := make(map[int]int, len(teamIDs)) + res := make(map[string]int, len(teamIDs)) for rows.Next() { - id := 0 + var id string count := 0 err := rows.Scan( &id, @@ -124,7 +124,7 @@ func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []in } // DeleteLastActionByTeamID removes the latest action for a team. -func (r *ActionsRepo) DeleteLastActionByTeamID(ctx context.Context, teamID int) error { +func (r *ActionsRepo) DeleteLastActionByTeamID(ctx context.Context, teamID string) error { tag, err := r.pool.Exec( ctx, `DELETE FROM actions diff --git a/internal/repos/answer.go b/internal/repos/answer.go index c91904a..38cf1ad 100644 --- a/internal/repos/answer.go +++ b/internal/repos/answer.go @@ -2,7 +2,7 @@ package repos type TeamAnswer struct { - TeamID int + TeamID string QuestionCode string Text string Score *int diff --git a/internal/repos/application.go b/internal/repos/application.go index dad966e..a6be7c3 100644 --- a/internal/repos/application.go +++ b/internal/repos/application.go @@ -4,6 +4,6 @@ package repos type Application struct { Name string Image string - TeamID int + TeamID string FileType string } diff --git a/internal/repos/applications_repo/repo.go b/internal/repos/applications_repo/repo.go index 74c00b5..bc229b3 100644 --- a/internal/repos/applications_repo/repo.go +++ b/internal/repos/applications_repo/repo.go @@ -29,7 +29,7 @@ func NewApplicationsRepo(pool *pgxpool.Pool) *ApplicationsRepo { // AddApplication stores an application for a team. func (r *ApplicationsRepo) AddApplication( ctx context.Context, - teamID int, + teamID string, name string, image string, fileType string, @@ -55,9 +55,9 @@ func (r *ApplicationsRepo) AddApplication( // GetApplicationsByTeamIDsAndState returns applications matching team IDs and state. func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState( ctx context.Context, - teamIDs []int, + teamIDs []string, state string, -) (map[int][]*repos.Application, error) { +) (map[string][]*repos.Application, error) { rows, err := r.pool.Query( ctx, `SELECT @@ -75,7 +75,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState( } defer rows.Close() - res := make(map[int][]*repos.Application, len(teamIDs)) + res := make(map[string][]*repos.Application, len(teamIDs)) for rows.Next() { application := &repos.Application{} err := rows.Scan( @@ -99,7 +99,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState( // UpdateApplicationStatus changes an application's status. func (r *ApplicationsRepo) UpdateApplicationStatus( ctx context.Context, - teamID int, + teamID string, name string, status string, ) error { @@ -123,7 +123,7 @@ func (r *ApplicationsRepo) UpdateApplicationStatus( } // DeleteApplicationByTeamIDAndName removes an application by team and name. -func (r *ApplicationsRepo) DeleteApplicationByTeamIDAndName(ctx context.Context, teamID int, name string) error { +func (r *ApplicationsRepo) DeleteApplicationByTeamIDAndName(ctx context.Context, teamID string, name string) error { tag, err := r.pool.Exec( ctx, `DELETE FROM applications diff --git a/internal/repos/game_exports_repo/fixture_test.go b/internal/repos/game_exports_repo/fixture_test.go index f93e054..c99babe 100644 --- a/internal/repos/game_exports_repo/fixture_test.go +++ b/internal/repos/game_exports_repo/fixture_test.go @@ -28,7 +28,7 @@ type exportFixture struct { userID int scenarioID int gameID int - teamID int + teamID string } // newExportFixture uses real migrations in an isolated test schema; all game data uses repositories. diff --git a/internal/repos/game_exports_repo/team_id_test.go b/internal/repos/game_exports_repo/team_id_test.go new file mode 100644 index 0000000..d574450 --- /dev/null +++ b/internal/repos/game_exports_repo/team_id_test.go @@ -0,0 +1,43 @@ +package game_exports_repo + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" +) + +func TestTeamIDsAreUniqueV7AcrossIndependentDatabases(t *testing.T) { + for _, name := range []string{"manual and public registration on three databases"} { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + seen := map[string]bool{} + for database := range 3 { + fixture := newExportFixture(t) + for _, create := range []func() (string, error){ + func() (string, error) { + return fixture.teams.AddTeam(ctx, fixture.userID, fixture.gameID, "Manual", "password") + }, + func() (string, error) { + return fixture.teams.RegisterTeam(ctx, fixture.gameID, fmt.Sprint(database), "Public", "captain@example.test", 2, "password") + }, + } { + id, err := create() + requireNoError(t, err) + parsed, err := uuid.Parse(id) + requireNoError(t, err) + if parsed.Version() != 7 || parsed.Variant() != uuid.RFC4122 || parsed.String() != id || seen[id] { + t.Fatalf("invalid or reused team UUID: %s", id) + } + seen[id] = true + team, err := fixture.teams.GetTeamByID(ctx, id) + requireNoError(t, err) + if team.ID != id || team.Password != "password" { + t.Fatal("team identity changed during storage") + } + } + } + }) + } +} diff --git a/internal/repos/game_imports_repo/publish.go b/internal/repos/game_imports_repo/publish.go index 5a6f6d3..79211ee 100644 --- a/internal/repos/game_imports_repo/publish.go +++ b/internal/repos/game_imports_repo/publish.go @@ -131,18 +131,6 @@ func (r *GameImportsRepo) Complete(ctx context.Context, id string, data []byte) FROM games_id_seq ) )), - setval('teams_id_seq', GREATEST( - ( - SELECT - COALESCE(MAX(id), 1) - FROM teams - ), - ( - SELECT - last_value - FROM teams_id_seq - ) - )), setval('tickets_id_seq', GREATEST( ( SELECT @@ -291,7 +279,7 @@ func insertGameRelations(ctx context.Context, tx pgx.Tx, data []byte) error { t.registration_delivery_id, t.created_at AT TIME ZONE 'UTC' FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'teams', 'null'::jsonb), '[]'::jsonb)) AS t( - id integer, game_id integer, name text, status text, password text, captain_email text, + id uuid, game_id integer, name text, status text, password text, captain_email text, participants_count integer, participation_status text, participation_source text, participation_answered_at timestamptz, registration_delivery_id text, created_at timestamptz )`, @@ -344,7 +332,7 @@ func insertGameRelations(ctx context.Context, tx pgx.Tx, data []byte) error { m.y, m.hidden FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'map', 'null'::jsonb), '[]'::jsonb)) AS m( - game_id integer, kind text, item_id integer, x integer, y integer, hidden boolean + game_id integer, kind text, item_id text, x integer, y integer, hidden boolean )`, data, ) diff --git a/internal/repos/game_imports_repo/repo.go b/internal/repos/game_imports_repo/repo.go index bc17e95..a8db8be 100644 --- a/internal/repos/game_imports_repo/repo.go +++ b/internal/repos/game_imports_repo/repo.go @@ -155,7 +155,7 @@ func checkConflicts(ctx context.Context, tx pgx.Tx, raw []byte) error { SELECT 1 FROM teams t - JOIN jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'teams', 'null'::jsonb), '[]'::jsonb)) AS s(id integer) ON s.id = t.id + JOIN jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'teams', 'null'::jsonb), '[]'::jsonb)) AS s(id uuid) ON s.id = t.id ) OR EXISTS ( SELECT 1 diff --git a/internal/repos/game_map_repo/repo.go b/internal/repos/game_map_repo/repo.go index 6111c39..8497a1a 100644 --- a/internal/repos/game_map_repo/repo.go +++ b/internal/repos/game_map_repo/repo.go @@ -13,7 +13,7 @@ var ErrItemNotFound = errors.New("объект карты не найден") type Item struct { GameID int Kind string - ItemID int + ItemID string X int Y int Hidden bool @@ -28,7 +28,7 @@ func NewGameMapRepo(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } -func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID int) error { +func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID string) error { _, err := r.pool.Exec(ctx, ` INSERT INTO game_map_items (game_id, kind, item_id) VALUES ($1, $2, $3) diff --git a/internal/repos/game_returns_repo/repo_test.go b/internal/repos/game_returns_repo/repo_test.go index 7327a23..7c794fb 100644 --- a/internal/repos/game_returns_repo/repo_test.go +++ b/internal/repos/game_returns_repo/repo_test.go @@ -54,32 +54,3 @@ func TestTransactionRetriesOnlyTransientFailures(t *testing.T) { }) } } - -func TestSequenceErrorPreservesCancellationAndCause(t *testing.T) { - for _, tc := range []struct { - name string - cancelParent, cancelAdvance bool - expected error - }{ - {"parent cancelled", true, true, context.Canceled}, - {"advance cancelled", false, true, ErrRetry}, - {"driver failure", false, false, ErrConflict}, - } { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - advanceCtx, advanceCancel := context.WithCancel(ctx) - defer advanceCancel() - if tc.cancelParent { - cancel() - } - if tc.cancelAdvance { - advanceCancel() - } - err := sequenceError(ctx, advanceCtx, ErrConflict) - if !errors.Is(err, tc.expected) { - t.Fatal(err) - } - }) - } -} diff --git a/internal/repos/game_returns_repo/write.go b/internal/repos/game_returns_repo/write.go index d62d241..3d3cec2 100644 --- a/internal/repos/game_returns_repo/write.go +++ b/internal/repos/game_returns_repo/write.go @@ -2,7 +2,6 @@ package game_returns_repo import ( "context" - "time" "github.com/jackc/pgx/v5" ) @@ -16,7 +15,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { SELECT 1 FROM teams t - JOIN jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) ON n.id = t.id + JOIN jsonb_to_recordset($2::jsonb->'teams') AS n(id uuid) ON n.id = t.id WHERE t.game_id <> $1 ) OR EXISTS ( SELECT @@ -49,15 +48,15 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { AND NOT EXISTS ( SELECT 1 - FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) - WHERE n.id = m.item_id + FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id uuid) + WHERE n.id::text = m.item_id )`, `DELETE FROM teams t WHERE t.game_id = $1 AND NOT EXISTS ( SELECT 1 - FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) + FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id uuid) WHERE n.id = t.id )`, `DELETE FROM tickets t @@ -89,7 +88,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { n.registration_delivery_id, n.created_at FROM jsonb_to_recordset($2::jsonb->'teams') AS n( - id integer, + id uuid, game_id integer, name text, status text, @@ -153,7 +152,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { n.created_at FROM jsonb_to_recordset($2::jsonb->'actions') AS n( id integer, - team_id integer, + team_id uuid, code text, via_mcp boolean, created_at timestamp @@ -175,7 +174,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { n.created_at FROM jsonb_to_recordset($2::jsonb->'applications') AS n( id integer, - team_id integer, + team_id uuid, name text, image text, file_type text, @@ -197,7 +196,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { n.created_at, n.updated_at FROM jsonb_to_recordset($2::jsonb->'answers') AS n( - team_id integer, + team_id uuid, question_code text, answer text, score integer, @@ -226,57 +225,9 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { return err } } - // nextval is monotonic under concurrent inserts; setval can move a live sequence backwards. - // shortcut: huge venue ID gaps consume sequence values even on refusal; widen IDs before supporting such venues. - var maximum int - if err := tx.QueryRow( - ctx, - `SELECT COALESCE(max(id),0) - FROM teams`, - ).Scan(&maximum); err != nil { - return err - } - if maximum >= 2147483647 { - return &ConflictError{Reason: "идентификатор команды достиг предела допустимого диапазона."} - } - advanceCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - for { - var id int - if err := tx.QueryRow( - advanceCtx, - `SELECT nextval('teams_id_seq')`, - ).Scan(&id); err != nil { - return sequenceError(ctx, advanceCtx, err) - } - if id >= maximum { - break - } - if err := tx.QueryRow( - advanceCtx, - `SELECT max(nextval('teams_id_seq')) - FROM generate_series(1,$1::integer)`, - min(maximum-id, 4096), - ).Scan(&id); err != nil { - return sequenceError(ctx, advanceCtx, err) - } - if id >= maximum { - break - } - } return nil } -func sequenceError(ctx, advanceCtx context.Context, err error) error { - if ctx.Err() != nil { - return ctx.Err() - } - if advanceCtx.Err() != nil { - return ErrRetry - } - return err -} - // RestoreStory isolates returned questions and materials from other games sharing the scenario. func RestoreStory(ctx context.Context, tx pgx.Tx, gameID int, story []byte) (int, error) { var scenarioID int diff --git a/internal/repos/game_reviews_repo/repo.go b/internal/repos/game_reviews_repo/repo.go index f94d50e..cebdd67 100644 --- a/internal/repos/game_reviews_repo/repo.go +++ b/internal/repos/game_reviews_repo/repo.go @@ -26,7 +26,7 @@ type Review struct { ID int GameID int GameName string - TeamID int + TeamID string TeamName string Rating int32 Difficulty int32 @@ -42,7 +42,7 @@ type Repo struct{ pool *pgxpool.Pool } func NewGameReviewsRepo(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } // Create stores a review. -func (r *Repo) Create(ctx context.Context, gameID, teamID int, rating, difficulty int32, text string) (*Review, error) { +func (r *Repo) Create(ctx context.Context, gameID int, teamID string, rating, difficulty int32, text string) (*Review, error) { review := &Review{} err := r.pool.QueryRow( ctx, diff --git a/internal/repos/team.go b/internal/repos/team.go index 7b5a215..0fb0ba5 100644 --- a/internal/repos/team.go +++ b/internal/repos/team.go @@ -8,7 +8,7 @@ type Team struct { ParticipationEmailSent bool CaptainEmail string ParticipantsCount int32 - ID int + ID string Name string Status string Password string diff --git a/internal/repos/team_answers_repo/repo.go b/internal/repos/team_answers_repo/repo.go index 478caa3..ac69052 100644 --- a/internal/repos/team_answers_repo/repo.go +++ b/internal/repos/team_answers_repo/repo.go @@ -22,7 +22,7 @@ func NewTeamAnswersRepo(pool *pgxpool.Pool) *TeamAnswersRepo { func (r *TeamAnswersRepo) UpsertAnswers( ctx context.Context, - teamID int, + teamID string, answers []repos.TeamAnswer, ) error { for _, answer := range answers { @@ -48,7 +48,7 @@ func (r *TeamAnswersRepo) UpsertAnswers( // GetAnswersByTeamID returns answers submitted by a team. func (r *TeamAnswersRepo) GetAnswersByTeamID( ctx context.Context, - teamID int, + teamID string, ) ([]*repos.TeamAnswer, error) { rows, err := r.pool.Query( ctx, @@ -133,7 +133,7 @@ func (r *TeamAnswersRepo) GetAnswersByGameID( func (r *TeamAnswersRepo) SetAnswerScore( ctx context.Context, - teamID int, + teamID string, questionCode string, score *int, ) error { diff --git a/internal/repos/teams_repo/id_test.go b/internal/repos/teams_repo/id_test.go new file mode 100644 index 0000000..7a57bfd --- /dev/null +++ b/internal/repos/teams_repo/id_test.go @@ -0,0 +1,22 @@ +package teams_repo + +import ( + "context" + "errors" + "testing" +) + +func TestTeamOperationsRejectInvalidIDs(t *testing.T) { + for _, id := range []string{"", "12", "invalid", "00000000-0000-0000-0000-000000000000", "019A1234-5678-7ABC-8DEF-000000000001", "019a123456787abc8def000000000001"} { + t.Run(id, func(t *testing.T) { + repo := NewTeamsRepo(nil) + ctx := context.Background() + _, err := repo.GetTeamByID(ctx, id) + for _, result := range []error{err, repo.UpdateTeam(ctx, id, "Team"), repo.DeleteTeam(ctx, id)} { + if !errors.Is(result, ErrTeamNotFound) { + t.Fatal(result) + } + } + }) + } +} diff --git a/internal/repos/teams_repo/participation.go b/internal/repos/teams_repo/participation.go index 4e091e9..88b681b 100644 --- a/internal/repos/teams_repo/participation.go +++ b/internal/repos/teams_repo/participation.go @@ -26,7 +26,7 @@ const maxReviewReminderAttempts = 8 // Participation describes a team's participation state. type Participation struct { - TeamID int + TeamID string TeamName string GameName string StartAt time.Time @@ -39,7 +39,7 @@ type Participation struct { // ReviewReminder describes a pending review reminder. type ReviewReminder struct { - TeamID int + TeamID string TeamName string GameID int GameName string @@ -130,7 +130,7 @@ func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation } // SetParticipationToken stores a participation token. -func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error { +func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id string, token string) error { hash, err := tokenHash(token) if err != nil { return err @@ -140,7 +140,7 @@ func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token str } // ConfirmParticipationByFirstAction confirms participation after the first action. -func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID int) error { +func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID string) error { _, err := r.pool.Exec(ctx, `UPDATE teams SET participation_status='confirmed', participation_source='first_action', participation_answered_at=NOW() WHERE id=$1 AND participation_status='pending'`, teamID) @@ -148,7 +148,7 @@ func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamI } // FinishParticipationEmail completes a participation email job. -func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error { +func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id string, attempt int, sendErr error) error { message := "" if sendErr != nil { message = sendErr.Error() @@ -216,7 +216,7 @@ func (r *TeamsRepo) ClaimReviewReminder(ctx context.Context) (*ReviewReminder, e } // FinishReviewReminder completes a review reminder job. -func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id, attempt int, sendErr error) error { +func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id string, attempt int, sendErr error) error { message := "" if sendErr != nil { message = sendErr.Error() diff --git a/internal/repos/teams_repo/registration_test.go b/internal/repos/teams_repo/registration_test.go index 1522a81..392df87 100644 --- a/internal/repos/teams_repo/registration_test.go +++ b/internal/repos/teams_repo/registration_test.go @@ -45,7 +45,7 @@ func TestRegistrationPostgres(t *testing.T) { } defer pool.Close() _, err = pool.Exec(ctx, `CREATE TABLE games (id SERIAL PRIMARY KEY, status TEXT DEFAULT 'draft'); - CREATE TABLE teams (id SERIAL PRIMARY KEY, name VARCHAR(255), creator_id INTEGER, + CREATE TABLE teams (id UUID PRIMARY KEY, name VARCHAR(255), creator_id INTEGER, game_id INTEGER REFERENCES games(id), password TEXT, status TEXT DEFAULT 'register', created_at TIMESTAMP DEFAULT NOW()); INSERT INTO games DEFAULT VALUES; INSERT INTO games DEFAULT VALUES;`) if err != nil { @@ -67,7 +67,7 @@ func TestRegistrationPostgres(t *testing.T) { } repo := NewTeamsRepo(pool) type result struct { - id int + id string err error } results := make(chan result, 8) @@ -77,13 +77,13 @@ func TestRegistrationPostgres(t *testing.T) { results <- result{id, err} }() } - var id int + var id string for range 8 { got := <-results if got.err != nil { t.Fatal(got.err) } - if id != 0 && got.id != id { + if id != "" && got.id != id { t.Fatal("concurrent delivery created a duplicate") } id = got.id @@ -107,7 +107,7 @@ func TestRegistrationPostgres(t *testing.T) { t.Fatal(err) } if got, err := repo.RegisterTeam(ctx, 1, "delivery", "Changed", "other@example.org", 9, "different"); err != nil || got != id { - t.Fatalf("retry after start: %d %v", got, err) + t.Fatalf("retry after start: %s %v", got, err) } if _, err := repo.RegisterTeam(ctx, 1, "new", "Team", "captain@example.org", 5, "abcdefgh"); !errors.Is(err, ErrRegistrationClosed) { t.Fatalf("registration after start: %v", err) @@ -116,7 +116,7 @@ func TestRegistrationPostgres(t *testing.T) { t.Fatalf("missing game: %v", err) } if otherID, err := repo.RegisterTeam(ctx, 2, "delivery", "Team", "captain@example.org", 5, "abcdefgh"); err != nil || otherID == id { - t.Fatalf("other game: %d %v", otherID, err) + t.Fatalf("other game: %s %v", otherID, err) } for _, email := range []string{"", "manual@example.org"} { manualID, err := repo.AddTeamWithParticipation(ctx, 1, 2, "Manual", "password", email, 0, false) diff --git a/internal/repos/teams_repo/repo.go b/internal/repos/teams_repo/repo.go index 5333d12..9e721da 100644 --- a/internal/repos/teams_repo/repo.go +++ b/internal/repos/teams_repo/repo.go @@ -5,6 +5,7 @@ import ( "errors" "evening_detective_server/internal/repos" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -27,7 +28,10 @@ func NewTeamsRepo(pool *pgxpool.Pool) *TeamsRepo { } // GetTeamByID returns a team by ID. -func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error) { +func (r *TeamsRepo) GetTeamByID(ctx context.Context, id string) (*repos.Team, error) { + if !validTeamID(id) { + return nil, ErrTeamNotFound + } team := &repos.Team{} row := r.pool.QueryRow( ctx, @@ -36,7 +40,10 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error name, captain_email, participants_count, - participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL, + participation_status, + participation_source, + participation_email_error, + participation_sent_at IS NOT NULL, status, password, game_id @@ -76,7 +83,10 @@ func (r *TeamsRepo) GetTeamsByGameID( name, captain_email, participants_count, - participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL, + participation_status, + participation_source, + participation_email_error, + participation_sent_at IS NOT NULL, status, password FROM teams @@ -120,22 +130,33 @@ func (r *TeamsRepo) AddTeam( gameID int, name string, password string, -) (int, error) { +) (string, error) { return r.AddTeamWithParticipation(ctx, creatorID, gameID, name, password, "", 0, false) } // AddTeamWithParticipation creates a team with participation data. -func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gameID int, name, password, captainEmail string, participantsCount int32, autoConfirm bool) (int, error) { - id := 0 +func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gameID int, name, password, captainEmail string, participantsCount int32, autoConfirm bool) (string, error) { + id, err := uuid.NewV7() + if err != nil { + return "", err + } + var teamID string status, source := "pending", "" if autoConfirm { status, source = "confirmed", "automatic" } - err := r.pool.QueryRow( + err = r.pool.QueryRow( ctx, - `INSERT INTO teams (name, creator_id, game_id, password, participation_status, participation_source, participation_answered_at, captain_email, participants_count) - VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END, $7, $8) - RETURNING id`, + `INSERT INTO teams ( + id, name, creator_id, game_id, password, participation_status, + participation_source, participation_answered_at, captain_email, participants_count + ) + VALUES ( + $9, $1, $2, $3, $4, $5, $6, + CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END, $7, $8 + ) + RETURNING + id`, name, creatorID, gameID, @@ -144,16 +165,20 @@ func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gam source, captainEmail, participantsCount, - ).Scan(&id) + id.String(), + ).Scan(&teamID) if err != nil { - return 0, err + return "", err } - return id, nil + return teamID, nil } // UpdateTeam changes a team's name. -func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID int, name string) error { +func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID string, name string) error { + if !validTeamID(teamID) { + return ErrTeamNotFound + } tag, err := r.pool.Exec( ctx, `UPDATE teams @@ -173,7 +198,10 @@ func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID int, name string) err } // DeleteTeam removes a team. -func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error { +func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID string) error { + if !validTeamID(teamID) { + return ErrTeamNotFound + } tag, err := r.pool.Exec( ctx, `DELETE FROM teams @@ -192,32 +220,73 @@ func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error { // ErrRegistrationClosed indicates that team registration is unavailable. var ErrRegistrationClosed = errors.New("игра не найдена или регистрация уже закрыта") -func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) { +func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (string, error) { return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false) } // RegisterTeamWithParticipation registers a team and its participation data. -func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (int, error) { - var id int - err := r.pool.QueryRow(ctx, `SELECT id FROM teams WHERE game_id = $1 AND registration_delivery_id = $2`, gameID, deliveryID).Scan(&id) +func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (string, error) { + var id string + err := r.pool.QueryRow( + ctx, + `SELECT + id + FROM teams + WHERE game_id = $1 AND registration_delivery_id = $2`, + gameID, + deliveryID, + ).Scan(&id) if err == nil { return id, nil } if !errors.Is(err, pgx.ErrNoRows) { - return 0, err + return "", err + } + newID, err := uuid.NewV7() + if err != nil { + return "", err } status, source := "pending", "" if autoConfirm { status, source = "confirmed", "automatic" } - err = r.pool.QueryRow(ctx, ` - INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password, participation_status, participation_source, participation_answered_at) - SELECT id, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END FROM games WHERE id = $1 AND status = 'draft' - ON CONFLICT (game_id, registration_delivery_id) - DO UPDATE SET registration_delivery_id = EXCLUDED.registration_delivery_id - RETURNING id`, gameID, deliveryID, name, email, participantsCount, password, status, source).Scan(&id) + err = r.pool.QueryRow( + ctx, + `INSERT INTO teams ( + id, game_id, registration_delivery_id, name, captain_email, + participants_count, password, participation_status, + participation_source, participation_answered_at + ) + SELECT + $9, + id, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END + FROM games + WHERE id = $1 AND status = 'draft' + ON CONFLICT (game_id, registration_delivery_id) + DO UPDATE + SET registration_delivery_id = EXCLUDED.registration_delivery_id + RETURNING + id`, + gameID, + deliveryID, + name, + email, + participantsCount, + password, + status, + source, + newID.String(), + ).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { - return 0, ErrRegistrationClosed + return "", ErrRegistrationClosed } return id, err } @@ -232,3 +301,8 @@ func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]* } return active, err } + +func validTeamID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil && parsed.String() == id +} diff --git a/internal/services/answer_service/results.go b/internal/services/answer_service/results.go index 8c12c58..f233094 100644 --- a/internal/services/answer_service/results.go +++ b/internal/services/answer_service/results.go @@ -23,7 +23,7 @@ type GameResults struct { func buildTeamResults( questions []*storytelling.Question, teams []*game_model.Team, - answerByTeamAndCode map[int]map[string]AnswerScore, + answerByTeamAndCode map[string]map[string]AnswerScore, ) []TeamResult { res := make([]TeamResult, 0, len(teams)) for _, team := range teams { diff --git a/internal/services/answer_service/results_test.go b/internal/services/answer_service/results_test.go index 8b2eb54..40c8979 100644 --- a/internal/services/answer_service/results_test.go +++ b/internal/services/answer_service/results_test.go @@ -2,6 +2,7 @@ package answer_service import ( "evening_detective_server/internal/services/game_model" + "fmt" "testing" "evening_detective_server/internal/modules/storytelling" @@ -11,7 +12,7 @@ func intPtr(v int) *int { return &v } func teamResult(id, actions, total int) TeamResult { return TeamResult{ - Team: &game_model.Team{ID: id, ActionsCount: actions}, + Team: &game_model.Team{ID: fmt.Sprintf("019a1234-5678-7abc-8def-%012x", id), ActionsCount: actions}, TotalScore: total, } } @@ -25,11 +26,11 @@ func TestRankTeamsSortsByTotalThenActions(t *testing.T) { } got := rankTeams(rows) - wantIDs := []int{2, 1, 3, 4} + wantIDs := []string{"019a1234-5678-7abc-8def-000000000002", "019a1234-5678-7abc-8def-000000000001", "019a1234-5678-7abc-8def-000000000003", "019a1234-5678-7abc-8def-000000000004"} wantPlaces := []int{1, 2, 3, 4} for i, id := range wantIDs { if got[i].Team.ID != id { - t.Fatalf("rankTeams[%d].Team.ID = %d, want %d (порядок: total desc, actions asc)", i, got[i].Team.ID, id) + t.Fatalf("rankTeams[%d].Team.ID = %s, want %s (порядок: total desc, actions asc)", i, got[i].Team.ID, id) } if got[i].Place != wantPlaces[i] { t.Fatalf("rankTeams[%d].Place = %d, want %d", i, got[i].Place, wantPlaces[i]) @@ -48,10 +49,10 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) { } got := rankTeams(rows) - wantIDs := []int{2, 5, 7} + wantIDs := []string{"019a1234-5678-7abc-8def-000000000002", "019a1234-5678-7abc-8def-000000000005", "019a1234-5678-7abc-8def-000000000007"} for i, id := range wantIDs { if got[i].Team.ID != id { - t.Fatalf("rankTeams[%d].Team.ID = %d, want %d (порядок показа внутри группы — id asc)", i, got[i].Team.ID, id) + t.Fatalf("rankTeams[%d].Team.ID = %s, want %s (порядок показа внутри группы — id asc)", i, got[i].Team.ID, id) } if got[i].Place != 1 { t.Fatalf("rankTeams[%d].Place = %d, want 1 (одно место у равных по сумме и ходам)", i, got[i].Place) @@ -68,11 +69,11 @@ func TestRankTeamsSkipsPlaceAfterSharedTie(t *testing.T) { } got := rankTeams(rows) - wantIDs := []int{1, 2, 3, 4} + wantIDs := []string{"019a1234-5678-7abc-8def-000000000001", "019a1234-5678-7abc-8def-000000000002", "019a1234-5678-7abc-8def-000000000003", "019a1234-5678-7abc-8def-000000000004"} wantPlaces := []int{1, 1, 3, 4} for i := range wantIDs { if got[i].Team.ID != wantIDs[i] { - t.Fatalf("rankTeams[%d].Team.ID = %d, want %d", i, got[i].Team.ID, wantIDs[i]) + t.Fatalf("rankTeams[%d].Team.ID = %s, want %s", i, got[i].Team.ID, wantIDs[i]) } if got[i].Place != wantPlaces[i] { t.Fatalf("rankTeams[%d].Place = %d, want %d (места 1,1,3,4)", i, got[i].Place, wantPlaces[i]) @@ -90,8 +91,8 @@ func TestRankTeamsDoesNotMutateInput(t *testing.T) { if rows[0].Place != 0 || rows[1].Place != 0 { t.Fatalf("rankTeams изменил входной срез: places = %d, %d; want 0, 0", rows[0].Place, rows[1].Place) } - if rows[0].Team.ID != 1 || rows[1].Team.ID != 2 { - t.Fatalf("rankTeams изменил порядок входного среза: ids = %d, %d; want 1, 2", rows[0].Team.ID, rows[1].Team.ID) + if rows[0].Team.ID != "019a1234-5678-7abc-8def-000000000001" || rows[1].Team.ID != "019a1234-5678-7abc-8def-000000000002" { + t.Fatalf("rankTeams изменил порядок входного среза: ids = %s, %s; want 1, 2", rows[0].Team.ID, rows[1].Team.ID) } } @@ -102,15 +103,15 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) { {Code: "q3", Text: "Вопрос 3"}, } teams := []*game_model.Team{ - {ID: 1, Name: "А", ActionsCount: 4}, - {ID: 2, Name: "Б", ActionsCount: 2}, + {ID: "019a1234-5678-7abc-8def-000000000001", Name: "А", ActionsCount: 4}, + {ID: "019a1234-5678-7abc-8def-000000000002", Name: "Б", ActionsCount: 2}, } - answerByTeamAndCode := map[int]map[string]AnswerScore{ - 1: { + answerByTeamAndCode := map[string]map[string]AnswerScore{ + "019a1234-5678-7abc-8def-000000000001": { "q1": {QuestionCode: "q1", Text: "ответ 1", Score: intPtr(5)}, "q3": {QuestionCode: "q3", Text: "ответ 3", Score: intPtr(-1)}, }, - 2: { + "019a1234-5678-7abc-8def-000000000002": { "q2": {QuestionCode: "q2", Text: "ответ 2"}, }, } @@ -122,8 +123,8 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) { } first := got[0] - if first.Team.ID != 1 || first.TotalScore != 4 { - t.Fatalf("команда 1: Team.ID = %d, TotalScore = %d; want ID 1, Total 4", first.Team.ID, first.TotalScore) + if first.Team.ID != "019a1234-5678-7abc-8def-000000000001" || first.TotalScore != 4 { + t.Fatalf("команда 1: Team.ID = %s, TotalScore = %d; want ID 1, Total 4", first.Team.ID, first.TotalScore) } if len(first.Answers) != 3 { t.Fatalf("команда 1: len(Answers) = %d, want 3 (порядок сценария)", len(first.Answers)) @@ -138,8 +139,8 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) { } second := got[1] - if second.Team.ID != 2 || second.TotalScore != 0 { - t.Fatalf("команда 2: Team.ID = %d, TotalScore = %d; want ID 2, Total 0", second.Team.ID, second.TotalScore) + if second.Team.ID != "019a1234-5678-7abc-8def-000000000002" || second.TotalScore != 0 { + t.Fatalf("команда 2: Team.ID = %s, TotalScore = %d; want ID 2, Total 0", second.Team.ID, second.TotalScore) } if second.Answers[1].Score != nil { t.Fatalf("команда 2: неоценённый q2: Score = %v, want nil", second.Answers[1].Score) diff --git a/internal/services/answer_service/service.go b/internal/services/answer_service/service.go index eccf752..094013a 100644 --- a/internal/services/answer_service/service.go +++ b/internal/services/answer_service/service.go @@ -49,7 +49,7 @@ var ( ErrOfflineAnswers = errors.New("в offline-игре ответы через сайт недоступны") ) -func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password string) (*repos.Team, *repos.Game, error) { +func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID string, password string) (*repos.Team, *repos.Game, error) { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { if errors.Is(err, teams_repo.ErrTeamNotFound) { @@ -82,7 +82,7 @@ func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenario return scenario.Story.Questions, byCode, nil } -func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password string) ([]Answer, error) { +func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID string, password string) ([]Answer, error) { _, game, err := s.getTeamAndGame(ctx, teamID, password) if err != nil { return nil, err @@ -111,7 +111,7 @@ func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password return answers, nil } -func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []Answer) error { +func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID string, password string, answers []Answer) error { _, game, err := s.getTeamAndGame(ctx, teamID, password) if err != nil { return err @@ -157,7 +157,7 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn if err != nil { return nil, err } - teamIDs := make([]int, 0, len(teams)) + teamIDs := make([]string, 0, len(teams)) for _, team := range teams { teamIDs = append(teamIDs, team.ID) } @@ -169,7 +169,7 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn if err != nil { return nil, err } - answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams)) + answerByTeamAndCode := make(map[string]map[string]AnswerScore, len(teams)) for _, row := range rows { if _, ok := byCode[row.QuestionCode]; !ok { continue @@ -208,7 +208,7 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn }, nil } -func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, questionCode string, score *int) error { +func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID string, questionCode string, score *int) error { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { return mapAnswerError(err) @@ -243,7 +243,7 @@ func (s *AnswerService) GetGameResults(ctx context.Context, gameID int) (*GameRe if err != nil { return nil, err } - teamIDs := make([]int, 0, len(teams)) + teamIDs := make([]string, 0, len(teams)) for _, team := range teams { teamIDs = append(teamIDs, team.ID) } @@ -255,7 +255,7 @@ func (s *AnswerService) GetGameResults(ctx context.Context, gameID int) (*GameRe if err != nil { return nil, err } - answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams)) + answerByTeamAndCode := make(map[string]map[string]AnswerScore, len(teams)) for _, row := range rows { if _, ok := byCode[row.QuestionCode]; !ok { continue diff --git a/internal/services/answer_service/service_test.go b/internal/services/answer_service/service_test.go index de110e8..d2044e7 100644 --- a/internal/services/answer_service/service_test.go +++ b/internal/services/answer_service/service_test.go @@ -32,10 +32,10 @@ func TestAnswerAccessPostgres(t *testing.T) { CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ); CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP, format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); - CREATE TEMP TABLE teams (id INT, name TEXT, captain_email TEXT, participants_count INT, + CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT, status TEXT, password TEXT, game_id INT); INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL); - INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1); + INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1); ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '', ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`) if err != nil { @@ -51,17 +51,17 @@ func TestAnswerAccessPostgres(t *testing.T) { if _, err := pool.Exec(ctx, "UPDATE games SET status=$1", tc.status); err != nil { t.Fatal(err) } - if err := service.SubmitTeamAnswers(ctx, 1, "secret", nil); !errors.Is(err, tc.want) { + if err := service.SubmitTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000001", "secret", nil); !errors.Is(err, tc.want) { t.Fatalf("status %s: %v", tc.status, err) } } if _, err := pool.Exec(ctx, "UPDATE games SET status='run',format='offline'"); err != nil { t.Fatal(err) } - if answers, err := service.GetTeamAnswers(ctx, 1, "secret"); err != nil || len(answers) != 0 { + if answers, err := service.GetTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000001", "secret"); err != nil || len(answers) != 0 { t.Fatalf("offline answers: %v %v", answers, err) } - if err := service.SubmitTeamAnswers(ctx, 1, "secret", nil); err == nil { + if err := service.SubmitTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000001", "secret", nil); err == nil { t.Fatal("offline accepted answers") } if _, err := service.GetGameResults(ctx, 1); !errors.Is(err, ErrGameNotFinished) { @@ -73,10 +73,10 @@ func TestAnswerAccessPostgres(t *testing.T) { if _, err := pool.Exec(ctx, "UPDATE teams SET participation_status=$1", tc.participation); err != nil { t.Fatal(err) } - if _, err := service.GetTeamAnswers(ctx, 1, tc.password); !errors.Is(err, ErrTeamNotFound) { + if _, err := service.GetTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000001", tc.password); !errors.Is(err, ErrTeamNotFound) { t.Fatalf("read access: %v", err) } - if err := service.SubmitTeamAnswers(ctx, 1, tc.password, nil); !errors.Is(err, ErrTeamNotFound) { + if err := service.SubmitTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000001", tc.password, nil); !errors.Is(err, ErrTeamNotFound) { t.Fatalf("write access: %v", err) } } diff --git a/internal/services/device_presence_service/service.go b/internal/services/device_presence_service/service.go index 3dafc2b..18d77b3 100644 --- a/internal/services/device_presence_service/service.go +++ b/internal/services/device_presence_service/service.go @@ -34,7 +34,7 @@ type IGamePresence interface { GetGame(context.Context, int) (*game_model.Game, error) } type ITeamPresence interface { - CheckPresence(context.Context, int, string) (int, error) + CheckPresence(context.Context, string, string) (int, error) } type IScreenPresence interface { Get(context.Context, int, string) (*screen_service.ScreenSession, error) @@ -45,7 +45,7 @@ type Heartbeat struct { DeviceID string ConnectionID string OrganizerGameID *int - TeamID *int + TeamID *string Password string ScreenID string ScreenTokenHash string @@ -64,7 +64,7 @@ type Connection struct { UserAgent string Kind string GameID int - TeamID int + TeamID string ScreenID int Connected bool } @@ -205,7 +205,7 @@ func (s *DevicePresenceService) verify(ctx context.Context, input Heartbeat) (Co contexts++ } } - if contexts > 1 || organizer && *input.OrganizerGameID <= 0 || team && (input.TeamID == nil || *input.TeamID <= 0 || input.Password == "") || screen && (input.ScreenID == "" || input.ScreenTokenHash == "") { + if contexts > 1 || organizer && *input.OrganizerGameID <= 0 || team && (input.TeamID == nil || *input.TeamID == "" || input.Password == "") || screen && (input.ScreenID == "" || input.ScreenTokenHash == "") { return Connection{}, ErrInvalid } connection := Connection{ diff --git a/internal/services/device_presence_service/service_test.go b/internal/services/device_presence_service/service_test.go index 143d8fc..49f2b40 100644 --- a/internal/services/device_presence_service/service_test.go +++ b/internal/services/device_presence_service/service_test.go @@ -25,8 +25,8 @@ func (presenceFixture) GetGame(_ context.Context, id int) (*game_model.Game, err } return &game_model.Game{}, nil } -func (presenceFixture) CheckPresence(_ context.Context, id int, password string) (int, error) { - if id != 2 || password != "secret" { +func (presenceFixture) CheckPresence(_ context.Context, id string, password string) (int, error) { + if id != "019a1234-5678-7abc-8def-000000000002" || password != "secret" { return 0, team_service.ErrTeamNotFound } return 7, nil @@ -63,7 +63,7 @@ func TestRegistrationAndContext(t *testing.T) { s, input := newFixture() now := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC) s.now = func() time.Time { return now } - input.TeamID, input.Password = pointer(2), "secret" + input.TeamID, input.Password = teamPointer("019a1234-5678-7abc-8def-000000000002"), "secret" send(t, s, input) now = now.Add(time.Second) input.IP, input.UserAgent = "192.0.2.1", "updated" @@ -73,7 +73,7 @@ func TestRegistrationAndContext(t *testing.T) { t.Fatal(devices) } connection := devices[0].Connections[0] - if connection.IP != input.IP || connection.TeamID != 2 || connection.FirstSeenAt.Equal(connection.LastSeenAt) { + if connection.IP != input.IP || connection.TeamID != "019a1234-5678-7abc-8def-000000000002" || connection.FirstSeenAt.Equal(connection.LastSeenAt) { t.Fatal(connection) } tab := input @@ -88,13 +88,13 @@ func TestRegistrationAndContext(t *testing.T) { input.ScreenID, input.ScreenTokenHash = "3", "hash" send(t, s, input) connection = s.connections[input.ConnectionID] - if connection.TeamID != 0 || connection.ScreenID != 3 || connection.GameID != 7 { + if connection.TeamID != "" || connection.ScreenID != 3 || connection.GameID != 7 { t.Fatal(connection) } input.ScreenID, input.ScreenTokenHash = "", "" send(t, s, input) connection = s.connections[input.ConnectionID] - if connection.TeamID != 0 || connection.ScreenID != 0 || connection.GameID != 0 || connection.Kind != "browser" { + if connection.TeamID != "" || connection.ScreenID != 0 || connection.GameID != 0 || connection.Kind != "browser" { t.Fatal(connection) } if len(list(t, s, pointer(7))[0].Connections) != 1 { @@ -173,9 +173,13 @@ func TestInvalidContextDoesNotOverwrite(t *testing.T) { {"connection UUID", func(h *Heartbeat) { h.ConnectionID = "" }, ErrInvalid}, {"nil UUID", func(h *Heartbeat) { h.DeviceID = uuid.Nil.String() }, ErrInvalid}, {"device reassignment", func(h *Heartbeat) { h.DeviceID = uuid.NewString() }, ErrInvalid}, - {"mixed", func(h *Heartbeat) { h.TeamID = pointer(2); h.Password = "secret"; h.OrganizerGameID = pointer(7) }, ErrInvalid}, + {"mixed", func(h *Heartbeat) { + h.TeamID = teamPointer("019a1234-5678-7abc-8def-000000000002") + h.Password = "secret" + h.OrganizerGameID = pointer(7) + }, ErrInvalid}, {"password only", func(h *Heartbeat) { h.Password = "secret" }, ErrInvalid}, - {"team only", func(h *Heartbeat) { h.TeamID = pointer(2) }, ErrInvalid}, + {"team only", func(h *Heartbeat) { h.TeamID = teamPointer("019a1234-5678-7abc-8def-000000000002") }, ErrInvalid}, {"screen only", func(h *Heartbeat) { h.ScreenID = "3" }, ErrInvalid}, {"token only", func(h *Heartbeat) { h.ScreenTokenHash = "hash" }, ErrInvalid}, {"screen ID", func(h *Heartbeat) { h.ScreenID = "oops"; h.ScreenTokenHash = "hash" }, ErrInvalid}, @@ -183,7 +187,7 @@ func TestInvalidContextDoesNotOverwrite(t *testing.T) { {"anonymous organizer", func(h *Heartbeat) { h.OrganizerGameID = pointer(7) }, ErrUnauthorized}, {"user organizer", func(h *Heartbeat) { h.OrganizerGameID = pointer(7); h.IsAuthenticated = true }, ErrPermission}, {"missing game", func(h *Heartbeat) { h.OrganizerGameID = pointer(8); h.IsAuthenticated = true; h.CanManage = true }, game_service.ErrGameNotFound}, - {"bad team", func(h *Heartbeat) { h.TeamID = pointer(2); h.Password = "bad" }, team_service.ErrTeamNotFound}, + {"bad team", func(h *Heartbeat) { h.TeamID = teamPointer("019a1234-5678-7abc-8def-000000000002"); h.Password = "bad" }, team_service.ErrTeamNotFound}, {"bad screen", func(h *Heartbeat) { h.ScreenID = "3"; h.ScreenTokenHash = "bad" }, screen_service.ErrScreenNotFound}, } for _, test := range tests { @@ -256,3 +260,5 @@ func TestMetadataUTF8(t *testing.T) { } } } + +func teamPointer(id string) *string { return &id } diff --git a/internal/services/game_export_service/export_test.go b/internal/services/game_export_service/export_test.go index 1f55724..f878237 100644 --- a/internal/services/game_export_service/export_test.go +++ b/internal/services/game_export_service/export_test.go @@ -78,14 +78,15 @@ func (s *memoryStorage) Delete(ctx context.Context, key string) error { } type serviceFixture struct { - pool *pgxpool.Pool - repo *game_exports_repo.GameExportsRepo - codec *game_package_service.GamePackageService - games *games_repo.GamesRepo - teams *teams_repo.TeamsRepo - scenarios *scenarios_repo.ScenariosRepo - gameID, teamID, scenarioID, userID int - media []byte + pool *pgxpool.Pool + repo *game_exports_repo.GameExportsRepo + codec *game_package_service.GamePackageService + games *games_repo.GamesRepo + teams *teams_repo.TeamsRepo + scenarios *scenarios_repo.ScenariosRepo + gameID, scenarioID, userID int + teamID string + media []byte } // newServiceFixture is schema infrastructure only; all fixture data uses public repositories. diff --git a/internal/services/game_export_service/return_test.go b/internal/services/game_export_service/return_test.go index 749c9ff..72f94a5 100644 --- a/internal/services/game_export_service/return_test.go +++ b/internal/services/game_export_service/return_test.go @@ -15,6 +15,7 @@ import ( "evening_detective_server/internal/app" "evening_detective_server/internal/modules/processor_jwt" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "os" "reflect" @@ -67,8 +68,10 @@ func newReturnFixture(t *testing.T, format string) returnFixture { team.ParticipationStatus = "confirmed" local := f.userID newTeam := team - newTeam.ID = original.Data.Teams[len(original.Data.Teams)-1].ID + 10 - newTeam.SourceID = 0 + newID, err := uuid.NewV7() + check(t, err) + newTeam.ID = newID.String() + newTeam.SourceID = "" newTeam.CreatorID = nil newTeam.LocalCreatorID = &local newTeam.Name = "Venue team" @@ -92,7 +95,7 @@ func newReturnFixture(t *testing.T, format string) returnFixture { Source: results.Source{ScenarioID: f.scenarioID, ScenarioSHA256: hex.EncodeToString(storyHash[:]), Teams: original.Data.Teams, Tickets: original.Data.Tickets, Materials: []results.Material{material}, QuestionCodes: []string{"q", "q2"}}, Game: results.Game{ID: f.gameID, Status: "finish", Format: format, StartedAt: &now, EndedAt: &end}, Teams: []results.Team{team, newTeam}, Tickets: []results.Ticket{ticket, newTicket}, - DeletedTeamIDs: []int{original.Data.Teams[1].ID}, DeletedTicketIDs: []int{original.Data.Tickets[1].ID}, + DeletedTeamIDs: []string{original.Data.Teams[1].ID}, DeletedTicketIDs: []int{original.Data.Tickets[1].ID}, Actions: []results.Action{{ID: 70, TeamID: team.ID, Code: "p", CreatedAt: now}, {ID: 71, TeamID: team.ID, Code: "p", ViaMCP: true, CreatedAt: now}}, Applications: []results.Application{{ID: 80, TeamID: team.ID, Material: material, Status: "done", CreatedAt: now}}, Answers: []results.Answer{{TeamID: team.ID, QuestionCode: "q", Text: "Butler", Score: &score, CreatedAt: now, UpdatedAt: now}, {TeamID: team.ID, QuestionCode: "q2", Text: "Money", CreatedAt: now, UpdatedAt: now}}, @@ -167,10 +170,10 @@ func TestReturnAtomicLifecycle(t *testing.T) { if newTicket.ID == f.data.Tickets[1].ID { t.Fatal("venue ticket ID reused") } - added, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Sequence check", "qr") + added, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "UUID check", "qr") check(t, err) - if added <= f.data.Teams[1].ID { - t.Fatal("sequence not advanced") + if added == f.data.Teams[1].ID { + t.Fatal("team UUID reused") } check(t, f.teams.DeleteTeam(ctx, added)) again, err := f.apply(raw) @@ -230,7 +233,7 @@ func TestReturnAtomicLifecycle(t *testing.T) { } func TestReturnRollbackOnCollision(t *testing.T) { - for _, kind := range []string{"team ID", "ticket hash", "source teams", "source story", "original ZIP", "source materials", "source questions", "source tickets", "format", "team overflow"} { + for _, kind := range []string{"team ID", "ticket hash", "source teams", "source story", "original ZIP", "source materials", "source questions", "source tickets", "format"} { t.Run(kind, func(t *testing.T) { ctx := context.Background() f := newReturnFixture(t, "online") @@ -253,8 +256,6 @@ func TestReturnRollbackOnCollision(t *testing.T) { f.data.Source.ScenarioSHA256 = hex.EncodeToString(make([]byte, 32)) case "format": f.data.Game.Format = "offline" - case "team overflow": - f.data.Teams[1].ID = 2147483647 case "source materials": f.data.Source.Materials[0].Name = "Forged clue" f.data.Applications[0].Name = "Forged clue" diff --git a/internal/services/game_import_service/import_test.go b/internal/services/game_import_service/import_test.go index 1e87031..dd60dd8 100644 --- a/internal/services/game_import_service/import_test.go +++ b/internal/services/game_import_service/import_test.go @@ -174,8 +174,8 @@ func snapshot() *game_package_service.Snapshot { Questions: []*storytelling.Question{{Code: "q", Text: "Who?", Answer: "Secret"}}, }}, Teams: []game_package_service.Team{ - {ID: 200, GameID: 100, Name: "Confirmed", Status: "register", Password: "printed-qr", CreatorID: &owner, CaptainEmail: "captain@example.org", ParticipantsCount: 4, ParticipationStatus: "confirmed", ParticipationSource: "captain", CreatedAt: now}, - {ID: 201, GameID: 100, Name: "Cancelled", Status: "register", Password: "cancelled-qr", ParticipationStatus: "cancelled", CreatedAt: now}, + {ID: "019a1234-5678-7abc-8def-0000000000c8", GameID: 100, Name: "Confirmed", Status: "register", Password: "printed-qr", CreatorID: &owner, CaptainEmail: "captain@example.org", ParticipantsCount: 4, ParticipationStatus: "confirmed", ParticipationSource: "captain", CreatedAt: now}, + {ID: "019a1234-5678-7abc-8def-0000000000c9", GameID: 100, Name: "Cancelled", Status: "register", Password: "cancelled-qr", ParticipationStatus: "cancelled", CreatedAt: now}, }, Tickets: []game_package_service.Ticket{ {ID: 300, GameID: 100, TokenHash: hash[:], Status: "active", CreatedAt: now}, @@ -186,7 +186,7 @@ func snapshot() *game_package_service.Snapshot { {ID: 2, Name: "Game music", Tracks: []game_package_service.Track{{ID: 2, Name: "Same audio again", Filename: "music.mp3", Position: 0}}}, }, ScreenPlan: &game_package_service.ScreenPlan{GameID: 100, CurrentStep: 1, Steps: []game_screen_plans_repo.Step{{Mode: 1}, {Mode: 8, Text: "Welcome"}}}, - Map: []game_package_service.MapItem{{GameID: 100, Kind: "team", ItemID: 200, X: 50, Y: 60, Hidden: true}, {GameID: 100, Kind: "organizer_desk", ItemID: 1, X: 80, Y: 90}}, + Map: []game_package_service.MapItem{{GameID: 100, Kind: "team", ItemID: "019a1234-5678-7abc-8def-0000000000c8", X: 50, Y: 60, Hidden: true}, {GameID: 100, Kind: "organizer_desk", ItemID: "1", X: 80, Y: 90}}, }, } } @@ -255,7 +255,7 @@ func TestImportPreservesGameAndIsIdempotent(t *testing.T) { t.Fatal("local scenario overwritten") } teams := teams_repo.NewTeamsRepo(f.pool) - team, err := teams.GetTeamByID(ctx, 200) + team, err := teams.GetTeamByID(ctx, "019a1234-5678-7abc-8def-0000000000c8") check(t, err) if team.Password != "printed-qr" || team.CaptainEmail != "captain@example.org" || team.ParticipationStatus != "confirmed" { t.Fatalf("team changed: %+v", team) @@ -275,7 +275,7 @@ func TestImportPreservesGameAndIsIdempotent(t *testing.T) { } items, err := game_map_repo.NewGameMapRepo(f.pool).List(ctx, 100) check(t, err) - if len(items) != 2 || items[1].ItemID != 200 || !items[1].Hidden { + if len(items) != 2 || items[1].ItemID != "019a1234-5678-7abc-8def-0000000000c8" || !items[1].Hidden { t.Fatal("map lost") } music, err := playlists_repo.New(f.pool).GetMusic(ctx, 100) @@ -304,10 +304,10 @@ func TestImportPreservesGameAndIsIdempotent(t *testing.T) { t.Fatal("source data or ID mappings lost") } count := f.storage.count() - check(t, teams.UpdateTeam(ctx, 200, "Local edit")) + check(t, teams.UpdateTeam(ctx, "019a1234-5678-7abc-8def-0000000000c8", "Local edit")) repeated, err := f.service.Import(ctx, bytes.NewReader(raw), f.actor) check(t, err) - team, err = teams.GetTeamByID(ctx, 200) + team, err = teams.GetTeamByID(ctx, "019a1234-5678-7abc-8def-0000000000c8") check(t, err) if !repeated.Repeated || *repeated != (imports.Result{100, result.PackageID, true}) || team.Name != "Local edit" || f.storage.count() != count { t.Fatal("repeat changed local data") @@ -319,16 +319,16 @@ func TestImportPreservesGameAndIsIdempotent(t *testing.T) { hash := sha256.Sum256([]byte("new local ticket")) nextTickets, err := ticketRepo.CreateBatch(ctx, nextGame, [][]byte{hash[:]}) check(t, err) - if nextGame <= 100 || nextTeam <= 201 || nextTickets[0] <= 301 { + if nextGame <= 100 || nextTeam == "019a1234-5678-7abc-8def-0000000000c9" || nextTickets[0] <= 301 { t.Fatal("sequence not advanced") } gameplay := gameplay_service.NewGameplayService(games, teams, scenarios_service.NewScenarioService(scenarios, cleaner.NewCleaner(), "https://online.example", nil, nil), storytelling.NewStory(cleaner.NewCleaner()), actions_repo.NewActionsRepo(f.pool), applications_repo.NewApplicationsRepo(f.pool), cleaner.NewCleaner(), "https://online.example") - _, _, _, err = gameplay.GetTeamActions(ctx, 200, "printed-qr") + _, _, _, err = gameplay.GetTeamActions(ctx, "019a1234-5678-7abc-8def-0000000000c8", "printed-qr") check(t, err) for _, access := range []struct { - id int + id string password string - }{{200, "wrong-password"}, {201, "cancelled-qr"}} { + }{{"019a1234-5678-7abc-8def-0000000000c8", "wrong-password"}, {"019a1234-5678-7abc-8def-0000000000c9", "cancelled-qr"}} { _, _, _, err := gameplay.GetTeamActions(ctx, access.id, access.password) if !errors.Is(err, gameplay_service.ErrTeamNotFound) { t.Fatal("invalid or cancelled QR accepted") @@ -389,7 +389,7 @@ func TestImportConflictsDoNotChangeData(t *testing.T) { if !errors.Is(err, imports.ErrConflict) || f.storage.count() != count { t.Fatalf("conflict changed media or wrong error: %v", err) } - team, err := teams_repo.NewTeamsRepo(f.pool).GetTeamByID(context.Background(), 200) + team, err := teams_repo.NewTeamsRepo(f.pool).GetTeamByID(context.Background(), "019a1234-5678-7abc-8def-0000000000c8") check(t, err) if team.Password != "printed-qr" { t.Fatal("conflict overwrote QR") diff --git a/internal/services/game_import_service/service.go b/internal/services/game_import_service/service.go index 499026c..5d552c3 100644 --- a/internal/services/game_import_service/service.go +++ b/internal/services/game_import_service/service.go @@ -304,11 +304,6 @@ func validateLocalIDs(data *game_package_service.Data) error { if data.Game.ID > maxID { return fmt.Errorf("%w: game ID", game_package_service.ErrInvalidPackage) } - for _, team := range data.Teams { - if team.ID > maxID { - return fmt.Errorf("%w: team ID", game_package_service.ErrInvalidPackage) - } - } for _, ticket := range data.Tickets { if ticket.ID > maxID { return fmt.Errorf("%w: ticket ID", game_package_service.ErrInvalidPackage) diff --git a/internal/services/game_model/team.go b/internal/services/game_model/team.go index b86321d..9cb73f7 100644 --- a/internal/services/game_model/team.go +++ b/internal/services/game_model/team.go @@ -10,7 +10,7 @@ type Team struct { ParticipationEmailSent bool CaptainEmail string ParticipantsCount int32 - ID int + ID string Name string Status string Password string diff --git a/internal/services/game_package_service/model.go b/internal/services/game_package_service/model.go index e4c0841..2cad0ef 100644 --- a/internal/services/game_package_service/model.go +++ b/internal/services/game_package_service/model.go @@ -12,7 +12,7 @@ import ( ) const ( - Version = 1 + Version = 2 MaxArchiveSize int64 = 1 << 30 MaxFileSize int64 = 64 << 20 MaxJSONSize int64 = 8 << 20 @@ -31,7 +31,7 @@ var ( // A caller can adapt IFileStorage.Get with io.NopCloser(bytes.NewReader(file.Data)). type OpenLocalFile func(context.Context, string) (io.ReadCloser, error) -// Data is the version 1 game.json contract, independent of database row models. +// Data is the version 2 game.json contract, independent of database row models. type Data struct { Game Game `json:"game"` Scenario Scenario `json:"scenario"` @@ -79,7 +79,7 @@ type Scenario struct { } type Team struct { - ID int `json:"id"` + ID string `json:"id"` GameID int `json:"game_id"` Name string `json:"name"` Status string `json:"status"` @@ -126,7 +126,7 @@ type ScreenPlan struct { type MapItem struct { GameID int `json:"game_id"` Kind string `json:"kind"` - ItemID int `json:"item_id"` + ItemID string `json:"item_id"` X int `json:"x"` Y int `json:"y"` Hidden bool `json:"hidden"` diff --git a/internal/services/game_package_service/service_test.go b/internal/services/game_package_service/service_test.go index 9529be3..3c13a90 100644 --- a/internal/services/game_package_service/service_test.go +++ b/internal/services/game_package_service/service_test.go @@ -41,8 +41,8 @@ func fixture() (*Snapshot, map[string][]byte) { }, }, Teams: []Team{ - {ID: 40, GameID: 10, Name: "Confirmed", Status: "register", Password: "original-qr-password", CaptainEmail: "captain@example.org", ParticipantsCount: 4, ParticipationStatus: "confirmed", CreatorID: &user}, - {ID: 41, GameID: 10, Name: "Cancelled", Status: "register", Password: "cancelled-password", ParticipationStatus: "cancelled"}, + {ID: "019a1234-5678-7abc-8def-000000000028", GameID: 10, Name: "Confirmed", Status: "register", Password: "original-qr-password", CaptainEmail: "captain@example.org", ParticipantsCount: 4, ParticipationStatus: "confirmed", CreatorID: &user}, + {ID: "019a1234-5678-7abc-8def-000000000029", GameID: 10, Name: "Cancelled", Status: "register", Password: "cancelled-password", ParticipationStatus: "cancelled"}, }, Tickets: []Ticket{ {ID: 50, GameID: 10, TokenHash: bytes.Repeat([]byte{1}, 32), Status: "active", CreatedAt: now}, @@ -50,7 +50,7 @@ func fixture() (*Snapshot, map[string][]byte) { }, Playlists: []Playlist{{ID: 30, Name: "Music", Tracks: []Track{{ID: 60, Name: "Track", Filename: "music.mp3", Position: 0}}}}, ScreenPlan: &ScreenPlan{GameID: 10, CurrentStep: 1, Steps: []game_screen_plans_repo.Step{{Mode: 1}, {Mode: 7, TimerDurationSeconds: 30, TimerLabel: "Break"}}}, - Map: []MapItem{{GameID: 10, Kind: "team", ItemID: 40, X: 123, Y: 456}, {GameID: 10, Kind: "organizer_desk", ItemID: 1, X: 80, Y: 90}}, + Map: []MapItem{{GameID: 10, Kind: "team", ItemID: "019a1234-5678-7abc-8def-000000000028", X: 123, Y: 456}, {GameID: 10, Kind: "organizer_desk", ItemID: "1", X: 80, Y: 90}}, }, }, map[string][]byte{ "cover.png": []byte("\x89PNG\r\n\x1a\nfixture-image"), @@ -90,7 +90,7 @@ func TestPackagePreservesPregameData(t *testing.T) { t.Run(tc.name, func(t *testing.T) { snapshot, files := fixture() snapshot.Data.Game.Format = tc.format - snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: 99}) + snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: "019a1234-5678-7abc-8def-000000000063"}) before, _ := json.Marshal(snapshot) calls := map[string]int{} service := NewGamePackageService("") @@ -185,10 +185,10 @@ func TestPackRejectsInvalidSnapshot(t *testing.T) { {"duplicate track", func(s *Snapshot) { s.Data.Playlists[0].Tracks = append(s.Data.Playlists[0].Tracks, s.Data.Playlists[0].Tracks[0]) }}, - {"foreign map team", func(s *Snapshot) { s.Data.Map[0].ItemID = 99 }}, + {"foreign map team", func(s *Snapshot) { s.Data.Map[0].ItemID = "019a1234-5678-7abc-8def-000000000063" }}, {"foreign map game", func(s *Snapshot) { s.Data.Map[0].GameID++ }}, {"duplicate map item", func(s *Snapshot) { s.Data.Map = append(s.Data.Map, s.Data.Map[0]) }}, - {"invalid organizer desk", func(s *Snapshot) { s.Data.Map[1].ItemID = 0 }}, + {"invalid organizer desk", func(s *Snapshot) { s.Data.Map[1].ItemID = "" }}, {"invalid plan index", func(s *Snapshot) { s.Data.ScreenPlan.CurrentStep = 10 }}, {"invalid plan mode", func(s *Snapshot) { s.Data.ScreenPlan.Steps[0].Mode = 9 }}, } { @@ -520,6 +520,7 @@ func TestUnpackRejectsDamagedPackages(t *testing.T) { change func([]zipEntry) []zipEntry want error }{ + {"legacy version", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Version = 1 }) }, ErrUnsupportedVersion}, {"version", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Version++ }) }, ErrUnsupportedVersion}, {"wrong game ID", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.GameID++ }) }, ErrInvalidPackage}, {"bad MIME", func(e []zipEntry) []zipEntry { diff --git a/internal/services/game_package_service/unpack.go b/internal/services/game_package_service/unpack.go index 0c79022..47ec1b4 100644 --- a/internal/services/game_package_service/unpack.go +++ b/internal/services/game_package_service/unpack.go @@ -198,7 +198,7 @@ func (s *GamePackageService) extractMedia(ctx context.Context, dir string, entry } // Bound the central directory before archive/zip allocates one object per entry. -// Version 1 cannot need ZIP64: all counts and sizes fit in ordinary ZIP fields. +// The package limits do not require ZIP64: all counts and sizes fit in ordinary ZIP fields. func (s *GamePackageService) checkDirectory(input io.ReaderAt, size int64) error { tail := make([]byte, min(size, int64(65535+22))) if _, err := input.ReadAt(tail, size-int64(len(tail))); err != nil { diff --git a/internal/services/game_package_service/validation.go b/internal/services/game_package_service/validation.go index 0b9d902..0648912 100644 --- a/internal/services/game_package_service/validation.go +++ b/internal/services/game_package_service/validation.go @@ -8,6 +8,8 @@ import ( "path" "strings" "unicode" + + "github.com/google/uuid" ) func invalid(reason string) error { @@ -64,10 +66,10 @@ func validateData(data *Data) error { } questions[question.Code] = true } - teams := map[int]bool{} + teams := map[string]bool{} deliveries := map[string]bool{} for _, team := range data.Teams { - if team.ID <= 0 || teams[team.ID] || team.GameID != g.ID || strings.TrimSpace(team.Name) == "" || + if !validTeamID(team.ID) || teams[team.ID] || team.GameID != g.ID || strings.TrimSpace(team.Name) == "" || team.Password == "" || team.Status != "register" || team.ParticipantsCount < 0 || !validUserID(team.CreatorID) || (team.ParticipationStatus != "pending" && team.ParticipationStatus != "confirmed" && team.ParticipationStatus != "cancelled") { @@ -134,11 +136,11 @@ func validateData(data *Data) error { } items := map[string]bool{} for _, item := range data.Map { - key := fmt.Sprintf("%s/%d", item.Kind, item.ItemID) + key := fmt.Sprintf("%s/%s", item.Kind, item.ItemID) if item.GameID != g.ID || items[key] || (item.Kind != "team" && item.Kind != "organizer_desk") || - (item.Kind == "team" && !teams[item.ItemID]) || item.ItemID <= 0 || - (item.Kind == "organizer_desk" && item.ItemID != 1) { + (item.Kind == "team" && !teams[item.ItemID]) || item.ItemID == "" || + (item.Kind == "organizer_desk" && item.ItemID != "1") { return invalid("некорректный объект карты") } items[key] = true @@ -215,3 +217,8 @@ func RewriteMedia(data *Data, names map[string]string) error { } return nil } + +func validTeamID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil && parsed.String() == id +} diff --git a/internal/services/game_results_package_service/apply_mapper.go b/internal/services/game_results_package_service/apply_mapper.go index d3715c8..f5fe027 100644 --- a/internal/services/game_results_package_service/apply_mapper.go +++ b/internal/services/game_results_package_service/apply_mapper.go @@ -7,16 +7,16 @@ import ( ) type idMappings struct { - Scenario int `json:"scenario"` - Teams map[int]int `json:"teams"` - Tickets map[int]int `json:"tickets"` - Actions map[int]int `json:"actions"` - Applications map[int]int `json:"applications"` + Scenario int `json:"scenario"` + Teams map[string]string `json:"teams"` + Tickets map[int]int `json:"tickets"` + Actions map[int]int `json:"actions"` + Applications map[int]int `json:"applications"` } func newMappings() idMappings { return idMappings{ - Teams: map[int]int{}, + Teams: map[string]string{}, Tickets: map[int]int{}, Actions: map[int]int{}, Applications: map[int]int{}, @@ -24,13 +24,13 @@ func newMappings() idMappings { } type actors struct { - Creators map[int]int `json:"creators"` - TicketUsers map[int]int `json:"ticket_users"` + Creators map[string]int `json:"creators"` + TicketUsers map[int]int `json:"ticket_users"` } func localActors(data *Data) actors { result := actors{ - Creators: map[int]int{}, + Creators: map[string]int{}, TicketUsers: map[int]int{}, } for _, team := range data.Teams { diff --git a/internal/services/game_results_package_service/codec_test.go b/internal/services/game_results_package_service/codec_test.go index 68f1628..a5c90f8 100644 --- a/internal/services/game_results_package_service/codec_test.go +++ b/internal/services/game_results_package_service/codec_test.go @@ -22,7 +22,7 @@ func sampleData(format string) *Data { owner, local, zero := 20, 2, 0 hash := bytes.Repeat([]byte{1}, 32) sourceTeam := game_package_service.Team{ - ID: 10, + ID: "019a1234-5678-7abc-8def-00000000000a", GameID: 1, Name: "Source", Status: "register", @@ -42,19 +42,19 @@ func sampleData(format string) *Data { } team := Team{ Team: sourceTeam, - SourceID: 10, + SourceID: "019a1234-5678-7abc-8def-00000000000a", } team.ParticipationStatus = "confirmed" team.ParticipationSource = "first_action" team.ParticipationAnsweredAt = &now newTeam := team - newTeam.ID = 11 - newTeam.SourceID = 0 + newTeam.ID = "019a1234-5678-7abc-8def-00000000000b" + newTeam.SourceID = "" newTeam.CreatorID = nil newTeam.LocalCreatorID = &local newTeam.Password = "local-qr" removedTeam := sourceTeam - removedTeam.ID = 12 + removedTeam.ID = "019a1234-5678-7abc-8def-00000000000c" ticket := Ticket{ Ticket: sourceTicket, SourceID: 30, @@ -92,18 +92,18 @@ func sampleData(format string) *Data { }, Teams: []Team{team, newTeam}, Tickets: []Ticket{ticket, newTicket}, - DeletedTeamIDs: []int{12}, + DeletedTeamIDs: []string{"019a1234-5678-7abc-8def-00000000000c"}, DeletedTicketIDs: []int{32}, Actions: []Action{ - {ID: 1, TeamID: 10, Code: "unknown", CreatedAt: now}, - {ID: 2, TeamID: 10, Code: "unknown", ViaMCP: true, CreatedAt: now}, + {ID: 1, TeamID: "019a1234-5678-7abc-8def-00000000000a", Code: "unknown", CreatedAt: now}, + {ID: 2, TeamID: "019a1234-5678-7abc-8def-00000000000a", Code: "unknown", ViaMCP: true, CreatedAt: now}, }, Applications: []Application{ - {ID: 1, TeamID: 10, Material: material, Status: "done", CreatedAt: now}, + {ID: 1, TeamID: "019a1234-5678-7abc-8def-00000000000a", Material: material, Status: "done", CreatedAt: now}, }, Answers: []Answer{ - {TeamID: 10, QuestionCode: "q", Text: "", Score: &zero, CreatedAt: now, UpdatedAt: end}, - {TeamID: 11, QuestionCode: "q2", Text: "Answer", CreatedAt: now, UpdatedAt: now}, + {TeamID: "019a1234-5678-7abc-8def-00000000000a", QuestionCode: "q", Text: "", Score: &zero, CreatedAt: now, UpdatedAt: end}, + {TeamID: "019a1234-5678-7abc-8def-00000000000b", QuestionCode: "q2", Text: "Answer", CreatedAt: now, UpdatedAt: now}, }, } } @@ -165,7 +165,7 @@ func TestPackRoundTripSupportsEmptyResults(t *testing.T) { data.Source.QuestionCodes = []string{} data.Teams = []Team{} data.Tickets = []Ticket{} - data.DeletedTeamIDs = []int{} + data.DeletedTeamIDs = []string{} data.DeletedTicketIDs = []int{} data.Actions = []Action{} data.Applications = []Application{} @@ -188,16 +188,18 @@ func TestPackRejectsInvalidRelations(t *testing.T) { {"end before start", func(d *Data) { end := d.Game.StartedAt.Add(-time.Second); d.Game.EndedAt = &end }}, {"missing complete array", func(d *Data) { d.Actions = nil }}, {"duplicate team", func(d *Data) { d.Teams = append(d.Teams, d.Teams[0]) }}, - {"source identity", func(d *Data) { d.Teams[0].SourceID = 0 }}, + {"source identity", func(d *Data) { d.Teams[0].SourceID = "" }}, {"local source user", func(d *Data) { d.Teams[1].CreatorID = d.Teams[0].CreatorID }}, - {"missing deletion", func(d *Data) { d.DeletedTeamIDs = []int{} }}, + {"missing deletion", func(d *Data) { d.DeletedTeamIDs = []string{} }}, {"invalid deletion", func(d *Data) { d.DeletedTicketIDs = []int{30} }}, - {"duplicate deletion", func(d *Data) { d.DeletedTeamIDs = []int{12, 12} }}, + {"duplicate deletion", func(d *Data) { + d.DeletedTeamIDs = []string{"019a1234-5678-7abc-8def-00000000000c", "019a1234-5678-7abc-8def-00000000000c"} + }}, {"changed hash", func(d *Data) { d.Tickets[0].TokenHash = bytes.Repeat([]byte{9}, 32) }}, {"duplicate ticket", func(d *Data) { d.Tickets = append(d.Tickets, d.Tickets[0]) }}, {"active with actor", func(d *Data) { d.Tickets[1].Status = "active"; d.Tickets[1].UsedAt = nil }}, {"source actor", func(d *Data) { d.Tickets[0].UsedBy = d.Teams[0].CreatorID }}, - {"unknown action team", func(d *Data) { d.Actions[0].TeamID = 999 }}, + {"unknown action team", func(d *Data) { d.Actions[0].TeamID = "019a1234-5678-7abc-8def-0000000003e7" }}, {"duplicate action", func(d *Data) { d.Actions[1].ID = d.Actions[0].ID }}, {"reordered actions", func(d *Data) { d.Actions[0], d.Actions[1] = d.Actions[1], d.Actions[0] }}, {"unknown material", func(d *Data) { d.Applications[0].Image = "files/other.pdf" }}, @@ -257,7 +259,7 @@ func TestUnpackRejectsDamagedArchive(t *testing.T) { want error }{ {"version", func(e []zipEntry) []zipEntry { - e[0].raw = bytes.Replace(e[0].raw, []byte(`"version":1`), []byte(`"version":2`), 1) + e[0].raw = bytes.Replace(e[0].raw, []byte(`"version":2`), []byte(`"version":1`), 1) return e }, ErrUnsupportedVersion}, {"missing", func(e []zipEntry) []zipEntry { return e[:1] }, ErrInvalidPackage}, @@ -270,7 +272,7 @@ func TestUnpackRejectsDamagedArchive(t *testing.T) { }, ErrInvalidPackage}, {"trailing JSON", func(e []zipEntry) []zipEntry { e[0].raw = append(e[0].raw, []byte("{}")...); return e }, ErrInvalidPackage}, {"unknown field", func(e []zipEntry) []zipEntry { - e[0].raw = bytes.Replace(e[0].raw, []byte(`"version":1`), []byte(`"version":1,"private":true`), 1) + e[0].raw = bytes.Replace(e[0].raw, []byte(`"version":2`), []byte(`"version":2,"private":true`), 1) return e }, ErrInvalidPackage}, {"oversized JSON", func(e []zipEntry) []zipEntry { diff --git a/internal/services/game_results_package_service/flow_test.go b/internal/services/game_results_package_service/flow_test.go index 464d9b5..e5e6755 100644 --- a/internal/services/game_results_package_service/flow_test.go +++ b/internal/services/game_results_package_service/flow_test.go @@ -68,7 +68,7 @@ func TestVenueFlowResults(t *testing.T) { break } } - if scored.TeamID == 0 { + if scored.TeamID == "" { t.Fatal("score missing") } answers := team_answers_repo.NewTeamAnswersRepo(pool) diff --git a/internal/services/game_results_package_service/model.go b/internal/services/game_results_package_service/model.go index 3c0985f..00ca4d8 100644 --- a/internal/services/game_results_package_service/model.go +++ b/internal/services/game_results_package_service/model.go @@ -8,7 +8,7 @@ import ( "evening_detective_server/internal/services/game_package_service" ) -const Version = 1 +const Version = 2 var ( ErrInvalidPackage = errors.New("некорректный пакет результатов") @@ -55,11 +55,11 @@ type Game struct { EndedAt *time.Time `json:"ended_at"` } -// SourceID is zero for venue-created rows; their ID is only a local reference. +// SourceID is empty for venue-created teams; UUIDs survive the return unchanged. type Team struct { game_package_service.Team - SourceID int `json:"source_id"` - LocalCreatorID *int `json:"local_creator_id,omitempty"` + SourceID string `json:"source_id"` + LocalCreatorID *int `json:"local_creator_id,omitempty"` } type Ticket struct { @@ -70,7 +70,7 @@ type Ticket struct { type Action struct { ID int `json:"id"` - TeamID int `json:"team_id"` + TeamID string `json:"team_id"` Code string `json:"code"` ViaMCP bool `json:"via_mcp"` CreatedAt time.Time `json:"created_at"` @@ -78,15 +78,15 @@ type Action struct { // Application.Image is a portable path in the original game package. type Application struct { - ID int `json:"id"` - TeamID int `json:"team_id"` + ID int `json:"id"` + TeamID string `json:"team_id"` Material Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } type Answer struct { - TeamID int `json:"team_id"` + TeamID string `json:"team_id"` QuestionCode string `json:"question_code"` Text string `json:"answer"` Score *int `json:"score"` @@ -94,13 +94,13 @@ type Answer struct { UpdatedAt time.Time `json:"updated_at"` } -// Data is the version 1 results.json contract. Arrays are complete snapshots. +// Data is the version 2 results.json contract. Arrays are complete snapshots. type Data struct { Source Source `json:"source"` Game Game `json:"game"` Teams []Team `json:"teams"` Tickets []Ticket `json:"tickets"` - DeletedTeamIDs []int `json:"deleted_team_ids"` + DeletedTeamIDs []string `json:"deleted_team_ids"` DeletedTicketIDs []int `json:"deleted_ticket_ids"` Actions []Action `json:"actions"` Applications []Application `json:"applications"` diff --git a/internal/services/game_results_package_service/snapshot.go b/internal/services/game_results_package_service/snapshot.go index 854daf6..fc71700 100644 --- a/internal/services/game_results_package_service/snapshot.go +++ b/internal/services/game_results_package_service/snapshot.go @@ -74,7 +74,7 @@ func prepareData(snapshot *game_results_repo.Snapshot) (*Data, error) { return nil, ErrScenarioChanged } } - sourceTeams := map[int]game_package_service.Team{} + sourceTeams := map[string]game_package_service.Team{} sourceTickets := map[int]game_package_service.Ticket{} for _, team := range data.Source.Teams { sourceTeams[team.ID] = team @@ -82,7 +82,7 @@ func prepareData(snapshot *game_results_repo.Snapshot) (*Data, error) { for _, ticket := range data.Source.Tickets { sourceTickets[ticket.ID] = ticket } - data.DeletedTeamIDs = []int{} + data.DeletedTeamIDs = []string{} data.DeletedTicketIDs = []int{} for i := range data.Teams { team := &data.Teams[i] diff --git a/internal/services/game_results_package_service/snapshot_test.go b/internal/services/game_results_package_service/snapshot_test.go index 7b335e6..749df44 100644 --- a/internal/services/game_results_package_service/snapshot_test.go +++ b/internal/services/game_results_package_service/snapshot_test.go @@ -59,7 +59,7 @@ func storedSample(t *testing.T) *game_results_repo.Snapshot { data.DeletedTicketIDs = nil for i := range data.Teams { data.Teams[i].CreatorID = data.Teams[i].LocalCreatorID - data.Teams[i].SourceID = 0 + data.Teams[i].SourceID = "" } for i := range data.Tickets { data.Tickets[i].UsedBy = data.Tickets[i].LocalUsedBy @@ -94,7 +94,7 @@ func TestPrepareDataRestoresSourceReferences(t *testing.T) { if data.Applications[0].Image != "files/clue.pdf" || data.Teams[0].CreatorID == nil || *data.Teams[0].CreatorID != 20 || data.Teams[1].CreatorID != nil || data.Tickets[0].UsedBy != nil || data.Tickets[0].LocalUsedBy == nil { t.Fatal("source/local references mixed") } - if len(data.DeletedTeamIDs) != 1 || data.DeletedTeamIDs[0] != 12 || len(data.DeletedTicketIDs) != 1 || data.DeletedTicketIDs[0] != 32 { + if len(data.DeletedTeamIDs) != 1 || data.DeletedTeamIDs[0] != "019a1234-5678-7abc-8def-00000000000c" || len(data.DeletedTicketIDs) != 1 || data.DeletedTicketIDs[0] != 32 { t.Fatal("deletions lost") } }) diff --git a/internal/services/game_results_package_service/validation.go b/internal/services/game_results_package_service/validation.go index 59a58ca..9020dbf 100644 --- a/internal/services/game_results_package_service/validation.go +++ b/internal/services/game_results_package_service/validation.go @@ -9,6 +9,8 @@ import ( "strings" "evening_detective_server/internal/services/game_package_service" + + "github.com/google/uuid" ) func validHex(value string, size int) bool { @@ -35,7 +37,7 @@ func validateManifest(m Manifest, gameID int) error { func validUser(id *int) bool { return id == nil || *id > 0 } func validTeam(team game_package_service.Team, gameID int) bool { - return team.ID > 0 && team.GameID == gameID && strings.TrimSpace(team.Name) != "" && team.Password != "" && team.Status == "register" && + return validTeamID(team.ID) && team.GameID == gameID && strings.TrimSpace(team.Name) != "" && team.Password != "" && team.Status == "register" && team.ParticipantsCount >= 0 && validUser(team.CreatorID) && !team.CreatedAt.IsZero() && (team.ParticipationStatus == "pending" || team.ParticipationStatus == "confirmed" || team.ParticipationStatus == "cancelled") } @@ -66,11 +68,11 @@ func validateData(data *Data) error { data.Teams == nil || data.Tickets == nil || data.DeletedTeamIDs == nil || data.DeletedTicketIDs == nil || data.Actions == nil || data.Applications == nil || data.Answers == nil { return ErrInvalidPackage } - sourceTeams := map[int]game_package_service.Team{} + sourceTeams := map[string]game_package_service.Team{} sourceTickets := map[int]game_package_service.Ticket{} hashes := map[string]bool{} for _, team := range data.Source.Teams { - if !validTeam(team, game.ID) || sourceTeams[team.ID].ID != 0 { + if !validTeam(team, game.ID) || sourceTeams[team.ID].ID != "" { return ErrInvalidPackage } sourceTeams[team.ID] = team @@ -82,13 +84,13 @@ func validateData(data *Data) error { sourceTickets[ticket.ID] = ticket hashes[string(ticket.TokenHash)] = true } - teams := map[int]bool{} + teams := map[string]bool{} deliveries := map[string]bool{} for _, team := range data.Teams { source, exists := sourceTeams[team.ID] if !validTeam(team.Team, game.ID) || teams[team.ID] || !validUser(team.LocalCreatorID) || (exists && (team.SourceID != team.ID || !sameUser(team.CreatorID, source.CreatorID) || !sameDelivery(team.RegistrationDeliveryID, source.RegistrationDeliveryID))) || - (!exists && (team.SourceID != 0 || team.CreatorID != nil || team.RegistrationDeliveryID != nil)) { + (!exists && (team.SourceID != "" || team.CreatorID != nil || team.RegistrationDeliveryID != nil)) { return ErrInvalidPackage } if team.RegistrationDeliveryID != nil { @@ -131,7 +133,7 @@ func validateData(data *Data) error { questions[code] = true } actions := map[int]bool{} - previous := map[int]Action{} + previous := map[string]Action{} for _, action := range data.Actions { prev := previous[action.TeamID] if action.ID <= 0 || actions[action.ID] || !teams[action.TeamID] || action.CreatedAt.IsZero() || @@ -149,7 +151,7 @@ func validateData(data *Data) error { } applications[application.ID] = true } - answers := map[int]map[string]bool{} + answers := map[string]map[string]bool{} for _, answer := range data.Answers { if answers[answer.TeamID] == nil { answers[answer.TeamID] = map[string]bool{} @@ -162,10 +164,10 @@ func validateData(data *Data) error { return nil } -func validDeletedTeams(ids []int, source map[int]game_package_service.Team, current map[int]bool) bool { - seen := map[int]bool{} +func validDeletedTeams(ids []string, source map[string]game_package_service.Team, current map[string]bool) bool { + seen := map[string]bool{} for _, id := range ids { - if source[id].ID == 0 || current[id] || seen[id] { + if source[id].ID == "" || current[id] || seen[id] { return false } seen[id] = true @@ -193,3 +195,8 @@ func validDeletedTickets(ids []int, source map[int]game_package_service.Ticket, } return true } + +func validTeamID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil && parsed.String() == id +} diff --git a/internal/services/game_review_service/mapper.go b/internal/services/game_review_service/mapper.go index dc4b4cc..b9e8525 100644 --- a/internal/services/game_review_service/mapper.go +++ b/internal/services/game_review_service/mapper.go @@ -12,7 +12,7 @@ type Review struct { ID int GameID int GameName string - TeamID int + TeamID string TeamName string Rating int32 Difficulty int32 diff --git a/internal/services/game_review_service/service.go b/internal/services/game_review_service/service.go index 72b7706..c21b8e5 100644 --- a/internal/services/game_review_service/service.go +++ b/internal/services/game_review_service/service.go @@ -49,7 +49,7 @@ func NewGameReviewService(reviews *game_reviews_repo.Repo, games *games_repo.Gam } // Create stores a game review. -func (s *GameReviewService) Create(ctx context.Context, gameID, teamID int, password string, rating, difficulty int32, text string) (*Review, error) { +func (s *GameReviewService) Create(ctx context.Context, gameID int, teamID string, password string, rating, difficulty int32, text string) (*Review, error) { if rating < 1 || rating > 5 || difficulty < 1 || difficulty > 3 || strings.TrimSpace(text) == "" { return nil, ErrInvalidReview } @@ -145,7 +145,7 @@ func (s *GameReviewService) ListByScenario(ctx context.Context, scenarioID int) } func reviewReminderEmail(job *teams_repo.ReviewReminder, baseURL string) email_sender.Message { - link := strings.TrimRight(baseURL, "/") + "/team-story/" + fmt.Sprint(job.TeamID) + "?" + url.Values{"password": {job.Password}, "review": {"1"}}.Encode() + link := strings.TrimRight(baseURL, "/") + "/team-story/" + job.TeamID + "?" + url.Values{"password": {job.Password}, "review": {"1"}}.Encode() body := fmt.Sprintf("Команда «%s», поделитесь впечатлениями об игре «%s».\n\nОценка и отзыв займут пару минут: %s\n\nОтзыв можно оставить один раз от команды.", job.TeamName, job.GameName, link) content := "

Как вам игра?

Команда «" + html.EscapeString(job.TeamName) + "» — «" + html.EscapeString(job.GameName) + "»

" content += email_sender.ButtonHTML(link, "Оставить отзыв") diff --git a/internal/services/game_review_service/service_test.go b/internal/services/game_review_service/service_test.go index 0d9c78a..022c48b 100644 --- a/internal/services/game_review_service/service_test.go +++ b/internal/services/game_review_service/service_test.go @@ -16,7 +16,7 @@ func TestReviewReminderEmail(t *testing.T) { { name: "escapes game name and builds review link", job: &teams_repo.ReviewReminder{ - TeamID: 7, TeamName: "Шерлоки", GameName: "Ночь & тайна", Email: "captain@example.org", Password: "secret", + TeamID: "019a1234-5678-7abc-8def-000000000007", TeamName: "Шерлоки", GameName: "Ночь & тайна", Email: "captain@example.org", Password: "secret", }, want: "Ночь & тайна", }, @@ -24,7 +24,7 @@ func TestReviewReminderEmail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { message := reviewReminderEmail(tt.job, "https://app.example") - if message.To != "captain@example.org" || !strings.Contains(message.Body, "/team-story/7?password=secret&review=1") { + if message.To != "captain@example.org" || !strings.Contains(message.Body, "/team-story/019a1234-5678-7abc-8def-000000000007?password=secret&review=1") { t.Fatalf("unexpected reminder email: %+v", message) } if !strings.Contains(message.HTML, tt.want) { diff --git a/internal/services/game_service/lifecycle_test.go b/internal/services/game_service/lifecycle_test.go index 678286f..91513eb 100644 --- a/internal/services/game_service/lifecycle_test.go +++ b/internal/services/game_service/lifecycle_test.go @@ -52,10 +52,10 @@ func TestServicesLifecyclePostgres(t *testing.T) { author_id INT, status TEXT DEFAULT 'draft', updated_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(), published_at TIMESTAMP, is_deleted BOOLEAN DEFAULT FALSE); CREATE TEMP TABLE games(id SERIAL PRIMARY KEY, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP, scenario_id INT, format TEXT, status TEXT DEFAULT 'draft', started_at TIMESTAMP, ended_at TIMESTAMP, updated_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW()); - CREATE TEMP TABLE teams(id SERIAL PRIMARY KEY, name TEXT, creator_id INT, game_id INT, password TEXT, status TEXT DEFAULT 'register', created_at TIMESTAMP DEFAULT NOW()); - CREATE TEMP TABLE actions(id SERIAL PRIMARY KEY, team_id INT, code TEXT, via_mcp BOOLEAN, created_at TIMESTAMP DEFAULT NOW()); - CREATE TEMP TABLE applications(id SERIAL PRIMARY KEY, team_id INT, name TEXT, image TEXT, file_type TEXT, status TEXT DEFAULT 'need', created_at TIMESTAMP DEFAULT NOW()); - CREATE TEMP TABLE team_answers(team_id INT, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code)); + CREATE TEMP TABLE teams(id UUID PRIMARY KEY, name TEXT, creator_id INT, game_id INT, password TEXT, status TEXT DEFAULT 'register', created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE actions(id SERIAL PRIMARY KEY, team_id UUID, code TEXT, via_mcp BOOLEAN, created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE applications(id SERIAL PRIMARY KEY, team_id UUID, name TEXT, image TEXT, file_type TEXT, status TEXT DEFAULT 'need', created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE team_answers(team_id UUID, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code)); INSERT INTO scenarios(id,name,scenario) VALUES(1,'Case','{"introduction":{"text":"Intro"},"places":[{"code":"p","name":"Place","applications":[{"name":"Clue","image":"clue.pdf"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}');`) check(err) for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} { @@ -124,7 +124,7 @@ func TestServicesLifecyclePostgres(t *testing.T) { check(err) registered, err = teams.GetTeamByID(ctx, teamID) check(err) - counts, err := actions.GetActionsCountByTeamIDs(ctx, []int{teamID}) + counts, err := actions.GetActionsCountByTeamIDs(ctx, []string{teamID}) check(err) if registered.ParticipationStatus != "pending" || counts[teamID] != 0 { t.Fatal("rejected moves changed participation or progress") diff --git a/internal/services/game_service/service.go b/internal/services/game_service/service.go index 6ed68a2..e5d8a0f 100644 --- a/internal/services/game_service/service.go +++ b/internal/services/game_service/service.go @@ -122,7 +122,7 @@ func (s *GameService) GetFullGame( res := game_model.MapGame(game, s.domain) - teamIDs := make([]int, 0, len(game.Teams)) + teamIDs := make([]string, 0, len(game.Teams)) for _, team := range game.Teams { teamIDs = append(teamIDs, team.ID) } diff --git a/internal/services/gameplay_service/finished_story_test.go b/internal/services/gameplay_service/finished_story_test.go index f6b7376..e4c7267 100644 --- a/internal/services/gameplay_service/finished_story_test.go +++ b/internal/services/gameplay_service/finished_story_test.go @@ -34,10 +34,10 @@ func TestFinishedTeamStoryPostgres(t *testing.T) { CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ); CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP, format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); - CREATE TEMP TABLE teams (id INT, name TEXT, captain_email TEXT, participants_count INT, + CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT, status TEXT, password TEXT, game_id INT); INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL); - INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1); + INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1); ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '', ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`) if err != nil { @@ -49,7 +49,7 @@ func TestFinishedTeamStoryPostgres(t *testing.T) { if _, err := pool.Exec(ctx, `UPDATE games SET format = $1`, format); err != nil { t.Fatal(err) } - story, game, actions, err := s.GetTeamActions(ctx, 1, "secret") + story, game, actions, err := s.GetTeamActions(ctx, "019a1234-5678-7abc-8def-000000000001", "secret") if err != nil { t.Fatal(err) } @@ -57,7 +57,7 @@ func TestFinishedTeamStoryPostgres(t *testing.T) { t.Fatalf("finished game exposed content or lost status: story=%+v game=%+v actions=%+v", story, game, actions) } } - if _, _, _, err := s.GetTeamActions(ctx, 1, "wrong"); !errors.Is(err, ErrTeamNotFound) { + if _, _, _, err := s.GetTeamActions(ctx, "019a1234-5678-7abc-8def-000000000001", "wrong"); !errors.Is(err, ErrTeamNotFound) { t.Fatalf("password check bypassed: %v", err) } } diff --git a/internal/services/gameplay_service/service.go b/internal/services/gameplay_service/service.go index 986ac18..9f850e0 100644 --- a/internal/services/gameplay_service/service.go +++ b/internal/services/gameplay_service/service.go @@ -12,6 +12,8 @@ import ( "evening_detective_server/internal/services/game_model" "evening_detective_server/internal/services/scenarios_service" "fmt" + + "github.com/google/uuid" ) var ( @@ -59,12 +61,16 @@ func NewGameplayService( } // GiveTeamApplications grants an application to a team. -func (s *GameplayService) GiveTeamApplications(ctx context.Context, teamID int, name string) error { +func (s *GameplayService) GiveTeamApplications(ctx context.Context, teamID string, name string) error { + id, err := uuid.Parse(teamID) + if err != nil || id == uuid.Nil || id.String() != teamID { + return ErrTeamNotFound + } return mapGameplayError(s.applicationsRepo.UpdateApplicationStatus(ctx, teamID, name, "done")) } // GetTeamActions returns the current story, game, and available team actions. -func (s *GameplayService) GetTeamActions(ctx context.Context, teamID int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) { +func (s *GameplayService) GetTeamActions(ctx context.Context, teamID string, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { return nil, nil, nil, mapGameplayError(err) @@ -102,7 +108,7 @@ func (s *GameplayService) GetTeamActions(ctx context.Context, teamID int, passwo } // AddTeamAction records an action performed by a team. -func (s *GameplayService) AddTeamAction(ctx context.Context, teamID int, password string, actionCode string, viaMCP bool) error { +func (s *GameplayService) AddTeamAction(ctx context.Context, teamID string, password string, actionCode string, viaMCP bool) error { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { return mapGameplayError(err) @@ -160,7 +166,7 @@ func (s *GameplayService) AddTeamAction(ctx context.Context, teamID int, passwor } // DeleteLastTeamAction removes the latest action for a team. -func (s *GameplayService) DeleteLastTeamAction(ctx context.Context, teamID int) error { +func (s *GameplayService) DeleteLastTeamAction(ctx context.Context, teamID string) error { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { return mapGameplayError(err) diff --git a/internal/services/gameplay_service/team_id_test.go b/internal/services/gameplay_service/team_id_test.go new file mode 100644 index 0000000..fbfb190 --- /dev/null +++ b/internal/services/gameplay_service/team_id_test.go @@ -0,0 +1,18 @@ +package gameplay_service + +import ( + "context" + "errors" + "testing" +) + +func TestGiveTeamApplicationsRejectsInvalidIDs(t *testing.T) { + for _, id := range []string{"", "1", "invalid", "00000000-0000-0000-0000-000000000000", "019A1234-5678-7ABC-8DEF-000000000001"} { + t.Run(id, func(t *testing.T) { + service := NewGameplayService(nil, nil, nil, nil, nil, nil, nil, "") + if err := service.GiveTeamApplications(context.Background(), id, "Clue"); !errors.Is(err, ErrTeamNotFound) { + t.Fatal(err) + } + }) + } +} diff --git a/internal/services/map_service/mapper.go b/internal/services/map_service/mapper.go index aa4a3e8..2e288a0 100644 --- a/internal/services/map_service/mapper.go +++ b/internal/services/map_service/mapper.go @@ -7,7 +7,7 @@ import "evening_detective_server/internal/repos/game_map_repo" type MapItem struct { GameID int Kind string - ItemID int + ItemID string X int Y int Hidden bool diff --git a/internal/services/map_service/service.go b/internal/services/map_service/service.go index fef0e2f..98d0a91 100644 --- a/internal/services/map_service/service.go +++ b/internal/services/map_service/service.go @@ -21,7 +21,7 @@ func NewMapService(repo *game_map_repo.Repo) *MapService { } // Ensure creates a map item if it does not exist. -func (s *MapService) Ensure(ctx context.Context, gameID int, kind string, itemID int) error { +func (s *MapService) Ensure(ctx context.Context, gameID int, kind string, itemID string) error { return s.repo.Ensure(ctx, gameID, kind, itemID) } diff --git a/internal/services/mcp_service/http_test.go b/internal/services/mcp_service/http_test.go index 284b67c..4a3e928 100644 --- a/internal/services/mcp_service/http_test.go +++ b/internal/services/mcp_service/http_test.go @@ -51,7 +51,7 @@ func TestStreamableHTTPEndToEnd(t *testing.T) { callRequest := mcp.CallToolRequest{} callRequest.Params.Name = "connect" callRequest.Params.Arguments = map[string]any{ - "url": "/team-story/10?password=team-pass-1", + "url": "/team-story/019a1234-5678-7abc-8def-00000000000a?password=team-pass-1", } result, err := mcpClient.CallTool(ctx, callRequest) if err != nil { @@ -63,7 +63,7 @@ func TestStreamableHTTPEndToEnd(t *testing.T) { text.WriteString(tc.Text) } } - if !strings.Contains(text.String(), "Подключено к команде 10") { + if !strings.Contains(text.String(), "Подключено к команде 019a1234-5678-7abc-8def-00000000000a") { t.Fatalf("CallTool(connect) = %q; want success message", text.String()) } if !strings.Contains(text.String(), "entrance") { diff --git a/internal/services/mcp_service/service.go b/internal/services/mcp_service/service.go index 6f9a999..db29051 100644 --- a/internal/services/mcp_service/service.go +++ b/internal/services/mcp_service/service.go @@ -7,7 +7,6 @@ import ( "evening_detective_server/internal/services/game_model" "fmt" "net/url" - "strconv" "strings" "time" @@ -15,28 +14,29 @@ import ( "evening_detective_server/internal/repos/actions_repo" "evening_detective_server/internal/services/answer_service" + "github.com/google/uuid" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const defaultCallTimeout = 10 * time.Second -type GamePlayer interface { - GetTeamActions(ctx context.Context, teamID int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) - AddTeamAction(ctx context.Context, teamID int, password, actionCode string, viaMCP bool) error +type IGamePlayer interface { + GetTeamActions(ctx context.Context, teamID string, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) + AddTeamAction(ctx context.Context, teamID string, password, actionCode string, viaMCP bool) error } -type AnswerSubmitter interface { - SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []answer_service.Answer) error +type IAnswerSubmitter interface { + SubmitTeamAnswers(ctx context.Context, teamID string, password string, answers []answer_service.Answer) error } type MCPService struct { - game GamePlayer - answers AnswerSubmitter + game IGamePlayer + answers IAnswerSubmitter } // NewMCPService creates an MCP service. -func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService { +func NewMCPService(game IGamePlayer, answers IAnswerSubmitter) *MCPService { return &MCPService{game: game, answers: answers} } @@ -52,7 +52,7 @@ func (s *MCPService) Server() *server.MCPServer { mcp.NewTool( "connect", mcp.WithDescription("Подключиться к игре по ссылке вида /team-story/{id}?password=... (принимаются относительные и абсолютные ссылки, в т.ч. с любым хостом — запросы по ним не выполняются, из ссылки берутся только id команды и пароль). Извлекает id команды и пароль и сразу возвращает текущую историю команды вместе с этими параметрами для последующих вызовов get_team_story/make_move."), - mcp.WithString("url", mcp.Required(), mcp.Description("Ссылка на игру, например /team-story/10?password=team-pass-1")), + mcp.WithString("url", mcp.Required(), mcp.Description("Ссылка на игру, например /team-story/019a1234-5678-7abc-8def-0123456789ab?password=team-pass-1")), ), s.handleConnect, ) @@ -61,7 +61,7 @@ func (s *MCPService) Server() *server.MCPServer { mcp.NewTool( "get_team_story", mcp.WithDescription("Получить текущую историю команды: введение сценария (текст и аудио), видимые точки сценария (текст, двери, улики) и информацию об игре. Команда идентифицируется паролем."), - mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")), + mcp.WithString("team_id", mcp.Required(), mcp.Description("ID команды")), mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")), ), s.handleGetTeamStory, @@ -71,7 +71,7 @@ func (s *MCPService) Server() *server.MCPServer { mcp.NewTool( "make_move", mcp.WithDescription("Сделать ход команды — перейти в точку сценария по её коду. После успешного хода возвращает обновлённую историю команды. Команда идентифицируется паролем."), - mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")), + mcp.WithString("team_id", mcp.Required(), mcp.Description("ID команды")), mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")), mcp.WithString("code", mcp.Required(), mcp.Description("Код точки сценария, в которую идёт команда")), ), @@ -85,7 +85,7 @@ func (s *MCPService) Server() *server.MCPServer { json.RawMessage(`{ "type": "object", "properties": { - "team_id": {"type": "number", "description": "ID команды"}, + "team_id": {"type": "string", "description": "ID команды"}, "password": {"type": "string", "description": "Пароль команды"}, "answers": { "type": "array", @@ -110,7 +110,7 @@ func (s *MCPService) Server() *server.MCPServer { } type connectArgs struct { - teamID int64 + teamID string password string } @@ -132,9 +132,9 @@ func parseConnectURL(rawURL string) (connectArgs, error) { if idPart == "" { return connectArgs{}, fmt.Errorf("невалидная ссылка: отсутствует id команды в пути %q", u.Path) } - teamID, err := strconv.ParseInt(idPart, 10, 64) - if err != nil || teamID < 0 { - return connectArgs{}, fmt.Errorf("невалидная ссылка: id команды %q — не целое неотрицательное число", idPart) + teamID := idPart + if !validTeamID(teamID) { + return connectArgs{}, fmt.Errorf("невалидная ссылка: id команды %q — не UUID", idPart) } password := u.Query().Get("password") @@ -183,7 +183,7 @@ func (s *MCPService) handleConnect(ctx context.Context, req mcp.CallToolRequest) ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() - story, game, _, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password) + story, game, _, err := s.game.GetTeamActions(ctx, args.teamID, args.password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil } @@ -194,7 +194,7 @@ func (s *MCPService) handleConnect(ctx context.Context, req mcp.CallToolRequest) } return mcp.NewToolResultText(fmt.Sprintf( - "Подключено к команде %d. Пароль: %s. История команды:\n%s", + "Подключено к команде %s. Пароль: %s. История команды:\n%s", args.teamID, args.password, jsonBody, @@ -202,9 +202,9 @@ func (s *MCPService) handleConnect(ctx context.Context, req mcp.CallToolRequest) } func (s *MCPService) handleGetTeamStory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - teamID, ok := argInt64(req.GetArguments()["team_id"]) - if !ok { - return mcp.NewToolResultError("get_team_story: требуется team_id (число)"), nil + teamID, ok := req.GetArguments()["team_id"].(string) + if !ok || !validTeamID(teamID) { + return mcp.NewToolResultError("get_team_story: требуется team_id (UUID)"), nil } password, _ := req.GetArguments()["password"].(string) if password == "" { @@ -214,7 +214,7 @@ func (s *MCPService) handleGetTeamStory(ctx context.Context, req mcp.CallToolReq ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() - story, game, _, err := s.game.GetTeamActions(ctx, int(teamID), password) + story, game, _, err := s.game.GetTeamActions(ctx, teamID, password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("get_team_story: %v", err)), nil } @@ -228,9 +228,9 @@ func (s *MCPService) handleGetTeamStory(ctx context.Context, req mcp.CallToolReq func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() - teamID, ok := argInt64(args["team_id"]) - if !ok { - return mcp.NewToolResultError("make_move: требуется team_id (число)"), nil + teamID, ok := args["team_id"].(string) + if !ok || !validTeamID(teamID) { + return mcp.NewToolResultError("make_move: требуется team_id (UUID)"), nil } password, _ := args["password"].(string) code, _ := args["code"].(string) @@ -241,11 +241,11 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() - if err := s.game.AddTeamAction(ctx, int(teamID), password, code, true); err != nil { + if err := s.game.AddTeamAction(ctx, teamID, password, code, true); err != nil { return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil } - story, game, _, err := s.game.GetTeamActions(ctx, int(teamID), password) + story, game, _, err := s.game.GetTeamActions(ctx, teamID, password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil } @@ -258,15 +258,15 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest } type submitAnswersArgs struct { - teamID int64 + teamID string password string answers []answer_service.Answer } func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) { - teamID, ok := argInt64(args["team_id"]) - if !ok { - return submitAnswersArgs{}, fmt.Errorf("требуется team_id (число)") + teamID, ok := args["team_id"].(string) + if !ok || !validTeamID(teamID) { + return submitAnswersArgs{}, fmt.Errorf("требуется team_id (UUID)") } password, _ := args["password"].(string) if password == "" { @@ -308,11 +308,11 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() - if err := s.answers.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil { + if err := s.answers.SubmitTeamAnswers(ctx, args.teamID, args.password, args.answers); err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil } - story, game, _, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password) + story, game, _, err := s.game.GetTeamActions(ctx, args.teamID, args.password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil } @@ -324,14 +324,7 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultText(jsonBody), nil } -func argInt64(v any) (int64, bool) { - switch n := v.(type) { - case float64: - return int64(n), true - case string: - id, err := strconv.ParseInt(n, 10, 64) - return id, err == nil - default: - return 0, false - } +func validTeamID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil && parsed.String() == id } diff --git a/internal/services/mcp_service/service_test.go b/internal/services/mcp_service/service_test.go index 1fa2063..657d769 100644 --- a/internal/services/mcp_service/service_test.go +++ b/internal/services/mcp_service/service_test.go @@ -23,14 +23,14 @@ type fakeGamePlayer struct { viaMCP bool } -func (f *fakeGamePlayer) GetTeamActions(_ context.Context, _ int, _ string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) { +func (f *fakeGamePlayer) GetTeamActions(_ context.Context, _ string, _ string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) { if f.err != nil { return nil, nil, nil, f.err } return f.story, f.game, nil, nil } -func (f *fakeGamePlayer) AddTeamAction(_ context.Context, _ int, _, _ string, viaMCP bool) error { +func (f *fakeGamePlayer) AddTeamAction(_ context.Context, _ string, _, _ string, viaMCP bool) error { f.viaMCP = viaMCP if f.err != nil { return f.err @@ -40,12 +40,12 @@ func (f *fakeGamePlayer) AddTeamAction(_ context.Context, _ int, _, _ string, vi type fakeAnswerSubmitter struct { err error - teamID int + teamID string password string answers []answer_service.Answer } -func (f *fakeAnswerSubmitter) SubmitTeamAnswers(_ context.Context, teamID int, password string, answers []answer_service.Answer) error { +func (f *fakeAnswerSubmitter) SubmitTeamAnswers(_ context.Context, teamID string, password string, answers []answer_service.Answer) error { f.teamID, f.password, f.answers = teamID, password, answers if f.err != nil { return f.err @@ -177,9 +177,9 @@ func TestConnectRelativeURL(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) got := f.call(t, "connect", map[string]any{ - "url": "/team-story/10?password=team-pass-1", + "url": "/team-story/019a1234-5678-7abc-8def-00000000000a?password=team-pass-1", }) - if !strings.Contains(got, "Подключено к команде 10") { + if !strings.Contains(got, "Подключено к команде 019a1234-5678-7abc-8def-00000000000a") { t.Fatalf("connect: %q; want team id in message", got) } if !strings.Contains(got, "team-pass-1") { @@ -197,9 +197,9 @@ func TestConnectProtocolRelativeURL(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) got := f.call(t, "connect", map[string]any{ - "url": "//host/team-story/10?password=team-pass-1", + "url": "//host/team-story/019a1234-5678-7abc-8def-00000000000a?password=team-pass-1", }) - if !strings.Contains(got, "Подключено к команде 10") { + if !strings.Contains(got, "Подключено к команде 019a1234-5678-7abc-8def-00000000000a") { t.Fatalf("connect: %q; want success", got) } } @@ -208,9 +208,9 @@ func TestConnectFullURL(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) got := f.call(t, "connect", map[string]any{ - "url": "http://any.example:9999/team-story/10?password=team-pass-1", + "url": "http://any.example:9999/team-story/019a1234-5678-7abc-8def-00000000000a?password=team-pass-1", }) - if !strings.Contains(got, "Подключено к команде 10") { + if !strings.Contains(got, "Подключено к команде 019a1234-5678-7abc-8def-00000000000a") { t.Fatalf("connect: %q; want success (хост игнорируется)", got) } } @@ -232,7 +232,7 @@ func TestConnectInvalidURLs(t *testing.T) { url string want string }{ - {"пустой password", "/team-story/10", "password"}, + {"пустой password", "/team-story/019a1234-5678-7abc-8def-00000000000a", "password"}, {"не тот путь", "/game/10?password=x", "путь должен быть"}, {"mailto-схема", "mailto:team@example.com", "путь должен быть"}, {"id не число", "/team-story/abc?password=x", "id команды"}, @@ -254,7 +254,7 @@ func TestConnectBusinessError(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("team not found")}) got := f.call(t, "connect", map[string]any{ - "url": "/team-story/10?password=wrong", + "url": "/team-story/019a1234-5678-7abc-8def-00000000000a?password=wrong", }) if !strings.Contains(got, "team not found") { t.Fatalf("connect с ошибкой сервиса: %q; want error text", got) @@ -265,7 +265,7 @@ func TestGetTeamStory(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) got := f.call(t, "get_team_story", map[string]any{ - "team_id": float64(10), + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "team-pass-1", }) if !strings.Contains(got, "entrance") { @@ -280,7 +280,7 @@ func TestGetTeamStoryBusinessError(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("team not found")}) got := f.call(t, "get_team_story", map[string]any{ - "team_id": float64(10), + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "wrong", }) if !strings.Contains(got, "team not found") { @@ -305,7 +305,7 @@ func TestMakeMove(t *testing.T) { f := newMCPFixture(t, player) got := f.call(t, "make_move", map[string]any{ - "team_id": "10", + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "team-pass-1", "code": "hall", }) @@ -321,7 +321,7 @@ func TestMakeMoveActionError(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("code is forbidden")}) got := f.call(t, "make_move", map[string]any{ - "team_id": float64(10), + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "team-pass-1", "code": "forbidden", }) @@ -334,7 +334,7 @@ func TestMakeMoveMissingCode(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{}) got := f.call(t, "make_move", map[string]any{ - "team_id": float64(10), + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "team-pass-1", "code": "", }) @@ -359,13 +359,13 @@ func TestParseConnectURL(t *testing.T) { okCases := []struct { name string url string - teamID int64 + teamID string password string }{ - {"относительная", "/team-story/10?password=p1", 10, "p1"}, - {"полный URL", "http://host:8090/team-story/7?password=p2", 7, "p2"}, - {"протокол-относительная", "//host/team-story/3?password=p3", 3, "p3"}, - {"лишний query", "/team-story/1?password=p4&foo=bar", 1, "p4"}, + {"относительная", "/team-story/019a1234-5678-7abc-8def-00000000000a?password=p1", "019a1234-5678-7abc-8def-00000000000a", "p1"}, + {"полный URL", "http://host:8090/team-story/019a1234-5678-7abc-8def-000000000007?password=p2", "019a1234-5678-7abc-8def-000000000007", "p2"}, + {"протокол-относительная", "//host/team-story/019a1234-5678-7abc-8def-000000000003?password=p3", "019a1234-5678-7abc-8def-000000000003", "p3"}, + {"лишний query", "/team-story/019a1234-5678-7abc-8def-000000000001?password=p4&foo=bar", "019a1234-5678-7abc-8def-000000000001", "p4"}, } for _, tc := range okCases { t.Run(tc.name, func(t *testing.T) { @@ -374,7 +374,7 @@ func TestParseConnectURL(t *testing.T) { t.Fatalf("parseConnectURL(%q) error = %v", tc.url, err) } if args.teamID != tc.teamID || args.password != tc.password { - t.Fatalf("parseConnectURL(%q) = %+v; want teamID=%d password=%q", tc.url, args, tc.teamID, tc.password) + t.Fatalf("parseConnectURL(%q) = %+v; want teamID=%s password=%q", tc.url, args, tc.teamID, tc.password) } }) } @@ -385,7 +385,7 @@ func TestParseConnectURL(t *testing.T) { }{ {"пустая", ""}, {"пробелы", " "}, - {"нет password", "/team-story/10"}, + {"нет password", "/team-story/019a1234-5678-7abc-8def-00000000000a"}, {"не тот путь", "/game/10?password=x"}, {"id не число", "/team-story/abc?password=x"}, {"отрицательный id", "/team-story/-5?password=x"}, @@ -420,7 +420,7 @@ func TestSubmitAnswers(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) got := f.call(t, "submit_answers", map[string]any{ - "team_id": float64(10), + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "team-pass-1", "answers": []any{ map[string]any{"questionCode": "q1", "answer": "Дворецкий"}, @@ -430,7 +430,7 @@ func TestSubmitAnswers(t *testing.T) { if !strings.Contains(got, "entrance") { t.Fatalf("submit_answers: %q; want story JSON в ответе", got) } - if f.answers.teamID != 10 || f.answers.password != "team-pass-1" || len(f.answers.answers) != 2 || + if f.answers.teamID != "019a1234-5678-7abc-8def-00000000000a" || f.answers.password != "team-pass-1" || len(f.answers.answers) != 2 || f.answers.answers[0] != (answer_service.Answer{QuestionCode: "q1", Text: "Дворецкий"}) || f.answers.answers[1] != (answer_service.Answer{QuestionCode: "q2", Text: ""}) { t.Fatalf("answers not forwarded to AnswerSubmitter: %+v", f.answers) @@ -446,10 +446,10 @@ func TestSubmitAnswersValidation(t *testing.T) { want string }{ {"нет team_id", map[string]any{"password": "x", "answers": []any{}}, "team_id"}, - {"нет password", map[string]any{"team_id": float64(1), "answers": []any{}}, "password"}, - {"нет answers", map[string]any{"team_id": float64(1), "password": "x"}, "answers"}, - {"элемент не объект", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{"str"}}, "answers[0]"}, - {"нет questionCode", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{map[string]any{"answer": "x"}}}, "questionCode"}, + {"нет password", map[string]any{"team_id": "019a1234-5678-7abc-8def-000000000001", "answers": []any{}}, "password"}, + {"нет answers", map[string]any{"team_id": "019a1234-5678-7abc-8def-000000000001", "password": "x"}, "answers"}, + {"элемент не объект", map[string]any{"team_id": "019a1234-5678-7abc-8def-000000000001", "password": "x", "answers": []any{"str"}}, "answers[0]"}, + {"нет questionCode", map[string]any{"team_id": "019a1234-5678-7abc-8def-000000000001", "password": "x", "answers": []any{map[string]any{"answer": "x"}}}, "questionCode"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -465,7 +465,7 @@ func TestSubmitAnswersBusinessError(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("игра завершена, ответы больше нельзя изменять")}) got := f.call(t, "submit_answers", map[string]any{ - "team_id": float64(10), + "team_id": "019a1234-5678-7abc-8def-00000000000a", "password": "wrong", "answers": []any{map[string]any{"questionCode": "q1", "answer": "x"}}, }) diff --git a/internal/services/participation_service/participation.go b/internal/services/participation_service/participation.go index 708ac35..d9dc2bb 100644 --- a/internal/services/participation_service/participation.go +++ b/internal/services/participation_service/participation.go @@ -6,7 +6,7 @@ import ( ) type Participation struct { - TeamID int + TeamID string TeamName string GameName string StartAt time.Time diff --git a/internal/services/participation_service/participation_test.go b/internal/services/participation_service/participation_test.go index 1c76161..c98aa89 100644 --- a/internal/services/participation_service/participation_test.go +++ b/internal/services/participation_service/participation_test.go @@ -29,8 +29,8 @@ func (s *participationSender) Send(_ context.Context, m email_sender.Message) er } func TestParticipationEmail(t *testing.T) { - token := participationToken("test-secret", 42) - if len(token) != 64 || token != participationToken("test-secret", 42) || token == participationToken("test-secret", 43) || token == participationToken("other", 42) { + token := participationToken("test-secret", "019a1234-5678-7abc-8def-00000000002a") + if len(token) != 64 || token != participationToken("test-secret", "019a1234-5678-7abc-8def-00000000002a") || token == participationToken("test-secret", "019a1234-5678-7abc-8def-00000000002b") || token == participationToken("other", "019a1234-5678-7abc-8def-00000000002a") { t.Fatal("token isolation or retry stability broken") } p := &Participation{TeamName: "", GameName: "Game", Email: "captain@example.org", StartAt: time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), ParticipantsCount: 5} @@ -80,9 +80,9 @@ func TestParticipationPostgres(t *testing.T) { } exec(`CREATE TABLE game_exports(game_id INT, state TEXT); CREATE TABLE games(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Game',start_at TIMESTAMP NOT NULL,status TEXT DEFAULT 'draft'); - CREATE TABLE teams(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Team',creator_id INTEGER,game_id INTEGER REFERENCES games(id),password TEXT DEFAULT 'secret',status TEXT DEFAULT 'register',created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC')); + CREATE TABLE teams(id UUID PRIMARY KEY,name TEXT DEFAULT 'Team',creator_id INTEGER,game_id INTEGER REFERENCES games(id),password TEXT DEFAULT 'secret',status TEXT DEFAULT 'register',created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC')); INSERT INTO games(start_at) VALUES((NOW() AT TIME ZONE 'UTC')+INTERVAL '12 hours'),((NOW() AT TIME ZONE 'UTC')+INTERVAL '2 days'); - INSERT INTO teams(game_id,created_at) VALUES(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '2 days'),(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 hour');`) + INSERT INTO teams(id,game_id,created_at) VALUES('019a1234-5678-7abc-8def-000000000001',1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '2 days'),('019a1234-5678-7abc-8def-000000000002',1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 hour');`) for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} { data, err := os.ReadFile("../../../migrations/" + name) if err != nil { @@ -94,7 +94,7 @@ func TestParticipationPostgres(t *testing.T) { service := NewParticipationService(repo, "https://example.org") state := func(id int, want string) { t.Helper() - team, err := repo.GetTeamByID(ctx, id) + team, err := repo.GetTeamByID(ctx, fmt.Sprintf("019a1234-5678-7abc-8def-%012x", id)) if err != nil || team.ParticipationStatus != want { t.Fatalf("team %d want %s got %+v, %v", id, want, team, err) } @@ -102,20 +102,20 @@ func TestParticipationPostgres(t *testing.T) { state(1, "pending") state(2, "pending") // Model registration output explicitly: direct SQL inserts do not auto-confirm teams. - exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1; - INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1; - INSERT INTO teams(game_id,created_at,captain_email,participation_status,participation_source,participation_answered_at) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org','confirmed','automatic',NOW() FROM games WHERE id=1; - INSERT INTO teams(game_id,captain_email) VALUES(2,'future@example.org'); - INSERT INTO teams(game_id,created_at) SELECT 1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`) + exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id='019a1234-5678-7abc-8def-000000000001'; + INSERT INTO teams(id,game_id,created_at,captain_email) SELECT '019a1234-5678-7abc-8def-000000000003',1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1; + INSERT INTO teams(id,game_id,created_at,captain_email,participation_status,participation_source,participation_answered_at) SELECT '019a1234-5678-7abc-8def-000000000004',1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org','confirmed','automatic',NOW() FROM games WHERE id=1; + INSERT INTO teams(id,game_id,captain_email) VALUES('019a1234-5678-7abc-8def-000000000005',2,'future@example.org'); + INSERT INTO teams(id,game_id,created_at) SELECT '019a1234-5678-7abc-8def-000000000006',1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`) state(3, "pending") state(4, "confirmed") state(5, "pending") - if err := repo.ConfirmParticipationByFirstAction(ctx, 5); err != nil { + if err := repo.ConfirmParticipationByFirstAction(ctx, "019a1234-5678-7abc-8def-000000000005"); err != nil { t.Fatal(err) } state(5, "confirmed") var wg sync.WaitGroup - ids := make(chan int, 8) + ids := make(chan string, 8) errs := make(chan error, 8) for range 8 { wg.Add(1) @@ -132,17 +132,17 @@ func TestParticipationPostgres(t *testing.T) { wg.Wait() close(ids) close(errs) - seen := map[int]bool{} + seen := map[string]bool{} for id := range ids { if seen[id] { - t.Fatalf("duplicate claim %d", id) + t.Fatalf("duplicate claim %s", id) } seen[id] = true } for err := range errs { t.Error(err) } - if len(seen) != 2 || !seen[1] || !seen[3] { + if len(seen) != 2 || !seen["019a1234-5678-7abc-8def-000000000001"] || !seen["019a1234-5678-7abc-8def-000000000003"] { t.Fatalf("wrong eligible teams: %v", seen) } exec(`UPDATE teams SET participation_next_attempt_at=NULL`) @@ -153,26 +153,26 @@ func TestParticipationPostgres(t *testing.T) { if len(sender.messages) != 2 { t.Fatalf("wanted two attempts: %d", len(sender.messages)) } - team, err := repo.GetTeamByID(ctx, 1) + team, err := repo.GetTeamByID(ctx, "019a1234-5678-7abc-8def-000000000001") if err != nil || team.ParticipationEmailError == "" || team.ParticipationEmailSent { t.Fatalf("failed send not recorded: %+v %v", team, err) } sender.err = nil - exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`) + exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN ('019a1234-5678-7abc-8def-000000000001','019a1234-5678-7abc-8def-000000000003')`) if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil { t.Fatal(err) } if len(sender.messages) != 4 { t.Fatal("expired lease not recovered") } - exec(`UPDATE teams SET participation_attempts=8, participation_next_attempt_at=NULL, participation_sent_at=NULL WHERE id=1`) + exec(`UPDATE teams SET participation_attempts=8, participation_next_attempt_at=NULL, participation_sent_at=NULL WHERE id='019a1234-5678-7abc-8def-000000000001'`) if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil { t.Fatal(err) } if len(sender.messages) != 4 { t.Fatal("email retry exceeded the eight-attempt limit") } - token := participationToken("secret", 1) + token := participationToken("secret", "019a1234-5678-7abc-8def-000000000001") if _, err := service.GetParticipation(ctx, "bad"); !errors.Is(err, ErrParticipationLink) { t.Fatalf("invalid token: %v", err) } @@ -208,7 +208,7 @@ func TestParticipationPostgres(t *testing.T) { if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil { t.Fatalf("same answer not idempotent: %v", err) } - token3 := participationToken("secret", 3) + token3 := participationToken("secret", "019a1234-5678-7abc-8def-000000000003") if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil { t.Fatal(err) } @@ -217,23 +217,23 @@ func TestParticipationPostgres(t *testing.T) { t.Fatal(err) } for _, team := range active { - if team.ID == 3 { + if team.ID == "019a1234-5678-7abc-8def-000000000003" { t.Fatal("cancelled team in active list") } } gameplayService := gameplay_service.NewGameplayService(nil, repo, nil, nil, nil, nil, nil, "") - if _, _, _, err := gameplayService.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, gameplay_service.ErrTeamNotFound) { + if _, _, _, err := gameplayService.GetTeamActions(ctx, "019a1234-5678-7abc-8def-000000000003", "secret"); !errors.Is(err, gameplay_service.ErrTeamNotFound) { t.Fatalf("cancelled team got story: %v", err) } - if err := gameplayService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, gameplay_service.ErrTeamNotFound) { + if err := gameplayService.AddTeamAction(ctx, "019a1234-5678-7abc-8def-000000000003", "secret", "anything", false); !errors.Is(err, gameplay_service.ErrTeamNotFound) { t.Fatalf("cancelled team added action: %v", err) } answerService := answer_service.NewAnswerService(nil, repo, nil, nil, nil) - if _, err := answerService.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, answer_service.ErrTeamNotFound) { + if _, err := answerService.GetTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000003", "secret"); !errors.Is(err, answer_service.ErrTeamNotFound) { t.Fatalf("cancelled team got answers: %v", err) } - token6 := participationToken("secret", 6) - if err := repo.SetParticipationToken(ctx, 6, token6); err != nil { + token6 := participationToken("secret", "019a1234-5678-7abc-8def-000000000006") + if err := repo.SetParticipationToken(ctx, "019a1234-5678-7abc-8def-000000000006", token6); err != nil { t.Fatal(err) } exec(`UPDATE games SET status='run' WHERE id=1`) diff --git a/internal/services/participation_service/service.go b/internal/services/participation_service/service.go index a1c42f4..7a0d889 100644 --- a/internal/services/participation_service/service.go +++ b/internal/services/participation_service/service.go @@ -58,9 +58,9 @@ func mapParticipationError(err error) error { } } -func participationToken(secret string, id int) string { +func participationToken(secret string, id string) string { mac := hmac.New(sha256.New, []byte(secret)) - _, _ = fmt.Fprintf(mac, "team-participation:v1:%d", id) + _, _ = fmt.Fprintf(mac, "team-participation:v1:%s", id) return hex.EncodeToString(mac.Sum(nil)) } diff --git a/internal/services/team_service/registration_test.go b/internal/services/team_service/registration_test.go index 302dd84..12ea938 100644 --- a/internal/services/team_service/registration_test.go +++ b/internal/services/team_service/registration_test.go @@ -35,7 +35,7 @@ func TestRegistrationValidation(t *testing.T) { func TestRegistrationEmail(t *testing.T) { message := registrationEmail(&repos.Team{ - ID: 7, + ID: "019a1234-5678-7abc-8def-000000000007", Name: "Команда", CaptainEmail: "captain@example.org", ParticipantsCount: 5, @@ -49,8 +49,8 @@ func TestRegistrationEmail(t *testing.T) { t.Errorf("registration email has incorrect team data: %q", value) } } - if !strings.Contains(message.Body, "https://app.test/team-story/7?password=abcdefgh") || - !strings.Contains(message.HTML, "team-story/7?password=abcdefgh") { + if !strings.Contains(message.Body, "https://app.test/team-story/019a1234-5678-7abc-8def-000000000007?password=abcdefgh") || + !strings.Contains(message.HTML, "team-story/019a1234-5678-7abc-8def-000000000007?password=abcdefgh") { t.Error("registration email misses the password link") } } diff --git a/internal/services/team_service/service.go b/internal/services/team_service/service.go index 904a4c9..2b822f9 100644 --- a/internal/services/team_service/service.go +++ b/internal/services/team_service/service.go @@ -81,7 +81,7 @@ func (s *TeamService) AddTeam( } // UpdateTeam changes a team's name. -func (s *TeamService) UpdateTeam(ctx context.Context, teamID int, name string) error { +func (s *TeamService) UpdateTeam(ctx context.Context, teamID string, name string) error { err := s.teamsRepo.UpdateTeam(ctx, teamID, name) if errors.Is(err, teams_repo.ErrTeamNotFound) { return ErrTeamNotFound @@ -90,7 +90,7 @@ func (s *TeamService) UpdateTeam(ctx context.Context, teamID int, name string) e } // DeleteTeam removes a team. -func (s *TeamService) DeleteTeam(ctx context.Context, teamID int) error { +func (s *TeamService) DeleteTeam(ctx context.Context, teamID string) error { err := s.teamsRepo.DeleteTeam(ctx, teamID) if errors.Is(err, teams_repo.ErrTeamNotFound) { return ErrTeamNotFound @@ -99,44 +99,44 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID int) error { } // RegisterTeam registers a team for a game. -func (s *TeamService) RegisterTeam(ctx context.Context, gameID int, deliveryID string, registration Registration) (int, error) { +func (s *TeamService) RegisterTeam(ctx context.Context, gameID int, deliveryID string, registration Registration) (string, error) { if err := registration.Validate(); err != nil { - return 0, err + return "", err } if gameID <= 0 || deliveryID == "" || len(deliveryID) > 255 || strings.ContainsRune(deliveryID, 0) { - return 0, ErrInvalidRegistration + return "", ErrInvalidRegistration } password, err := s.passwordGenerator.Generate() if err != nil { - return 0, err + return "", err } game, err := s.gamesRepo.GetGameByID(ctx, gameID) if err != nil { - return 0, err + return "", err } autoConfirm := !time.Now().Before(game.StartAt.Add(-24 * time.Hour)) id, err := s.teamsRepo.RegisterTeamWithParticipation(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password, autoConfirm) if err != nil { if errors.Is(err, teams_repo.ErrRegistrationClosed) { - return 0, ErrRegistrationClosed + return "", ErrRegistrationClosed } - return 0, err + return "", err } if s.emailSender == nil { return id, nil } team, err := s.teamsRepo.GetTeamByID(ctx, id) if err != nil { - return 0, err + return "", err } if err := s.emailSender.Send(ctx, registrationEmail(team, game.Name, s.appBaseURL)); err != nil { - return 0, fmt.Errorf("email send: %w", err) + return "", fmt.Errorf("email send: %w", err) } return id, nil } func registrationEmail(team *repos.Team, gameName, baseURL string) email_sender.Message { - link := strings.TrimRight(baseURL, "/") + "/team-story/" + fmt.Sprint(team.ID) + "?password=" + team.Password + link := strings.TrimRight(baseURL, "/") + "/team-story/" + team.ID + "?password=" + team.Password body := fmt.Sprintf("Регистрация команды «%s» в игре «%s» прошла успешно.\n\nУчастников: %d\n\nСсылка для входа в игру: %s", team.Name, gameName, team.ParticipantsCount, link) content := fmt.Sprintf("

Регистрация команды %s в игре %s прошла успешно.

Участников: %d

%s", html.EscapeString(team.Name), html.EscapeString(gameName), team.ParticipantsCount, email_sender.ButtonHTML(link, "Войти в игру")) markup := email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/")) @@ -168,7 +168,7 @@ func (s *TeamService) DownloadTeamQR(ctx context.Context, id int) ([]byte, strin } // CheckPresence validates team credentials without loading game progress or a scenario. -func (s *TeamService) CheckPresence(ctx context.Context, teamID int, password string) (int, error) { +func (s *TeamService) CheckPresence(ctx context.Context, teamID string, password string) (int, error) { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if errors.Is(err, teams_repo.ErrTeamNotFound) { return 0, ErrTeamNotFound diff --git a/internal/services/team_service/team_qr.go b/internal/services/team_service/team_qr.go index cb516da..dec3056 100644 --- a/internal/services/team_service/team_qr.go +++ b/internal/services/team_service/team_qr.go @@ -2,7 +2,6 @@ package team_service import ( "errors" - "fmt" "net/url" "strings" "unicode" @@ -23,11 +22,11 @@ func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_q } cards := make([]team_qr_pdf.Card, 0, len(teams)) for _, team := range teams { - if team.ID <= 0 || team.Password == "" { + if team.ID == "" || team.Password == "" { return nil, "", errors.New("у команды отсутствуют данные для входа") } link := *base - link.Path = strings.TrimRight(base.Path, "/") + fmt.Sprintf("/team-story/%d", team.ID) + link.Path = strings.TrimRight(base.Path, "/") + "/team-story/" + team.ID link.RawPath = "" link.RawQuery = url.Values{"password": {team.Password}}.Encode() cards = append(cards, team_qr_pdf.Card{Name: team.Name, URL: link.String()}) diff --git a/internal/services/team_service/team_qr_test.go b/internal/services/team_service/team_qr_test.go index 8d97dac..571e8f3 100644 --- a/internal/services/team_service/team_qr_test.go +++ b/internal/services/team_service/team_qr_test.go @@ -10,7 +10,7 @@ import ( func TestTeamQRData(t *testing.T) { game := &repos.Game{Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)} - teams := []*repos.Team{{ID: 7, Name: "Знатоки", Password: "a+b&c?"}} + teams := []*repos.Team{{ID: "019a1234-5678-7abc-8def-000000000007", Name: "Знатоки", Password: "a+b&c?"}} cards, filename, err := teamQRData("https://example.org/app/", game, teams) if err != nil { t.Fatal(err) @@ -19,7 +19,7 @@ func TestTeamQRData(t *testing.T) { t.Fatalf("cards: %+v", cards) } u, err := url.Parse(cards[0].URL) - if err != nil || u.Path != "/app/team-story/7" || u.Query().Get("password") != teams[0].Password { + if err != nil || u.Path != "/app/team-story/019a1234-5678-7abc-8def-000000000007" || u.Query().Get("password") != teams[0].Password { t.Fatalf("wrong team link: %s", cards[0].URL) } if filename != "2026-09-08_Дело _ №1__.pdf" { diff --git a/migrations/20261013120000_team_uuid.sql b/migrations/20261013120000_team_uuid.sql new file mode 100644 index 0000000..0bc9251 --- /dev/null +++ b/migrations/20261013120000_team_uuid.sql @@ -0,0 +1,65 @@ +-- +goose Up +-- Finish venue transfers and back up all databases before this coordinated upgrade. +CREATE TEMP TABLE team_id_mapping ( + old_id INTEGER PRIMARY KEY, + new_id UUID NOT NULL UNIQUE +) ON COMMIT DROP; + +-- PostgreSQL 15 lacks uuidv7(); this one-time migration fills v7 time bits in a random UUID. +INSERT INTO team_id_mapping (old_id, new_id) +SELECT + id, + overlay( + overlay( + replace(gen_random_uuid()::text, '-', '') + placing lpad(to_hex(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint), 12, '0') + from 1 for 12 + ) + placing '7' from 13 for 1 + )::uuid +FROM teams; + +-- +goose StatementBegin +CREATE FUNCTION pg_temp.team_uuid(old_id INTEGER) RETURNS UUID +LANGUAGE SQL STABLE STRICT AS $$ + SELECT m.new_id FROM team_id_mapping m WHERE m.old_id = $1; +$$; +-- +goose StatementEnd + +ALTER TABLE actions DROP CONSTRAINT fk_actions_team; +ALTER TABLE applications DROP CONSTRAINT fk_applications_team; +ALTER TABLE team_answers DROP CONSTRAINT fk_team_answers_team; +ALTER TABLE game_reviews DROP CONSTRAINT game_reviews_team_id_fkey; + +ALTER TABLE teams ALTER COLUMN id DROP DEFAULT; +ALTER TABLE teams ALTER COLUMN id TYPE UUID USING pg_temp.team_uuid(id); +ALTER TABLE actions ALTER COLUMN team_id TYPE UUID USING pg_temp.team_uuid(team_id); +ALTER TABLE applications ALTER COLUMN team_id TYPE UUID USING pg_temp.team_uuid(team_id); +ALTER TABLE team_answers ALTER COLUMN team_id TYPE UUID USING pg_temp.team_uuid(team_id); +ALTER TABLE game_reviews ALTER COLUMN team_id TYPE UUID USING pg_temp.team_uuid(team_id); + +ALTER TABLE actions ADD CONSTRAINT fk_actions_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE; +ALTER TABLE applications ADD CONSTRAINT fk_applications_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE; +ALTER TABLE team_answers ADD CONSTRAINT fk_team_answers_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE; +ALTER TABLE game_reviews ADD CONSTRAINT game_reviews_team_id_fkey FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE; + +ALTER TABLE game_map_items ALTER COLUMN item_id TYPE TEXT USING item_id::text; +UPDATE game_map_items m +SET item_id = n.new_id::text +FROM team_id_mapping n +WHERE m.kind = 'team' AND m.item_id = n.old_id::text; + +DROP SEQUENCE teams_id_seq; +DROP FUNCTION pg_temp.team_uuid(INTEGER); + +-- Old packages contain numeric team IDs. Keep gameplay and media, discard transfer registries. +DELETE FROM game_returns; +DELETE FROM game_exports; +DELETE FROM game_imports; + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + RAISE EXCEPTION 'Restore the pre-UUID database backup and previous application version to roll back'; +END $$; +-- +goose StatementEnd diff --git a/proto/main.pb.go b/proto/main.pb.go index 47ec6f8..b7dfd97 100644 --- a/proto/main.pb.go +++ b/proto/main.pb.go @@ -6862,7 +6862,7 @@ func (*SetScreenSoundRsp) Descriptor() ([]byte, []int) { type MapItem struct { state protoimpl.MessageState `protogen:"open.v1"` Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` - ItemID int32 `protobuf:"varint,2,opt,name=itemID,proto3" json:"itemID,omitempty"` + ItemID string `protobuf:"bytes,2,opt,name=itemID,proto3" json:"itemID,omitempty"` Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"` X int32 `protobuf:"varint,4,opt,name=x,proto3" json:"x,omitempty"` Y int32 `protobuf:"varint,5,opt,name=y,proto3" json:"y,omitempty"` @@ -6910,11 +6910,11 @@ func (x *MapItem) GetKind() string { return "" } -func (x *MapItem) GetItemID() int32 { +func (x *MapItem) GetItemID() string { if x != nil { return x.ItemID } - return 0 + return "" } func (x *MapItem) GetLabel() string { @@ -7051,7 +7051,7 @@ type UpdateGameMapItemReq struct { state protoimpl.MessageState `protogen:"open.v1"` GameID int32 `protobuf:"varint,1,opt,name=gameID,proto3" json:"gameID,omitempty"` Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` - ItemID int32 `protobuf:"varint,3,opt,name=itemID,proto3" json:"itemID,omitempty"` + ItemID string `protobuf:"bytes,3,opt,name=itemID,proto3" json:"itemID,omitempty"` X int32 `protobuf:"varint,4,opt,name=x,proto3" json:"x,omitempty"` Y int32 `protobuf:"varint,5,opt,name=y,proto3" json:"y,omitempty"` Hidden bool `protobuf:"varint,6,opt,name=hidden,proto3" json:"hidden,omitempty"` @@ -7103,11 +7103,11 @@ func (x *UpdateGameMapItemReq) GetKind() string { return "" } -func (x *UpdateGameMapItemReq) GetItemID() int32 { +func (x *UpdateGameMapItemReq) GetItemID() string { if x != nil { return x.ItemID } - return 0 + return "" } func (x *UpdateGameMapItemReq) GetX() int32 { @@ -8159,7 +8159,7 @@ func (x *ParticipationRsp) GetClosed() bool { type Team struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int32 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` @@ -8209,11 +8209,11 @@ func (*Team) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{154} } -func (x *Team) GetID() int32 { +func (x *Team) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *Team) GetName() string { @@ -8713,7 +8713,7 @@ type GameReview struct { state protoimpl.MessageState `protogen:"open.v1"` ID int32 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` GameID int32 `protobuf:"varint,2,opt,name=gameID,proto3" json:"gameID,omitempty"` - TeamID int32 `protobuf:"varint,3,opt,name=teamID,proto3" json:"teamID,omitempty"` + TeamID string `protobuf:"bytes,3,opt,name=teamID,proto3" json:"teamID,omitempty"` TeamName string `protobuf:"bytes,4,opt,name=team_name,json=teamName,proto3" json:"team_name,omitempty"` Rating int32 `protobuf:"varint,5,opt,name=rating,proto3" json:"rating,omitempty"` Text string `protobuf:"bytes,6,opt,name=text,proto3" json:"text,omitempty"` @@ -8769,11 +8769,11 @@ func (x *GameReview) GetGameID() int32 { return 0 } -func (x *GameReview) GetTeamID() int32 { +func (x *GameReview) GetTeamID() string { if x != nil { return x.TeamID } - return 0 + return "" } func (x *GameReview) GetTeamName() string { @@ -8828,7 +8828,7 @@ func (x *GameReview) GetDifficulty() int32 { type CreateGameReviewReq struct { state protoimpl.MessageState `protogen:"open.v1"` GameID int32 `protobuf:"varint,1,opt,name=gameID,proto3" json:"gameID,omitempty"` - TeamID int32 `protobuf:"varint,2,opt,name=teamID,proto3" json:"teamID,omitempty"` + TeamID string `protobuf:"bytes,2,opt,name=teamID,proto3" json:"teamID,omitempty"` Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` Rating int32 `protobuf:"varint,4,opt,name=rating,proto3" json:"rating,omitempty"` Text string `protobuf:"bytes,5,opt,name=text,proto3" json:"text,omitempty"` @@ -8874,11 +8874,11 @@ func (x *CreateGameReviewReq) GetGameID() int32 { return 0 } -func (x *CreateGameReviewReq) GetTeamID() int32 { +func (x *CreateGameReviewReq) GetTeamID() string { if x != nil { return x.TeamID } - return 0 + return "" } func (x *CreateGameReviewReq) GetPassword() string { @@ -9911,7 +9911,7 @@ func (x *AddTeamReq) GetParticipantsCount() int32 { type AddTeamRsp struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int32 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -9947,11 +9947,11 @@ func (*AddTeamRsp) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{187} } -func (x *AddTeamRsp) GetID() int32 { +func (x *AddTeamRsp) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *AddTeamRsp) GetPassword() string { @@ -9963,7 +9963,7 @@ func (x *AddTeamRsp) GetPassword() string { type UpdateTeamReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int32 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -9999,11 +9999,11 @@ func (*UpdateTeamReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{188} } -func (x *UpdateTeamReq) GetID() int32 { +func (x *UpdateTeamReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *UpdateTeamReq) GetName() string { @@ -10051,7 +10051,7 @@ func (*UpdateTeamRsp) Descriptor() ([]byte, []int) { type DeleteTeamReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10086,11 +10086,11 @@ func (*DeleteTeamReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{190} } -func (x *DeleteTeamReq) GetID() int64 { +func (x *DeleteTeamReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } type DeleteTeamRsp struct { @@ -10131,7 +10131,7 @@ func (*DeleteTeamRsp) Descriptor() ([]byte, []int) { type GiveTeamApplicationsReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -10167,11 +10167,11 @@ func (*GiveTeamApplicationsReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{192} } -func (x *GiveTeamApplicationsReq) GetID() int64 { +func (x *GiveTeamApplicationsReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *GiveTeamApplicationsReq) GetName() string { @@ -10219,7 +10219,7 @@ func (*GiveTeamApplicationsRsp) Descriptor() ([]byte, []int) { type GetTeamStoryReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -10255,11 +10255,11 @@ func (*GetTeamStoryReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{194} } -func (x *GetTeamStoryReq) GetID() int64 { +func (x *GetTeamStoryReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *GetTeamStoryReq) GetPassword() string { @@ -10383,7 +10383,7 @@ func (x *TeamAction) GetMcp() bool { type AddTeamActionReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` Code string `protobuf:"bytes,3,opt,name=code,proto3" json:"code,omitempty"` unknownFields protoimpl.UnknownFields @@ -10420,11 +10420,11 @@ func (*AddTeamActionReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{197} } -func (x *AddTeamActionReq) GetID() int64 { +func (x *AddTeamActionReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *AddTeamActionReq) GetPassword() string { @@ -10479,7 +10479,7 @@ func (*AddTeamActionRsp) Descriptor() ([]byte, []int) { type DeleteLastTeamActionReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10514,11 +10514,11 @@ func (*DeleteLastTeamActionReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{199} } -func (x *DeleteLastTeamActionReq) GetID() int64 { +func (x *DeleteLastTeamActionReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } type DeleteLastTeamActionRsp struct { @@ -10559,7 +10559,7 @@ func (*DeleteLastTeamActionRsp) Descriptor() ([]byte, []int) { type GetTeamAnswersReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -10595,11 +10595,11 @@ func (*GetTeamAnswersReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{201} } -func (x *GetTeamAnswersReq) GetID() int64 { +func (x *GetTeamAnswersReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *GetTeamAnswersReq) GetPassword() string { @@ -10655,7 +10655,7 @@ func (x *GetTeamAnswersRsp) GetAnswers() []*Answer { type SubmitTeamAnswersReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` Answers []*Answer `protobuf:"bytes,3,rep,name=answers,proto3" json:"answers,omitempty"` unknownFields protoimpl.UnknownFields @@ -10692,11 +10692,11 @@ func (*SubmitTeamAnswersReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{203} } -func (x *SubmitTeamAnswersReq) GetID() int64 { +func (x *SubmitTeamAnswersReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *SubmitTeamAnswersReq) GetPassword() string { @@ -10751,7 +10751,7 @@ func (*SubmitTeamAnswersRsp) Descriptor() ([]byte, []int) { type SetTeamAnswerScoreReq struct { state protoimpl.MessageState `protogen:"open.v1"` - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` QuestionCode string `protobuf:"bytes,2,opt,name=questionCode,proto3" json:"questionCode,omitempty"` // Балл; отсутствует — сбросить выставленный балл Score *int32 `protobuf:"varint,3,opt,name=score,proto3,oneof" json:"score,omitempty"` @@ -10789,11 +10789,11 @@ func (*SetTeamAnswerScoreReq) Descriptor() ([]byte, []int) { return file_main_proto_rawDescGZIP(), []int{205} } -func (x *SetTeamAnswerScoreReq) GetID() int64 { +func (x *SetTeamAnswerScoreReq) GetID() string { if x != nil { return x.ID } - return 0 + return "" } func (x *SetTeamAnswerScoreReq) GetQuestionCode() string { @@ -12219,7 +12219,7 @@ type DeviceHeartbeatReq struct { DeviceID string `protobuf:"bytes,1,opt,name=deviceID,proto3" json:"deviceID,omitempty"` ConnectionID string `protobuf:"bytes,2,opt,name=connectionID,proto3" json:"connectionID,omitempty"` OrganizerGameID *int32 `protobuf:"varint,3,opt,name=organizerGameID,proto3,oneof" json:"organizerGameID,omitempty"` - TeamID *int32 `protobuf:"varint,4,opt,name=teamID,proto3,oneof" json:"teamID,omitempty"` + TeamID *string `protobuf:"bytes,4,opt,name=teamID,proto3,oneof" json:"teamID,omitempty"` Password string `protobuf:"bytes,5,opt,name=password,proto3" json:"password,omitempty"` ScreenID string `protobuf:"bytes,6,opt,name=screenID,proto3" json:"screenID,omitempty"` AccessToken string `protobuf:"bytes,7,opt,name=accessToken,proto3" json:"accessToken,omitempty"` @@ -12278,11 +12278,11 @@ func (x *DeviceHeartbeatReq) GetOrganizerGameID() int32 { return 0 } -func (x *DeviceHeartbeatReq) GetTeamID() int32 { +func (x *DeviceHeartbeatReq) GetTeamID() string { if x != nil && x.TeamID != nil { return *x.TeamID } - return 0 + return "" } func (x *DeviceHeartbeatReq) GetPassword() string { @@ -12547,7 +12547,7 @@ type DeviceConnection struct { UserAgent string `protobuf:"bytes,5,opt,name=userAgent,proto3" json:"userAgent,omitempty"` Connected bool `protobuf:"varint,6,opt,name=connected,proto3" json:"connected,omitempty"` GameID *int32 `protobuf:"varint,7,opt,name=gameID,proto3,oneof" json:"gameID,omitempty"` - TeamID *int32 `protobuf:"varint,8,opt,name=teamID,proto3,oneof" json:"teamID,omitempty"` + TeamID *string `protobuf:"bytes,8,opt,name=teamID,proto3,oneof" json:"teamID,omitempty"` ScreenID *int32 `protobuf:"varint,9,opt,name=screenID,proto3,oneof" json:"screenID,omitempty"` Kind string `protobuf:"bytes,10,opt,name=kind,proto3" json:"kind,omitempty"` // browser, organizer, team, screen unknownFields protoimpl.UnknownFields @@ -12633,11 +12633,11 @@ func (x *DeviceConnection) GetGameID() int32 { return 0 } -func (x *DeviceConnection) GetTeamID() int32 { +func (x *DeviceConnection) GetTeamID() string { if x != nil && x.TeamID != nil { return *x.TeamID } - return 0 + return "" } func (x *DeviceConnection) GetScreenID() int32 { @@ -13219,7 +13219,7 @@ const file_main_proto_rawDesc = "" + "\x11SetScreenSoundRsp\"\xbc\x01\n" + "\aMapItem\x12\x12\n" + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x16\n" + - "\x06itemID\x18\x02 \x01(\x05R\x06itemID\x12\x14\n" + + "\x06itemID\x18\x02 \x01(\tR\x06itemID\x12\x14\n" + "\x05label\x18\x03 \x01(\tR\x05label\x12\f\n" + "\x01x\x18\x04 \x01(\x05R\x01x\x12\f\n" + "\x01y\x18\x05 \x01(\x05R\x01y\x12\x16\n" + @@ -13233,7 +13233,7 @@ const file_main_proto_rawDesc = "" + "\x14UpdateGameMapItemReq\x12\x16\n" + "\x06gameID\x18\x01 \x01(\x05R\x06gameID\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x16\n" + - "\x06itemID\x18\x03 \x01(\x05R\x06itemID\x12\f\n" + + "\x06itemID\x18\x03 \x01(\tR\x06itemID\x12\f\n" + "\x01x\x18\x04 \x01(\x05R\x01x\x12\f\n" + "\x01y\x18\x05 \x01(\x05R\x01y\x12\x16\n" + "\x06hidden\x18\x06 \x01(\bR\x06hidden\"\x16\n" + @@ -13298,7 +13298,7 @@ const file_main_proto_rawDesc = "" + "\x06status\x18\x05 \x01(\tR\x06status\x12\x16\n" + "\x06closed\x18\x06 \x01(\bR\x06closed\"\x84\x04\n" + "\x04Team\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + "\x06status\x18\x03 \x01(\tR\x06status\x12\x1a\n" + "\bpassword\x18\x04 \x01(\tR\bpassword\x12#\n" + @@ -13342,7 +13342,7 @@ const file_main_proto_rawDesc = "" + "GameReview\x12\x0e\n" + "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x16\n" + "\x06gameID\x18\x02 \x01(\x05R\x06gameID\x12\x16\n" + - "\x06teamID\x18\x03 \x01(\x05R\x06teamID\x12\x1b\n" + + "\x06teamID\x18\x03 \x01(\tR\x06teamID\x12\x1b\n" + "\tteam_name\x18\x04 \x01(\tR\bteamName\x12\x16\n" + "\x06rating\x18\x05 \x01(\x05R\x06rating\x12\x12\n" + "\x04text\x18\x06 \x01(\tR\x04text\x12\x16\n" + @@ -13356,7 +13356,7 @@ const file_main_proto_rawDesc = "" + "difficulty\"\xad\x01\n" + "\x13CreateGameReviewReq\x12\x16\n" + "\x06gameID\x18\x01 \x01(\x05R\x06gameID\x12\x16\n" + - "\x06teamID\x18\x02 \x01(\x05R\x06teamID\x12\x1a\n" + + "\x06teamID\x18\x02 \x01(\tR\x06teamID\x12\x1a\n" + "\bpassword\x18\x03 \x01(\tR\bpassword\x12\x16\n" + "\x06rating\x18\x04 \x01(\x05R\x06rating\x12\x12\n" + "\x04text\x18\x05 \x01(\tR\x04text\x12\x1e\n" + @@ -13418,21 +13418,21 @@ const file_main_proto_rawDesc = "" + "\x12participants_count\x18\x04 \x01(\x05R\x11participantsCount\"8\n" + "\n" + "AddTeamRsp\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x1a\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\"3\n" + "\rUpdateTeamReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\"\x0f\n" + "\rUpdateTeamRsp\"\x1f\n" + "\rDeleteTeamReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\"\x0f\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\"\x0f\n" + "\rDeleteTeamRsp\"=\n" + "\x17GiveTeamApplicationsReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\x12\x12\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\"\x19\n" + "\x17GiveTeamApplicationsRsp\"=\n" + "\x0fGetTeamStoryReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\x12\x1a\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\"\xce\x01\n" + "\x0fGetTeamStoryRsp\x12;\n" + "\x05story\x18\x01 \x01(\v2%.crabs.evening_detective_server.StoryR\x05story\x128\n" + @@ -13443,25 +13443,25 @@ const file_main_proto_rawDesc = "" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x10\n" + "\x03mcp\x18\x02 \x01(\bR\x03mcp\"R\n" + "\x10AddTeamActionReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\x12\x1a\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\x12\x12\n" + "\x04code\x18\x03 \x01(\tR\x04code\"\x12\n" + "\x10AddTeamActionRsp\")\n" + "\x17DeleteLastTeamActionReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\"\x19\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\"\x19\n" + "\x17DeleteLastTeamActionRsp\"?\n" + "\x11GetTeamAnswersReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\x12\x1a\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\"U\n" + "\x11GetTeamAnswersRsp\x12@\n" + "\aanswers\x18\x01 \x03(\v2&.crabs.evening_detective_server.AnswerR\aanswers\"\x84\x01\n" + "\x14SubmitTeamAnswersReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\x12\x1a\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\x12@\n" + "\aanswers\x18\x03 \x03(\v2&.crabs.evening_detective_server.AnswerR\aanswers\"\x16\n" + "\x14SubmitTeamAnswersRsp\"p\n" + "\x15SetTeamAnswerScoreReq\x12\x0e\n" + - "\x02ID\x18\x01 \x01(\x03R\x02ID\x12\"\n" + + "\x02ID\x18\x01 \x01(\tR\x02ID\x12\"\n" + "\fquestionCode\x18\x02 \x01(\tR\fquestionCode\x12\x19\n" + "\x05score\x18\x03 \x01(\x05H\x00R\x05score\x88\x01\x01B\b\n" + "\x06_score\"\x17\n" + @@ -13570,7 +13570,7 @@ const file_main_proto_rawDesc = "" + "\bdeviceID\x18\x01 \x01(\tR\bdeviceID\x12\"\n" + "\fconnectionID\x18\x02 \x01(\tR\fconnectionID\x12-\n" + "\x0forganizerGameID\x18\x03 \x01(\x05H\x00R\x0forganizerGameID\x88\x01\x01\x12\x1b\n" + - "\x06teamID\x18\x04 \x01(\x05H\x01R\x06teamID\x88\x01\x01\x12\x1a\n" + + "\x06teamID\x18\x04 \x01(\tH\x01R\x06teamID\x88\x01\x01\x12\x1a\n" + "\bpassword\x18\x05 \x01(\tR\bpassword\x12\x1a\n" + "\bscreenID\x18\x06 \x01(\tR\bscreenID\x12 \n" + "\vaccessToken\x18\a \x01(\tR\vaccessTokenB\x12\n" + @@ -13610,7 +13610,7 @@ const file_main_proto_rawDesc = "" + "\tuserAgent\x18\x05 \x01(\tR\tuserAgent\x12\x1c\n" + "\tconnected\x18\x06 \x01(\bR\tconnected\x12\x1b\n" + "\x06gameID\x18\a \x01(\x05H\x00R\x06gameID\x88\x01\x01\x12\x1b\n" + - "\x06teamID\x18\b \x01(\x05H\x01R\x06teamID\x88\x01\x01\x12\x1f\n" + + "\x06teamID\x18\b \x01(\tH\x01R\x06teamID\x88\x01\x01\x12\x1f\n" + "\bscreenID\x18\t \x01(\x05H\x02R\bscreenID\x88\x01\x01\x12\x12\n" + "\x04kind\x18\n" + " \x01(\tR\x04kindB\t\n" + diff --git a/proto/main.pb.gw.go b/proto/main.pb.gw.go index 2fc5440..39afd83 100644 --- a/proto/main.pb.gw.go +++ b/proto/main.pb.gw.go @@ -2849,7 +2849,7 @@ func request_EveningDetectiveServer_UpdateGameMapItem_0(ctx context.Context, mar if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "itemID") } - protoReq.ItemID, err = runtime.Int32(val) + protoReq.ItemID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "itemID", err) } @@ -2886,7 +2886,7 @@ func local_request_EveningDetectiveServer_UpdateGameMapItem_0(ctx context.Contex if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "itemID") } - protoReq.ItemID, err = runtime.Int32(val) + protoReq.ItemID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "itemID", err) } @@ -3882,7 +3882,7 @@ func request_EveningDetectiveServer_UpdateTeam_0(ctx context.Context, marshaler if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int32(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -3903,7 +3903,7 @@ func local_request_EveningDetectiveServer_UpdateTeam_0(ctx context.Context, mars if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int32(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -3924,7 +3924,7 @@ func request_EveningDetectiveServer_DeleteTeam_0(ctx context.Context, marshaler if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -3942,7 +3942,7 @@ func local_request_EveningDetectiveServer_DeleteTeam_0(ctx context.Context, mars if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -3966,7 +3966,7 @@ func request_EveningDetectiveServer_GiveTeamApplications_0(ctx context.Context, if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -3987,7 +3987,7 @@ func local_request_EveningDetectiveServer_GiveTeamApplications_0(ctx context.Con if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4010,7 +4010,7 @@ func request_EveningDetectiveServer_GetTeamStory_0(ctx context.Context, marshale if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4034,7 +4034,7 @@ func local_request_EveningDetectiveServer_GetTeamStory_0(ctx context.Context, ma if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4064,7 +4064,7 @@ func request_EveningDetectiveServer_AddTeamAction_0(ctx context.Context, marshal if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4085,7 +4085,7 @@ func local_request_EveningDetectiveServer_AddTeamAction_0(ctx context.Context, m if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4106,7 +4106,7 @@ func request_EveningDetectiveServer_DeleteLastTeamAction_0(ctx context.Context, if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4124,7 +4124,7 @@ func local_request_EveningDetectiveServer_DeleteLastTeamAction_0(ctx context.Con if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4147,7 +4147,7 @@ func request_EveningDetectiveServer_GetTeamAnswers_0(ctx context.Context, marsha if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4171,7 +4171,7 @@ func local_request_EveningDetectiveServer_GetTeamAnswers_0(ctx context.Context, if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4201,7 +4201,7 @@ func request_EveningDetectiveServer_SubmitTeamAnswers_0(ctx context.Context, mar if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4222,7 +4222,7 @@ func local_request_EveningDetectiveServer_SubmitTeamAnswers_0(ctx context.Contex if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4246,7 +4246,7 @@ func request_EveningDetectiveServer_SetTeamAnswerScore_0(ctx context.Context, ma if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) } @@ -4275,7 +4275,7 @@ func local_request_EveningDetectiveServer_SetTeamAnswerScore_0(ctx context.Conte if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ID") } - protoReq.ID, err = runtime.Int64(val) + protoReq.ID, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ID", err) }