diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 3114aef..2808a34 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/internal/app/game_results_package_errors.go b/internal/app/game_results_package_errors.go index 2f5cf20..062fba4 100644 --- a/internal/app/game_results_package_errors.go +++ b/internal/app/game_results_package_errors.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "evening_detective_server/internal/repos/game_returns_repo" results "evening_detective_server/internal/services/game_results_package_service" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -17,6 +18,10 @@ func gameResultsErrorStatus(err error) error { case errors.Is(err, results.ErrLimitExceeded): return status.Error(codes.ResourceExhausted, "Пакет результатов превышает допустимый размер") case errors.Is(err, results.ErrReturnConflict): + var conflict *game_returns_repo.ConflictError + if errors.As(err, &conflict) { + return status.Error(codes.Aborted, conflict.Error()) + } return status.Error(codes.Aborted, "Конфликт пакета результатов") case errors.Is(err, results.ErrReturnRetry): return status.Error(codes.Unavailable, "Повторите загрузку позже") diff --git a/internal/app/game_results_package_test.go b/internal/app/game_results_package_test.go index cac8351..60efe62 100644 --- a/internal/app/game_results_package_test.go +++ b/internal/app/game_results_package_test.go @@ -10,6 +10,7 @@ import ( "testing" "evening_detective_server/internal/modules/processor_jwt" + "evening_detective_server/internal/repos/game_returns_repo" results "evening_detective_server/internal/services/game_results_package_service" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -61,3 +62,14 @@ func TestResultsDownloadFailureDoesNotSendZIPOrLeakFile(t *testing.T) { t.Fatal("temporary file leaked", err) } } + +func TestResultsConflictReasonReachesInterface(t *testing.T) { + conflict := &game_returns_repo.ConflictError{Reason: "идентификатор команды занят другой игрой."} + err := gameResultsErrorStatus(errors.Join(conflict, errors.New("private SQL and file path"))) + if status.Code(err) != codes.Aborted || status.Convert(err).Message() != conflict.Error() { + t.Fatal(err) + } + if !errors.Is(conflict, results.ErrReturnConflict) { + t.Fatal("conflict identity lost") + } +} diff --git a/internal/repos/game_returns_repo/repo.go b/internal/repos/game_returns_repo/repo.go index 7f8dc1d..b155f10 100644 --- a/internal/repos/game_returns_repo/repo.go +++ b/internal/repos/game_returns_repo/repo.go @@ -19,6 +19,14 @@ var ( ErrRetry = errors.New("возврат временно недоступен; повторите попытку") ) +// ConflictError carries a safe explanation for the upload interface. +type ConflictError struct{ Reason string } + +func (e *ConflictError) Error() string { + return "Конфликт пакета результатов: " + e.Reason +} +func (e *ConflictError) Unwrap() error { return ErrConflict } + // Return is retained even after the source game is deleted. type Return struct { PackageID string @@ -59,8 +67,14 @@ func (r *GameReturnsRepo) Transaction(ctx context.Context, apply func(pgx.Tx) (* switch pgErr.Code { case "40001", "40P01": continue - case "23505", "23503", "23514", "22001", "22003", "2200H": - return nil, ErrConflict + case "23505": + return nil, &ConflictError{Reason: "идентификатор или уникальные данные уже заняты (например, хеш билета)."} + case "23503": + return nil, &ConflictError{Reason: "отсутствует связанная запись, необходимая для загрузки результатов."} + case "23514", "22001", "22003": + return nil, &ConflictError{Reason: "значение в пакете не соответствует ограничениям базы данных."} + case "2200H": + return nil, &ConflictError{Reason: "исчерпан диапазон идентификаторов базы данных."} } } return nil, err diff --git a/internal/repos/game_returns_repo/write.go b/internal/repos/game_returns_repo/write.go index 1f023e2..d62d241 100644 --- a/internal/repos/game_returns_repo/write.go +++ b/internal/repos/game_returns_repo/write.go @@ -32,7 +32,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { return err } if collision { - return ErrConflict + return &ConflictError{Reason: "идентификатор команды или билета уже используется в другой игре."} } for _, query := range []string{ `DELETE FROM actions a @@ -237,7 +237,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { return err } if maximum >= 2147483647 { - return ErrConflict + return &ConflictError{Reason: "идентификатор команды достиг предела допустимого диапазона."} } advanceCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() diff --git a/internal/services/game_results_package_service/apply.go b/internal/services/game_results_package_service/apply.go index 566bc76..8abdfa1 100644 --- a/internal/services/game_results_package_service/apply.go +++ b/internal/services/game_results_package_service/apply.go @@ -135,7 +135,7 @@ func (s *GameResultsApplyService) apply(ctx context.Context, bundle *Bundle, dig return nil, err } if err == nil && !m.CreatedAt.After(latest.PreparedAt) { - return nil, ErrReturnConflict + return nil, &game_returns_repo.ConflictError{Reason: "уже загружен пакет с такой же или более поздней датой формирования. Создайте новый пакет на офлайн-сервере и проверьте его часы."} } current, err := game_returns_repo.Capture(ctx, tx, m.GameID) if err != nil { @@ -199,7 +199,7 @@ func (s *GameResultsApplyService) apply(ctx context.Context, bundle *Bundle, dig func repeatReturn(saved *game_returns_repo.Return, digest string) (*game_returns_repo.Return, error) { if saved.SHA256 != digest { - return nil, ErrReturnConflict + return nil, &game_returns_repo.ConflictError{Reason: "пакет с этим идентификатором уже загружен, но содержимое ZIP-файла отличается. Загрузите исходный файл или создайте новый пакет на офлайн-сервере."} } return saved, nil }