diff --git a/cmd/evening_detective_server/gateway_test.go b/cmd/evening_detective_server/gateway_test.go index e3deee7..631a8bb 100644 --- a/cmd/evening_detective_server/gateway_test.go +++ b/cmd/evening_detective_server/gateway_test.go @@ -26,7 +26,6 @@ import ( "google.golang.org/grpc/status" ) -// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC. type testStub struct { proto.UnimplementedEveningDetectiveServerServer @@ -128,8 +127,6 @@ func TestGatewayDownloadTeamQR(t *testing.T) { } } -// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же -// опциями, что и в main.go (outgoing matcher, error handler, лимиты, // rawBodyMarshaler). func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server { return newTestGatewayWithLimit(t, stub, maxFileUploadBody) @@ -237,8 +234,6 @@ func TestGatewayDownloadZipWithContentDisposition(t *testing.T) { } } -// TestGatewayDownloadFileNosniff — скачивание файла отдаёт содержимое, -// Content-Type и X-Content-Type-Options: nosniff. func TestGatewayDownloadFileNosniff(t *testing.T) { stub := &testStub{} ts := newTestGateway(t, stub) @@ -264,7 +259,6 @@ func TestGatewayDownloadFileNosniff(t *testing.T) { } } -// uploadJSONBody собирает JSON-тело загрузки файла (base64 data). func uploadJSONBody(filename string, data []byte) []byte { body, _ := json.Marshal(proto.UploadFileReq{ Filename: filename, @@ -273,8 +267,6 @@ func uploadJSONBody(filename string, data []byte) []byte { return body } -// TestGatewayUploadArchiveOverLimit — тело архива больше HTTP-лимита -// (MaxBytesReader) → 413 с понятным текстом (связка middleware → декодер → // customErrorHandler). func TestGatewayUploadArchiveOverLimit(t *testing.T) { stub := &testStub{} @@ -296,14 +288,11 @@ func TestGatewayUploadArchiveOverLimit(t *testing.T) { } } -// TestGatewayUploadFileOverHTTPLimit — тело загрузки больше HTTP-лимита -// (MaxBytesReader) → 413 с понятным текстом. func TestGatewayUploadFileOverHTTPLimit(t *testing.T) { stub := &testStub{} const limit = 1 << 20 // 1 MiB ts := newTestGatewayWithLimit(t, stub, limit) - // 1 MiB данных в base64 ~1.33 MiB > лимит 1 MiB. data := bytes.Repeat([]byte("x"), limit) resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data))) if err != nil { @@ -320,14 +309,12 @@ func TestGatewayUploadFileOverHTTPLimit(t *testing.T) { } } -// TestGatewayUploadFileExactLimit — тело ровно в пределах лимита проходит -// через HTTP-слой и gRPC-транспорт → 200. func TestGatewayUploadFileExactLimit(t *testing.T) { stub := &testStub{} - const limit = 2 << 20 // 2 MiB — лимит HTTP-тела + const limit = 2 << 20 ts := newTestGatewayWithLimit(t, stub, limit) - data := bytes.Repeat([]byte("x"), 1<<20) // 1 MiB данных → тело ~1.33 MiB < 2 MiB + data := bytes.Repeat([]byte("x"), 1<<20) resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data))) if err != nil { t.Fatalf("POST: %v", err) @@ -343,8 +330,6 @@ func TestGatewayUploadFileExactLimit(t *testing.T) { } } -// TestGatewayUploadFileResourceExhausted — бизнес-ветка: сервис отклоняет -// файл больше лимита (ResourceExhausted) → 413. func TestGatewayUploadFileResourceExhausted(t *testing.T) { stub := &testStub{} stub.uploadErr = status.Error(codes.ResourceExhausted, "файл слишком большой (лимит 64 МБ)") @@ -365,7 +350,6 @@ func TestGatewayUploadFileResourceExhausted(t *testing.T) { } } -// TestCustomErrorHandler — маппинг ошибок превышения размера в 413. func TestCustomErrorHandler(t *testing.T) { mux := runtime.NewServeMux() marshaler := &runtime.JSONPb{} @@ -403,15 +387,12 @@ func TestCustomErrorHandler(t *testing.T) { t.Run("ResourceExhausted on other route delegates", func(t *testing.T) { rec := do(t, httptest.NewRequest(http.MethodPost, "/api/test/echo", nil), status.Error(codes.ResourceExhausted, "rate limit")) - // DefaultHTTPErrorHandler мапит ResourceExhausted в 429. if rec.Code != http.StatusTooManyRequests { t.Errorf("status = %d, want 429 (дефолтный маппинг)", rec.Code) } }) } -// TestLimitUploadBody — тело загрузки ограничено на HTTP-слое -// (защита от OOM), прочие маршруты не затронуты. func TestLimitUploadBody(t *testing.T) { limited := func(route string, limit int64) http.Handler { return limitUploadBody(route, limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -467,9 +448,6 @@ func TestLimitUploadBody(t *testing.T) { }) } -// TestCORSHeaders — фронт через fetch должен читать Content-Disposition -// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки -// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose. func TestCORSHeaders(t *testing.T) { h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 0c3435d..2f89abb 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -66,17 +66,10 @@ import ( //go:embed main.swagger.json var swaggerJSON []byte -// maxFileSize — максимальный размер файла, передаваемого через gRPC -const maxFileSize = 64 << 20 // 64 МБ +const maxFileSize = 64 << 20 -// maxFileUploadBody — лимит HTTP-тела POST /api/files/upload (JSON с base64): -// ceil(maxFileSize·4/3) — точный размер base64, +2 КБ запаса на JSON-обёртку -// {"filename":...,"data":...} с коротким именем файла. const maxFileUploadBody = (int64(maxFileSize)+2)/3*4 + 2*1024 -// grpcMsgLimit — лимит размера gRPC-сообщения (сервер и клиент gateway): -// maxFileSize + 1 MiB на прото-оверхед (tag+varint) поверх ровно 64 MiB -// данных; авторитетная проверка размера — в file_service. const grpcMsgLimit = maxFileSize + (1 << 20) func main() { @@ -240,14 +233,6 @@ func main() { log.Fatalln("Failed to dial server:", err) } - // X-Real-IP пробрасывается от доверенного обратного прокси (если фронт- - // контур развёрнут перед gateway, см. docs/COMPLIANCE.md п. 6.6) и - // используется в clientInfoFromContext для фиксации IP клиента при - // акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем - // при удалении аккаунта (не в query, чтобы не светить креденшел в URL - // и логах). - // Сырая загрузка архива (без base64) для распространённых Content-Type; - // JSON-вариант {"data": ""} работает через application/json. rawBody := &rawBodyMarshaler{ Marshaler: &runtime.HTTPBodyMarshaler{ Marshaler: &runtime.JSONPb{ @@ -265,8 +250,6 @@ func main() { } return runtime.DefaultHeaderMatcher(key) }), - // Проброс Content-Disposition и X-Content-Type-Options (nosniff) - // от сервиса в HTTP-ответ. runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) { switch { case strings.EqualFold(key, "Content-Disposition"): @@ -299,9 +282,6 @@ func main() { log.Printf("write swagger document: %v", err) } }) - // MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита - // проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы — - // maxFileUploadBody, архивы — MaxArchiveSize(). mainMux.Handle("/api/", limitUploadBody( "/api/files/upload", maxFileUploadBody, @@ -312,14 +292,6 @@ func main() { ), )) - // MCP-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP). - // Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service). - // WithDisableLocalhostProtection: mcp-go отклоняет loopback-запросы с - // не-localhost Host-заголовком (защита от DNS-rebinding), что ломает - // работу за reverse-proxy (nginx/caddy) — контур деплоя проекта. - // Эндпоинт публичный, секрет — пароль команды (аналогично публичному - // REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому - // http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway. mcpHTTP := server.NewStreamableHTTPServer( mcp_service.NewMCPService(gameplayService, answerService).Server(), server.WithDisableLocalhostProtection(true), @@ -353,13 +325,7 @@ func cors(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") - // Mcp-Session-Id / Mcp-Protocol-Version / Last-Event-ID — заголовки - // streamable HTTP MCP-эндпоинта /api/mcp (сессия, версия протокола, - // SSE-реконнект); без них браузерные MCP-клиенты заблокируются preflight. w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID") - // Content-Disposition нужен фронту, чтобы прочитать имя файла архива; - // Mcp-Session-Id / Mcp-Protocol-Version — браузерному MCP-клиенту, - // чтобы читать сессионные заголовки ответов /api/mcp. w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version") if r.Method == "OPTIONS" { return @@ -368,8 +334,6 @@ func cors(h http.Handler) http.Handler { }) } -// limitUploadBody ограничивает тело POST-запроса на заданном маршруте -// (gateway читает тело в память целиком). func limitUploadBody(route string, limit int64, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.URL.Path == route { @@ -379,16 +343,6 @@ func limitUploadBody(route string, limit int64, next http.Handler) http.Handler }) } -// customErrorHandler мапит ошибки превышения размера тела в HTTP 413 -// с понятным текстом: -// - *http.MaxBytesError (raw) — только для юнит-тестов: сгенерированный -// gateway-код строкифицирует ошибку декодера через %v и тип теряется; -// - gRPC ResourceExhausted на путях загрузки (файл больше лимита); -// - InvalidArgument с текстом MaxBytesError ("http: request body too -// large") — единственная реальная защита 413 на HTTP-слое, ветку нельзя -// удалять при «упрощении» обработчика. -// -// Остальные ошибки обрабатываются как обычно (DefaultHTTPErrorHandler). func customErrorHandler( ctx context.Context, mux *runtime.ServeMux, @@ -417,9 +371,6 @@ func customErrorHandler( runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err) } -// writeTooLargeError — ответ 413 в том же формате {"error": ...}, что и -// DefaultHTTPErrorHandler. Текст лимита формируется из maxFileSize, чтобы -// не расходиться с реальным лимитом при его изменении. func writeTooLargeError(w http.ResponseWriter, marshaler runtime.Marshaler) { w.Header().Set("Content-Type", marshaler.ContentType(nil)) w.WriteHeader(http.StatusRequestEntityTooLarge) diff --git a/cmd/evening_detective_server/rawbody.go b/cmd/evening_detective_server/rawbody.go index c08bc71..750405d 100644 --- a/cmd/evening_detective_server/rawbody.go +++ b/cmd/evening_detective_server/rawbody.go @@ -7,9 +7,6 @@ import ( "google.golang.org/genproto/googleapis/api/httpbody" ) -// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.): -// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует -// JSON-маршалеру (работает и {"data": ""}). type rawBodyMarshaler struct { runtime.Marshaler } @@ -21,8 +18,6 @@ func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder { } } -// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data, -// для protobuf-сообщений декодирует JSON как обычно. type rawBodyDecoder struct { runtime.Decoder r io.Reader diff --git a/cmd/evening_detective_server/rawbody_test.go b/cmd/evening_detective_server/rawbody_test.go index c4738fb..42696af 100644 --- a/cmd/evening_detective_server/rawbody_test.go +++ b/cmd/evening_detective_server/rawbody_test.go @@ -35,7 +35,6 @@ func TestRawBodyDecoderDecodesHttpBody(t *testing.T) { func TestRawBodyMarshalerDelegatesToJSONForProtos(t *testing.T) { m := newTestRawBodyMarshaler() - // Для protobuf-сообщения (не HttpBody) декодирование идёт через JSON. req := &proto.EchoReq{} if err := m.NewDecoder(strings.NewReader(`{"text":"hi"}`)).Decode(req); err != nil { t.Fatalf("Decode: %v", err) diff --git a/docs/GO_STANDARD.md b/docs/GO_STANDARD.md index a8ffb1e..db94594 100644 --- a/docs/GO_STANDARD.md +++ b/docs/GO_STANDARD.md @@ -291,7 +291,7 @@ go test ./... ## 11. Именование и стиль -- Все комментарии в исходном Go-коде пиши на русском языке. Сгенерированные +- Все комментарии в исходном Go-коде пиши на английском языке. Сгенерированные файлы и комментарии внешних библиотек не редактируй. - Имена короткие и предметные: `gameID`, `teamID`, `ctx`, `err` допустимы по общему Go-стилю. diff --git a/docs/embed.go b/docs/embed.go index c760fa1..c55747b 100644 --- a/docs/embed.go +++ b/docs/embed.go @@ -1,6 +1,5 @@ -// Package docs встраивает юридические документы в бинарник и раздаёт их -// по постоянным URL (/terms, /privacy, /consent), на которые сервис -// ссылается при фиксации акцептов соглашений (user_agreements). +// Package docs embeds legal documents into the binary and serves them from +// stable URLs (/terms, /privacy, /consent) used when recording agreements. package docs import "embed" diff --git a/internal/app/change_password_test.go b/internal/app/change_password_test.go index 243acc9..24d942e 100644 --- a/internal/app/change_password_test.go +++ b/internal/app/change_password_test.go @@ -42,8 +42,6 @@ func TestChangeUserPasswordAccess(t *testing.T) { } } -// Встраиваем настоящий репозиторий, чтобы неожиданные вызовы завершались -// ошибкой, а не проходили незаметно. type passwordRepo struct { *users_repo.UsersRepo role string diff --git a/internal/app/file_service_test.go b/internal/app/file_service_test.go index e5e9317..9c89f50 100644 --- a/internal/app/file_service_test.go +++ b/internal/app/file_service_test.go @@ -17,7 +17,6 @@ import ( "google.golang.org/grpc/status" ) -// fakeStorage — in-memory реализация IFileStorage для app-слоя. type fakeStorage struct { files map[string]*file_storage.File putErr error @@ -90,7 +89,6 @@ func TestUploadFilePermissionDenied(t *testing.T) { func TestUploadFileValidationInvalidArgument(t *testing.T) { s := newTestFileServer(nil, 1<<20) - // Имя без расширения. _, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue", Data: pdfData(16)}) if status.Code(err) != codes.InvalidArgument { t.Errorf("code = %v, want InvalidArgument", status.Code(err)) @@ -133,7 +131,6 @@ func TestUploadFileSuccess(t *testing.T) { } } -// mockServerTransportStream — заглушка для grpc.SetHeader в юнит-тестах. type mockServerTransportStream struct { grpc.ServerTransportStream header metadata.MD diff --git a/internal/app/files.go b/internal/app/files.go index 69e4c34..c369024 100644 --- a/internal/app/files.go +++ b/internal/app/files.go @@ -16,7 +16,6 @@ import ( ) func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) { - // Файлы (в т.ч. улики) загружают авторы сценариев в редакторе. claims, claimsErr := claimsFromContext(ctx) if claimsErr != nil { return nil, claimsErr @@ -55,8 +54,6 @@ func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) ( return nil, status.Error(codes.Internal, "не удалось скачать файл") } } - // nosniff — при публичном скачивании браузер не должен угадывать - // Content-Type по содержимому (защита от stored-XSS при расхождении). if err := grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff")); err != nil { return nil, status.Error(codes.Internal, "не удалось установить заголовки файла") } diff --git a/internal/app/legal_docs.go b/internal/app/legal_docs.go index 9da8eaa..7f88d4e 100644 --- a/internal/app/legal_docs.go +++ b/internal/app/legal_docs.go @@ -9,8 +9,6 @@ import ( "google.golang.org/grpc/status" ) -// GetTerms возвращает текст Пользовательского соглашения (постоянный URL -// /api/terms, на который ссылается форма регистрации при акцепте). func (s *server) GetTerms(_ context.Context, _ *proto.GetTermsReq) (*proto.GetTermsRsp, error) { text, err := docs.FS.ReadFile("TERMS.md") if err != nil { @@ -19,7 +17,6 @@ func (s *server) GetTerms(_ context.Context, _ *proto.GetTermsReq) (*proto.GetTe return &proto.GetTermsRsp{Text: string(text)}, nil } -// GetPrivacy возвращает текст Политики конфиденциальности (постоянный URL // /api/privacy). func (s *server) GetPrivacy(_ context.Context, _ *proto.GetPrivacyReq) (*proto.GetPrivacyRsp, error) { text, err := docs.FS.ReadFile("PRIVACY.md") @@ -29,8 +26,6 @@ func (s *server) GetPrivacy(_ context.Context, _ *proto.GetPrivacyReq) (*proto.G return &proto.GetPrivacyRsp{Text: string(text)}, nil } -// GetConsent возвращает текст согласия на обработку персональных данных -// (постоянный URL /api/consent). func (s *server) GetConsent(_ context.Context, _ *proto.GetConsentReq) (*proto.GetConsentRsp, error) { text, err := docs.FS.ReadFile("CONSENT.md") if err != nil { diff --git a/internal/app/results_mapper.go b/internal/app/results_mapper.go index d88f7cf..b028a3b 100644 --- a/internal/app/results_mapper.go +++ b/internal/app/results_mapper.go @@ -6,9 +6,6 @@ import ( proto "evening_detective_server/proto" ) -// mapResultsQuestions мапит вопросы сценария в шапку итоговой таблицы: -// правильный ответ (поле Answer) не передаётся — он виден только в панели -// оценки (GetGameAnswers). func mapResultsQuestions(o []*storytelling.Question) []*proto.ResultsQuestion { res := make([]*proto.ResultsQuestion, 0, len(o)) for _, item := range o { @@ -20,9 +17,6 @@ func mapResultsQuestions(o []*storytelling.Question) []*proto.ResultsQuestion { return res } -// mapTeamResults мапит строки итоговой таблицы. Команда мапится целиком -// (включая password/applications — аудитория та же, что у GetGameAnswers: -// организатор/автор); actionsCount продублирован на уровне строки таблицы. func mapTeamResults(o []answer_service.TeamResult) []*proto.TeamResult { res := make([]*proto.TeamResult, 0, len(o)) for _, item := range o { diff --git a/internal/app/results_mapper_test.go b/internal/app/results_mapper_test.go index a7a0a34..2dcde36 100644 --- a/internal/app/results_mapper_test.go +++ b/internal/app/results_mapper_test.go @@ -33,7 +33,6 @@ func TestMapTeamResults(t *testing.T) { if row.Team == nil || row.Team.ID != 1 || row.Team.Name != "Команда А" { t.Fatalf("row.Team = %+v, want id 1, name Команда А", row.Team) } - // actionsCount продублирован на уровне строки таблицы. if row.ActionsCount != 7 { t.Fatalf("row.ActionsCount = %d, want 7", row.ActionsCount) } diff --git a/internal/app/scenario_archive.go b/internal/app/scenario_archive.go index 3951e8c..df0eb09 100644 --- a/internal/app/scenario_archive.go +++ b/internal/app/scenario_archive.go @@ -44,8 +44,6 @@ func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.Downloa } } - // Имя файла транслитерируется (заголовки HTTP — только ASCII) и - // санитайзится от header injection; заголовок пробрасывается gateway. transliterated := sanitizeFilename(string_tools.Transliterate(name)) if transliterated == "" { transliterated = "scenario" @@ -88,7 +86,6 @@ func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBo }, nil } -// sanitizeFilename — защита от инъекции заголовков в Content-Disposition. func sanitizeFilename(name string) string { replacer := strings.NewReplacer( `"`, "_", diff --git a/internal/app/scenarios.go b/internal/app/scenarios.go index 84c6401..8858944 100644 --- a/internal/app/scenarios.go +++ b/internal/app/scenarios.go @@ -146,7 +146,7 @@ func (s *server) PublicScenario(ctx context.Context, req *proto.PublicScenarioRe int(req.ID), "public", claims.UserID, - roles.HasRole(claims, roles.Admin), // публикация = момент отчуждения прав + roles.HasRole(claims, roles.Admin), ) if err != nil { return nil, scenarioErrorStatus(err, "не удалось опубликовать сценарий") @@ -160,9 +160,6 @@ func (s *server) DraftScenario(ctx context.Context, req *proto.DraftScenarioReq) if claimsErr != nil { return nil, claimsErr } - // Снять с публикации опубликованный сценарий может только админ: - // исключительное право отчуждено Оператору в момент публикации - // (п. 7.2 Пользовательского соглашения, ст. 1234, 1269 ГК РФ). if !roles.HasRole(claims, roles.Admin) { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } @@ -190,8 +187,6 @@ func (s *server) DeleteScenario(ctx context.Context, req *proto.DeleteScenarioRe return nil, status.Errorf(codes.PermissionDenied, "permission denied") } - // Автор не может удалить опубликованный сценарий (исключительное право - // отчуждено); админ может. err := s.scenarioService.DeleteScenarioByID( ctx, int(req.ID), diff --git a/internal/app/team_registration.go b/internal/app/team_registration.go index 37c8faf..7e60b0e 100644 --- a/internal/app/team_registration.go +++ b/internal/app/team_registration.go @@ -14,7 +14,6 @@ import ( "time" ) -// TeamRegistrationHandler принимает вебхук Яндекс Форм отдельно от пользовательского JWT. func TeamRegistrationHandler(token string, register func(context.Context, int, string, team_service.Registration) (int, error)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/app/user_mapper.go b/internal/app/user_mapper.go index 830fab7..b18829b 100644 --- a/internal/app/user_mapper.go +++ b/internal/app/user_mapper.go @@ -7,9 +7,6 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -// mapUser преобразует пользователя в proto-структуру. Возвращает nil для -// обезличенного автора (аккаунт удалён, контент сохранён) — клиент отображает -// такого автора как «Автор удалён». func mapUser(o *users_service.User) *proto.User { if o == nil { return nil diff --git a/internal/app/users.go b/internal/app/users.go index ade60f5..f0fb5b1 100644 --- a/internal/app/users.go +++ b/internal/app/users.go @@ -38,9 +38,6 @@ func (s *server) DeleteAccount(ctx context.Context, _ *proto.DeleteAccountReq) ( if !ok { return nil, status.Errorf(codes.Unauthenticated, "claims is not provided") } - // Пароль приходит HTTP-заголовком X-Password (пробрасывается матчером - // в main.go), чтобы креденшел не попадал в URL и логи. Для прямых - // gRPC-клиентов заголовок передаётся в metadata под тем же ключом. password := "" if md, ok := metadata.FromIncomingContext(ctx); ok { if values := md.Get("x-password"); len(values) > 0 { @@ -54,28 +51,15 @@ func (s *server) DeleteAccount(ctx context.Context, _ *proto.DeleteAccountReq) ( return &proto.DeleteAccountRsp{}, nil } -// clientInfoFromContext извлекает IP-адрес и user-agent из gRPC-метаданных. -// -// grpc-gateway пробрасывает HTTP-заголовки в метаданные так // (runtime.annotateContext / runtime.DefaultHeaderMatcher): -// - x-forwarded-for — всегда, при этом gateway дописывает реальный RemoteAddr -// клиента последним элементом списка; -// - постоянные HTTP-заголовки (включая User-Agent) — с префиксом -// grpcgateway-; -// - остальные заголовки по умолчанию не пробрасываются: X-Real-IP и -// X-Password включаются явным матчером в main.go. // -// Полученный IP валидируется netip.ParseAddr: при прямой экспозиции gateway -// клиент может подделать эти заголовки, поэтому в БД попадает только -// корректный адрес. +// grpcgateway-; func clientInfoFromContext(ctx context.Context) (ip string, userAgent string) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return "", "" } - // X-Real-IP от доверенного прокси — приоритетен: в этом случае последним - // элементом X-Forwarded-For является адрес прокси, а не клиента. if values := md.Get("x-real-ip"); len(values) > 0 { if v := strings.TrimSpace(values[0]); v != "" { if _, err := netip.ParseAddr(v); err == nil { @@ -86,8 +70,6 @@ func clientInfoFromContext(ctx context.Context) (ip string, userAgent string) { if ip == "" { if values := md.Get("x-forwarded-for"); len(values) > 0 { parts := strings.Split(values[0], ",") - // Идём с конца списка: последний элемент — реальный RemoteAddr - // клиента (первые элементы мог подделать сам клиент). for i := len(parts) - 1; i >= 0; i-- { v := strings.TrimSpace(parts[i]) if v == "" { diff --git a/internal/modules/email_sender/sender.go b/internal/modules/email_sender/sender.go index 78f512b..84f0d49 100644 --- a/internal/modules/email_sender/sender.go +++ b/internal/modules/email_sender/sender.go @@ -36,7 +36,6 @@ func NewSender( replyTo string, timeout time.Duration, ) IEmailSender { - // From заголовка письма по умолчанию совпадает с учётной записью SMTP. from := smtpUser if timeout <= 0 { timeout = 10 * time.Second @@ -50,18 +49,11 @@ func NewSender( fromName: sanitizeHeader(fromName), replyTo: sanitizeHeader(replyTo), timeout: timeout, - // Проверка имени сервера включена всегда; поле переопределяется - // только в тестах (свой RootCAs для самоподписанного сертификата). - tlsConfig: &tls.Config{ServerName: smtpHost}, + tlsConfig: &tls.Config{ServerName: smtpHost}, } } -// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и -// таймаута: соединение устанавливается через DialContext, а все фазы SMTP- -// диалога ограничены общим deadline, производным от ctx. func (s *sender) Send(ctx context.Context, message Message) error { - // Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают - // формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.). to := sanitizeHeader(message.To) subject := sanitizeHeader(message.Subject) if err := validateMessage(to, subject); err != nil { @@ -81,8 +73,6 @@ func (s *sender) Send(ctx context.Context, message Message) error { conn := tls.Client(rawConn, s.tlsConfig) if deadline, ok := ctx.Deadline(); ok { - // Единый бюджет времени на весь SMTP-диалог: зависший сервер не - // должен держать запрос дольше таймаута. if err = conn.SetDeadline(deadline); err != nil { return fmt.Errorf("email: set deadline: %w", err) } @@ -92,8 +82,6 @@ func (s *sender) Send(ctx context.Context, message Message) error { if err != nil { return fmt.Errorf("email: smtp greeting: %w", err) } - // Close гарантированно закрывает соединение при раннем выходе; в конце - // штатного пути вместо него вызывается Quit. defer client.Close() //nolint:errcheck auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost) @@ -123,12 +111,6 @@ func (s *sender) Send(ctx context.Context, message Message) error { return nil } -// buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому -// не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе -// кириллица может быть испорчена промежуточными серверами. Заголовки -// Date и Message-ID добавляются явно: их отсутствие — типичный признак -// спама для почтовых фильтров (включая Gmail). Если задана HTML-версия, -// письмо собирается как multipart/alternative (text/plain + text/html). func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byte { var b strings.Builder b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512) @@ -190,7 +172,6 @@ func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byt return []byte(b.String()) } -// messageID генерирует уникальный Message-ID в домене отправителя. func messageID(from string) string { domain := "" if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 { @@ -202,8 +183,6 @@ func messageID(from string) string { return "<" + randomHex(12) + "@" + domain + ">" } -// randomHex возвращает случайную hex-строку (криптостойкий генератор; -// при сбое — значение из таймера, чтобы отправка не падала). func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -212,15 +191,11 @@ func randomHex(n int) string { return fmt.Sprintf("%x", b) } -// sanitizeHeader удаляет символы, ломающие структуру заголовков письма -// (CRLF-инъекция заголовков, NUL). func sanitizeHeader(s string) string { r := strings.NewReplacer("\r", "", "\n", "", "\x00", "") return r.Replace(s) } -// validateMessage проверяет адрес получателя и наличие темы до обращения к -// SMTP-серверу. func validateMessage(to, subject string) error { if _, err := mail.ParseAddress(to); err != nil { return fmt.Errorf("email: некорректный адрес получателя: %w", err) diff --git a/internal/modules/email_sender/sender_test.go b/internal/modules/email_sender/sender_test.go index 820727d..11604be 100644 --- a/internal/modules/email_sender/sender_test.go +++ b/internal/modules/email_sender/sender_test.go @@ -16,11 +16,6 @@ import ( "time" ) -// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на -// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми -// телами писем. При hang=true сервер отправляет приветствие и молчит — -// для проверки таймаутов. Сертификат генерируется один на тест и -// передаётся и серверу, и клиенту (testSender). func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) { t.Helper() @@ -41,7 +36,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st } if hang { go func() { - // Приветствие отправляем, дальше молчим. _, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n")) }() continue @@ -53,7 +47,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st return ln.Addr().String(), messages } -// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp: // EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT. func handleSMTPConn(conn net.Conn, messages chan<- []byte) { defer func() { _ = conn.Close() }() @@ -102,9 +95,6 @@ func handleSMTPConn(conn net.Conn, messages chan<- []byte) { } } -// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и -// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы -// тест мог добавить его в корни доверия клиента. func testTLSCert(t *testing.T) tls.Certificate { t.Helper() @@ -133,8 +123,6 @@ func testTLSCert(t *testing.T) tls.Certificate { return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf} } -// testSender собирает sender, доверяющий самоподписанному сертификату -// тестового SMTP-сервера (в проде NewSender использует системные корни). func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender { t.Helper() host, port := splitHostPort(t, addr) @@ -183,7 +171,6 @@ func TestSendSuccess(t *testing.T) { case data := <-messages: raw := string(data) for _, want := range []string{ - // From с отображаемым именем (RFC 2047). "From: =?utf-8?b?", "", "To: user@example.com", @@ -213,8 +200,6 @@ func TestSendRejectsCRLFInjection(t *testing.T) { addr, messages := startSMTPTestServer(t, false, cert) s := testSender(t, addr, 5*time.Second, cert) - // CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо - // должно быть отклонено до обращения к SMTP-серверу. err := s.Send(context.Background(), Message{ To: "user@example.com\r\nBcc: victim@example.com", Subject: "Test", @@ -250,7 +235,7 @@ func TestSendContextCancelled(t *testing.T) { func TestSendTimeout(t *testing.T) { cert := testTLSCert(t) - addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия + addr, _ := startSMTPTestServer(t, true, cert) s := testSender(t, addr, 500*time.Millisecond, cert) start := time.Now() @@ -264,7 +249,6 @@ func TestSendTimeout(t *testing.T) { } func TestBuildMessage(t *testing.T) { - // С HTML-версией — multipart/alternative. raw := string(buildMessage( "sender@example.com", "Вечерний детектив", "user@example.com", "Привет", "support@example.com", @@ -290,7 +274,6 @@ func TestBuildMessage(t *testing.T) { } } - // Без HTML-версии — только text/plain (обратная совместимость). raw = string(buildMessage( "sender@example.com", "Вечерний детектив", "user@example.com", "Привет", "", diff --git a/internal/modules/email_sender/template.go b/internal/modules/email_sender/template.go index 66ed9ea..4103671 100644 --- a/internal/modules/email_sender/template.go +++ b/internal/modules/email_sender/template.go @@ -10,7 +10,6 @@ func ButtonHTML(link, label string) string { ` } -// WrapHTML возвращает общую светлую/тёмную HTML-обёртку для писем сервиса. func WrapHTML(content, baseURL string) string { fontURL := "" if baseURL != "" { diff --git a/internal/modules/file_storage/interface.go b/internal/modules/file_storage/interface.go index b301a4c..3ede1de 100644 --- a/internal/modules/file_storage/interface.go +++ b/internal/modules/file_storage/interface.go @@ -9,8 +9,6 @@ import ( "strings" ) -// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для -// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound). var ErrFileNotFound = errors.New("файл не найден") // File describes a stored file. @@ -28,10 +26,6 @@ type IFileStorage interface { MimeType(filename string) string } -// FileType возвращает категорию файла по расширению имени: pdf → "pdf", -// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", mp4/webm → "video", иначе "". -// Регистр расширения и query-параметры игнорируются. -// Используется для деривации file_type улик (см. storytelling.Application). func FileType(filename string) string { ext := strings.ToLower(extension(filename)) switch ext { @@ -48,9 +42,6 @@ func FileType(filename string) string { } } -// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт -// без расширения): исключает коллизии в общем бакете. Расширение добавляет -// вызывающий код. func NewName() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/internal/modules/file_storage/storage.go b/internal/modules/file_storage/storage.go index c5fe810..d275728 100644 --- a/internal/modules/file_storage/storage.go +++ b/internal/modules/file_storage/storage.go @@ -59,8 +59,6 @@ func (s *storage) Put(ctx context.Context, file *File) error { func (s *storage) Get(ctx context.Context, filename string) (*File, error) { data, err := s.client.GetObjectBytes(ctx, s.bucket, filename) if err != nil { - // s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта - // (NoSuchKey/NotFound) в sentinel для прикладного слоя. var apiErr smithy.APIError if errors.As(err, &apiErr) { switch apiErr.ErrorCode() { @@ -88,8 +86,6 @@ func (s *storage) MimeType(filename string) string { return "application/octet-stream" } -// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из -// расширения, чтобы объект в S3 соответствовал фактическому содержимому. func mimeFor(f *File) string { if f.Mime != "" { return f.Mime @@ -97,10 +93,6 @@ func mimeFor(f *File) string { return mimeFromData(f.Data, f.Name) } -// mimeFromData — Content-Type по содержимому (первые 512 байт) с запасным вариантом -// на расширение. text/* (например, HTML) трактуется как octet-stream и -// уходит в запасной вариант: при публичном скачивании не должно отдаваться как -// html с доверенного домена. func mimeFromData(data []byte, filename string) string { detected := http.DetectContentType(data) if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" { @@ -112,8 +104,6 @@ func mimeFromData(data []byte, filename string) string { return detected } -// extension — расширение имени файла в нижнем регистре, без query/fragment -// (например, "photo.PNG?v=2" → ".png"). func extension(filename string) string { if i := strings.IndexAny(filename, "?#"); i >= 0 { filename = filename[:i] diff --git a/internal/modules/file_storage/storage_test.go b/internal/modules/file_storage/storage_test.go index 5b6850a..4f08c7c 100644 --- a/internal/modules/file_storage/storage_test.go +++ b/internal/modules/file_storage/storage_test.go @@ -48,7 +48,6 @@ func TestNewName(t *testing.T) { if a == b { t.Errorf("NewName вернул одинаковые имена %q и %q", a, b) } - // hex-токен 16 байт = 32 символа if len(a) != 32 { t.Errorf("длина имени = %d, want 32", len(a)) } diff --git a/internal/modules/network_utils/addresses.go b/internal/modules/network_utils/addresses.go index a916f6b..cc1d20d 100644 --- a/internal/modules/network_utils/addresses.go +++ b/internal/modules/network_utils/addresses.go @@ -3,7 +3,6 @@ package network_utils import "net" -// GetLocalIPv4Addresses возвращает IPv4-адреса хоста, кроме loopback-адресов. func GetLocalIPv4Addresses() ([]string, error) { addrs, err := net.InterfaceAddrs() if err != nil { diff --git a/internal/modules/processor_jwt/context.go b/internal/modules/processor_jwt/context.go index ce486ad..99b4090 100644 --- a/internal/modules/processor_jwt/context.go +++ b/internal/modules/processor_jwt/context.go @@ -10,7 +10,6 @@ func ContextWithClaims(ctx context.Context, claims *JWTClaims) context.Context { return context.WithValue(ctx, claimsContextKey{}, claims) } -// ClaimsFromContext возвращает данные аутентификации, сохранённые в ctx. func ClaimsFromContext(ctx context.Context) (*JWTClaims, bool) { claims, ok := ctx.Value(claimsContextKey{}).(*JWTClaims) return claims, ok && claims != nil diff --git a/internal/modules/roles/interface.go b/internal/modules/roles/interface.go index 8fcfe6d..1fdb73c 100644 --- a/internal/modules/roles/interface.go +++ b/internal/modules/roles/interface.go @@ -1,7 +1,6 @@ // Package roles defines application roles and role checks. package roles -// Роли пользователей системы. const ( Admin = "admin" Author = "author" @@ -9,7 +8,6 @@ const ( User = "user" ) -// AllRoles — список всех ролей. var AllRoles = []string{ Admin, Author, @@ -17,7 +15,6 @@ var AllRoles = []string{ User, } -// IHasRole — контракт субъекта, у которого можно проверить наличие роли. type IHasRole interface { HasRole(role string) bool } diff --git a/internal/modules/roles/roles.go b/internal/modules/roles/roles.go index 317f803..84b6658 100644 --- a/internal/modules/roles/roles.go +++ b/internal/modules/roles/roles.go @@ -1,7 +1,5 @@ package roles -// HasRole возвращает true, если у субъекта есть роль; администратор -// проходит проверку любой роли. func HasRole(claims IHasRole, role string) bool { if claims.HasRole(Admin) { return true diff --git a/internal/modules/roles/subject.go b/internal/modules/roles/subject.go index 04df944..889bed2 100644 --- a/internal/modules/roles/subject.go +++ b/internal/modules/roles/subject.go @@ -2,7 +2,6 @@ package roles import "slices" -// subject — реализация IHasRole: субъект с фиксированным набором ролей. type subject struct { roles []string } @@ -12,7 +11,6 @@ func NewSubject(roles ...string) IHasRole { return &subject{roles: roles} } -// HasRole возвращает true, если роль есть в наборе ролей субъекта. func (s *subject) HasRole(role string) bool { return slices.Contains(s.roles, role) } diff --git a/internal/modules/scenario_archive/archive.go b/internal/modules/scenario_archive/archive.go index 3710787..5a4f334 100644 --- a/internal/modules/scenario_archive/archive.go +++ b/internal/modules/scenario_archive/archive.go @@ -1,6 +1,3 @@ -// Package scenario_archive собирает и разбирает ZIP-архивы сценариев. -// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений). -// Все пути внутри архива относительные. package scenario_archive import ( @@ -19,32 +16,24 @@ import ( "evening_detective_server/internal/modules/storytelling" ) -// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается -// по фактически распакованным байтам — размеры в заголовках ZIP подделываются. var ( - maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива - maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного - maxEntries = 500 // число записей - maxEntrySize = 32 << 20 // 32 МБ — размер одной записи - maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке + maxArchiveSize = 32 << 20 + maxUnpackedSize = 128 << 20 + maxEntries = 500 + maxEntrySize = 32 << 20 + maxPackSize = 256 << 20 ) type scenarioArchive struct{} -// NewScenarioArchive создаёт реализацию IScenarioArchive. func NewScenarioArchive() IScenarioArchive { return &scenarioArchive{} } -// MaxArchiveSize — максимальный размер входящего архива (для проверок -// в хендлере и на HTTP-слое). func MaxArchiveSize() int { return maxArchiveSize } -// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true -// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy). -// Внешние URL и пустые ссылки — ("", false): в архив не кладутся. func ImagePath(ref, domain string) (string, bool) { ref = strings.TrimPrefix(ref, domain) if ref == "" { @@ -57,9 +46,6 @@ func ImagePath(ref, domain string) (string, bool) { return "images/" + path.Base(ref), true } -// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища -// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние -// URL остаются без изменений. Архив детерминирован (порядок записей и метки). func (a *scenarioArchive) Pack( ctx context.Context, scenario *Scenario, @@ -77,7 +63,6 @@ func (a *scenarioArchive) Pack( } } - // Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка. archivePathOf := map[string]string{} addRef := func(ref string) error { if ref == "" { @@ -127,7 +112,6 @@ func (a *scenarioArchive) Pack( } } - // Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом. images := make(map[string][]byte, len(archivePathOf)) var totalPacked int64 for ref, archivePath := range archivePathOf { @@ -197,7 +181,6 @@ func writeZipEntry(zw *zip.Writer, name string, data []byte) error { Name: name, Method: zip.Deflate, } - // Нулевое время — детерминизм архива между запусками. header.Modified = time.Time{} w, err := zw.CreateHeader(header) if err != nil { @@ -226,7 +209,6 @@ func strPtrValue(p *string) string { return *p } -// Unpack разбирает входящий архив с проверкой лимитов и путей записей. func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) { if len(data) == 0 { return nil, errors.New("архив пуст") @@ -273,7 +255,6 @@ func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) { return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize) } - // Дубли записей отвергаем — «последний победил» портит данные. if name == FileName { if scenarioRaw != nil { return nil, fmt.Errorf("в архиве несколько записей %s", FileName) @@ -309,8 +290,6 @@ func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) { return bundle, nil } -// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP -// подделываются тривиально). func readEntry(r io.Reader) ([]byte, error) { lr := io.LimitReader(r, int64(maxEntrySize)+1) content, err := io.ReadAll(lr) @@ -323,7 +302,6 @@ func readEntry(r io.Reader) ([]byte, error) { return content, nil } -// validateEntryPath отклоняет небезопасные пути записей. func validateEntryPath(name string) error { if name == "" { return errors.New("пустое имя записи в архиве") diff --git a/internal/modules/scenario_archive/archive_test.go b/internal/modules/scenario_archive/archive_test.go index 66662dd..da07287 100644 --- a/internal/modules/scenario_archive/archive_test.go +++ b/internal/modules/scenario_archive/archive_test.go @@ -90,7 +90,6 @@ func TestPackUnpackRoundTrip(t *testing.T) { t.Errorf("в архиве нет файла %q", want) } } - // Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз. if len(bundle.Files) != 3 { t.Errorf("Files = %v, want 3 files", bundle.Files) } @@ -176,7 +175,6 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) { } func TestPackLegacyFullURLImage(t *testing.T) { - // Устаревшие данные: полный URL хранилища срезается до базового имени файла. description := "desc" scenario := &Scenario{ Name: "Legacy", @@ -438,7 +436,6 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) { name = "images/x.png" content = []byte("x") case 2: - // Дубль записи images/x.png. name = "images/x.png" content = []byte("y") } diff --git a/internal/modules/scenario_archive/interface.go b/internal/modules/scenario_archive/interface.go index bf539ca..7ebc9d2 100644 --- a/internal/modules/scenario_archive/interface.go +++ b/internal/modules/scenario_archive/interface.go @@ -12,8 +12,6 @@ const FileName = "scenario.json" // Version is the current scenario.json format version. const Version = 1 -// ScenarioJSON — описание сценария в архиве; Story — в формате колонки -// scenarios.scenario в БД. type ScenarioJSON struct { Version int `json:"version"` Name string `json:"name"` @@ -22,7 +20,6 @@ type ScenarioJSON struct { Story *storytelling.Story `json:"story"` } -// Scenario — данные сценария, необходимые для сборки архива. type Scenario struct { Name string Description *string @@ -36,12 +33,7 @@ type Bundle struct { Files map[string][]byte } -// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор -// ZIP-архива сценария. type IScenarioArchive interface { - // Pack собирает ZIP-архив сценария: ссылки на изображения хранилища - // переписываются в images/<имя>, файлы запрашиваются через getFile; - // внешние URL остаются без изменений. Pack( ctx context.Context, scenario *Scenario, @@ -49,6 +41,5 @@ type IScenarioArchive interface { getFile func(ctx context.Context, name string) ([]byte, error), ) ([]byte, error) - // Unpack разбирает входящий архив с проверкой лимитов и путей записей. Unpack(data []byte) (*Bundle, error) } diff --git a/internal/modules/storytelling/story.go b/internal/modules/storytelling/story.go index 81388f3..309f929 100644 --- a/internal/modules/storytelling/story.go +++ b/internal/modules/storytelling/story.go @@ -22,17 +22,12 @@ func (s *story) GetStory( codesWithUseActions := map[string]struct{}{} codesHidden := map[string]struct{}{} givenApplications := map[string]struct{}{} - // Введение передаётся игроку всегда — и до первого хода (начало игры), - // и в дальнейшем (как заголовок истории). if scenario.Introduction != nil { story.Introduction = &Introduction{ Text: s.cleaner.ClearText(scenario.Introduction.Text), Audio: scenario.Introduction.Audio, } } - // Вопросы дела передаются команде всегда — с начала игры; правильные - // ответы (Answer) команде не передаются: они нужны только автору и - // организаторам при оценке ответов. for _, question := range scenario.Questions { story.Questions = append(story.Questions, &Question{ Code: question.Code, @@ -49,7 +44,6 @@ func (s *story) GetStory( if i < len(codes)-1 { nextPlace, _ = s.findPlace(scenario, codes[i+1]) } - // Если следующая точка это открытие двери в текущей точке, то текущая точка считается использованной для действия if nextPlace != nil && currentPlace != nil { openDoor := false for _, door := range currentPlace.Doors { @@ -60,7 +54,6 @@ func (s *story) GetStory( } } if openDoor { - // Запрещаем смотреть не выбранные действия for _, door := range currentPlace.Doors { if door.Code != nextPlace.Code { codesHidden[door.Code] = struct{}{} diff --git a/internal/modules/string_tools/transliterate.go b/internal/modules/string_tools/transliterate.go index 515ee68..bc93ade 100644 --- a/internal/modules/string_tools/transliterate.go +++ b/internal/modules/string_tools/transliterate.go @@ -45,8 +45,6 @@ var ( ) ) -// Transliterate выполняет транслитерацию русского текста в латиницу, -// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.) func Transliterate(text string) string { result := replacer.Replace(strings.ToLower(text)) diff --git a/internal/modules/team_qr_pdf/generator.go b/internal/modules/team_qr_pdf/generator.go index 6b067f3..46a3612 100644 --- a/internal/modules/team_qr_pdf/generator.go +++ b/internal/modules/team_qr_pdf/generator.go @@ -61,8 +61,6 @@ func Generate(cards []Card) ([]byte, error) { } x := margin + float64(i%3)*width y := margin + float64(i%12/3)*height - // Растровый режим включает свободное поле QR; векторные квадраты сохраняют - // чёткость при печати без растрового масштабирования. bitmap := qr.Bitmap() pixel := qrSize / float64(len(bitmap)) for row, cells := range bitmap { @@ -73,8 +71,6 @@ func Generate(cards []Card) ([]byte, error) { } } name := strings.Join(strings.Fields(card.Name), " ") - // ponytail: fpdf поддерживает BMP; для символов вне BMP (например, - // emoji) нужен другой движок шрифтов. Возвращаем ошибку вместо паники. for _, r := range name { if r > 0xffff { return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r) diff --git a/internal/repos/answer.go b/internal/repos/answer.go index c9861fc..c91904a 100644 --- a/internal/repos/answer.go +++ b/internal/repos/answer.go @@ -1,12 +1,9 @@ // Package repos contains database repositories and persistence models. package repos -// TeamAnswer — ответ команды на вопрос дела (вопросы живут в сценарии, -// ответы — в таблице team_answers). type TeamAnswer struct { TeamID int QuestionCode string Text string - // Score — балл, выставленный организатором; nil — ещё не оценено. - Score *int + Score *int } diff --git a/internal/repos/game_map_repo/repo.go b/internal/repos/game_map_repo/repo.go index e5dff47..6111c39 100644 --- a/internal/repos/game_map_repo/repo.go +++ b/internal/repos/game_map_repo/repo.go @@ -8,10 +8,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// ErrItemNotFound означает, что объект карты не найден. var ErrItemNotFound = errors.New("объект карты не найден") -// Item — объект на карте игры. type Item struct { GameID int Kind string @@ -21,7 +19,6 @@ type Item struct { Hidden bool } -// Repo работает с объектами карт в PostgreSQL. type Repo struct { pool *pgxpool.Pool } @@ -31,7 +28,6 @@ func NewGameMapRepo(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } -// Ensure добавляет объект карты, если он ещё не существует. func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID int) error { _, err := r.pool.Exec(ctx, ` INSERT INTO game_map_items (game_id, kind, item_id) @@ -44,7 +40,6 @@ func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID int) return err } -// List возвращает объекты карты игры. func (r *Repo) List(ctx context.Context, gameID int) ([]Item, error) { rows, err := r.pool.Query( ctx, @@ -75,7 +70,6 @@ func (r *Repo) List(ctx context.Context, gameID int) ([]Item, error) { return items, rows.Err() } -// Update изменяет координаты и видимость объекта карты. func (r *Repo) Update(ctx context.Context, item Item) error { result, err := r.pool.Exec( ctx, @@ -103,7 +97,6 @@ func (r *Repo) Update(ctx context.Context, item Item) error { return nil } -// Delete удаляет объект карты. func (r *Repo) Delete(ctx context.Context, item Item) error { _, err := r.pool.Exec( ctx, diff --git a/internal/repos/scenarios_repo/repo.go b/internal/repos/scenarios_repo/repo.go index c683035..80e0b91 100644 --- a/internal/repos/scenarios_repo/repo.go +++ b/internal/repos/scenarios_repo/repo.go @@ -224,7 +224,6 @@ func (r *ScenariosRepo) GetScenarioByID( return nil, err } - // Автор может отсутствовать (обезличен после удаления аккаунта). if authorID != nil && authorUsername != nil { scenario.Author = &repos.User{ID: *authorID, Username: *authorUsername} } @@ -232,7 +231,6 @@ func (r *ScenariosRepo) GetScenarioByID( return scenario, nil } -// UpdateScenarioByID меняет основные поля сценария. func (r *ScenariosRepo) UpdateScenarioByID( ctx context.Context, id int, @@ -263,12 +261,6 @@ func (r *ScenariosRepo) UpdateScenarioByID( return nil } -// UpdateScenarioStatusByID меняет статус сценария. allowPublished=true -// разрешает менять статус опубликованного сценария (только для админа). -// Возвращает false, если строка не обновлена (например, не-админ пытается -// снять с публикации опубликованный сценарий). published_at фиксируется -// только при ПЕРВОЙ публикации: повторная публикация не двигает «момент -// отчуждения» исключительного права (п. 7.2 Пользовательского соглашения). func (r *ScenariosRepo) UpdateScenarioStatusByID( ctx context.Context, id int, @@ -296,10 +288,6 @@ func (r *ScenariosRepo) UpdateScenarioStatusByID( return tag.RowsAffected() > 0, nil } -// DeleteScenarioByID помечает сценарий удалённым (soft delete). -// allowPublished=true разрешает удаление опубликованного сценария -// (только для админа). Возвращает false, если строка не обновлена -// (например, не-админ пытается удалить опубликованный сценарий). func (r *ScenariosRepo) DeleteScenarioByID( ctx context.Context, id int, @@ -347,7 +335,6 @@ func (r *ScenariosRepo) GetStoryByScenarioID( return story, nil } -// UpdateStoryByScenarioID обновляет содержимое (историю) сценария. func (r *ScenariosRepo) UpdateStoryByScenarioID( ctx context.Context, id int, diff --git a/internal/repos/team_answers_repo/repo.go b/internal/repos/team_answers_repo/repo.go index 53a40cf..478caa3 100644 --- a/internal/repos/team_answers_repo/repo.go +++ b/internal/repos/team_answers_repo/repo.go @@ -20,8 +20,6 @@ func NewTeamAnswersRepo(pool *pgxpool.Pool) *TeamAnswersRepo { } } -// UpsertAnswers сохраняет ответы команды: существующие перезаписываются, -// отсутствующие — вставляются (UNIQUE (team_id, question_code)). func (r *TeamAnswersRepo) UpsertAnswers( ctx context.Context, teamID int, @@ -90,7 +88,6 @@ func (r *TeamAnswersRepo) GetAnswersByTeamID( return answers, nil } -// GetAnswersByGameID возвращает ответы всех команд игры (вместе с баллами). func (r *TeamAnswersRepo) GetAnswersByGameID( ctx context.Context, gameID int, @@ -134,10 +131,6 @@ func (r *TeamAnswersRepo) GetAnswersByGameID( return answers, nil } -// SetAnswerScore выставляет балл за ответ; score == nil сбрасывает балл. -// Балл можно выставить и за неотвеченный вопрос (команда может не дать -// ответ): если строки (team_id, question_code) ещё нет, она создаётся с -// пустым ответом и выставленным баллом. func (r *TeamAnswersRepo) SetAnswerScore( ctx context.Context, teamID int, diff --git a/internal/repos/teams_repo/participation.go b/internal/repos/teams_repo/participation.go index b1bd843..1519978 100644 --- a/internal/repos/teams_repo/participation.go +++ b/internal/repos/teams_repo/participation.go @@ -86,7 +86,6 @@ func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer strin if err != nil { return nil, err } - // Условие проверяется повторно после снятия блокировки строки конкурентным ответом. _, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g) UPDATE teams t SET participation_status=$2, diff --git a/internal/repos/teams_repo/repo.go b/internal/repos/teams_repo/repo.go index 68c27fe..8054c98 100644 --- a/internal/repos/teams_repo/repo.go +++ b/internal/repos/teams_repo/repo.go @@ -191,7 +191,6 @@ func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error { // ErrRegistrationClosed indicates that team registration is unavailable. var ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта") -// RegisterTeam сохраняет одну команду на доставку, включая одновременные повторы. func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) { return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false) } @@ -199,7 +198,6 @@ func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, na // 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) if err == nil { return id, nil @@ -223,7 +221,6 @@ func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID in return id, err } -// GetActiveTeamsByGameID исключает отменённые регистрации из игровых выгрузок и результатов. func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) { teams, err := r.GetTeamsByGameID(ctx, gameID) active := teams[:0] diff --git a/internal/repos/user_agreements_repo/repo.go b/internal/repos/user_agreements_repo/repo.go index bbc76a1..c6fff3e 100644 --- a/internal/repos/user_agreements_repo/repo.go +++ b/internal/repos/user_agreements_repo/repo.go @@ -1,16 +1,11 @@ // Package user_agreements_repo persists user agreements. package user_agreements_repo -// AgreementType — типы документов, акцептуемых пользователем. const ( - AgreementTypeTerms = "terms" // Пользовательское соглашение - AgreementTypePrivacy = "privacy" // Политика конфиденциальности + AgreementTypeTerms = "terms" + AgreementTypePrivacy = "privacy" ) -// Agreement — фиксируемый факт акцепта документа (ст. 9 152-ФЗ — согласие в -// форме, позволяющей подтвердить факт его получения). Version должна -// соответствовать опубликованной редакции документа; при изменении текста -// документа версия обязана увеличиваться. type Agreement struct { AgreementType string Version string diff --git a/internal/repos/users_repo/repo.go b/internal/repos/users_repo/repo.go index fd4af8b..000e246 100644 --- a/internal/repos/users_repo/repo.go +++ b/internal/repos/users_repo/repo.go @@ -30,10 +30,6 @@ func NewUserRepo(pool *pgxpool.Pool) *UsersRepo { } } -// AddUserWithAgreements создаёт пользователя и фиксирует акцепты документов -// (user_agreements) в одной транзакции: пользователь либо существует вместе с -// записями о согласиях, либо не существует вовсе (доказуемое согласие, -// ст. 9 152-ФЗ). Возвращает id созданного пользователя. func (s *UsersRepo) AddUserWithAgreements( ctx context.Context, username string, @@ -42,8 +38,6 @@ func (s *UsersRepo) AddUserWithAgreements( roles []string, agreements []user_agreements_repo.Agreement, ) (int, error) { - // Инвариант «пользователь не существует без записей о согласиях» - // держится на том, что акцепты передаются всегда. if len(agreements) == 0 { return 0, errors.New("Не переданы записи об акцепте соглашений") } @@ -73,15 +67,11 @@ func (s *UsersRepo) AddUserWithAgreements( email, ).Scan(&id) if err != nil { - // Ничего не вставлено — занят username или email. if errors.Is(err, pgx.ErrNoRows) { return 0, errors.New("Username или email уже используется") } - // Гонка: между проверкой NOT EXISTS и INSERT другой запрос успел - // создать пользователя с тем же username/email — нарушение - // уникальности. Возвращаем тот же понятный клиенту ответ. var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { // нарушение уникальности + if errors.As(err, &pgErr) && pgErr.Code == "23505" { return 0, errors.New("Username или email уже используется") } return 0, err @@ -266,12 +256,6 @@ func (s *UsersRepo) DeleteUserRole( return err } -// DeleteUser удаляет учётную запись и персональные данные пользователя -// (ст. 21 152-ФЗ — уничтожение ПДн при удалении аккаунта). Выполняется в одной -// транзакции. Публичный контент пользователя (сценарии, команды) сохраняется: -// FK-ограничения с ON DELETE SET NULL (миграция -// 20260818232725_keep_content_on_user_delete.sql) обнуляют привязку к автору — -// контент остаётся в обезличенном виде. func (s *UsersRepo) DeleteUser( ctx context.Context, userID int, @@ -282,7 +266,6 @@ func (s *UsersRepo) DeleteUser( } defer tx.Rollback(ctx) //nolint:errcheck - // Refresh-токены (FK CASCADE, удаляем явно для предсказуемости). if _, err = tx.Exec( ctx, `DELETE FROM refresh_tokens WHERE user_id = $1`, @@ -291,13 +274,6 @@ func (s *UsersRepo) DeleteUser( return err } - // Зафиксированные акцепты соглашений НЕ удаляются: ст. 9 ч. 4 152-ФЗ - // возлагает на оператора бремя доказывания получения согласия, поэтому - // факт акцепта (документ, версия, время) сохраняется в обезличенном виде: - // ссылка на пользователя и персональные данные внутри записи (ip, - // user_agent) обнуляются. FK ON DELETE SET NULL (миграция - // 20260818231943_create_user_agreements_table.sql) гарантирует, что при - // удалении users строки акцептов остаются с user_id = NULL. if _, err = tx.Exec( ctx, `UPDATE user_agreements @@ -308,8 +284,6 @@ func (s *UsersRepo) DeleteUser( return err } - // Пользователь. Связанные сценарии (author_id) и команды (creator_id) - // обнуляются автоматически через ON DELETE SET NULL. tag, err := tx.Exec( ctx, `DELETE FROM users WHERE id = $1`, diff --git a/internal/services/answer_service/answer.go b/internal/services/answer_service/answer.go index e1fbfef..506ba25 100644 --- a/internal/services/answer_service/answer.go +++ b/internal/services/answer_service/answer.go @@ -6,28 +6,22 @@ import ( "evening_detective_server/internal/services/game_model" ) -// Answer — ответ команды на вопрос дела (для публичного API команды). type Answer struct { QuestionCode string Text string } -// AnswerScore — ответ команды с баллом организатора (для панели оценки). type AnswerScore struct { QuestionCode string Text string - // Score — балл; nil — ещё не оценено. - Score *int + Score *int } -// TeamAnswers — ответы одной команды игры. type TeamAnswers struct { Team *game_model.Team Answers []AnswerScore } -// GameAnswers — полная картина для оценки организатором: вопросы сценария -// (с правильными ответами) и ответы всех команд с баллами. type GameAnswers struct { Questions []*storytelling.Question Teams []TeamAnswers diff --git a/internal/services/answer_service/results.go b/internal/services/answer_service/results.go index 0a774ee..8c12c58 100644 --- a/internal/services/answer_service/results.go +++ b/internal/services/answer_service/results.go @@ -7,31 +7,18 @@ import ( "evening_detective_server/internal/modules/storytelling" ) -// TeamResult — строка итоговой таблицы: команда, баллы по вопросам (в порядке -// сценария), сумма баллов и место. type TeamResult struct { - Team *game_model.Team - Answers []AnswerScore - // TotalScore — сумма баллов по всем вопросам сценария; неоценённые - // вопросы считаются как 0. + Team *game_model.Team + Answers []AnswerScore TotalScore int - // Place — место в итоговой таблице. Команды с одинаковой суммой баллов - // и одинаковым числом ходов занимают одно место (следующее место - // пропускается); проставляется функцией rankTeams. - Place int + Place int } -// GameResults — итоговая таблица игры: вопросы сценария (шапка таблицы) и -// результаты команд, отсортированные по сумме баллов. type GameResults struct { Questions []*storytelling.Question Teams []TeamResult } -// buildTeamResults собирает строки итоговой таблицы по командам: баллы в -// порядке вопросов сценария (неотвеченный/неоценённый вопрос — с пустым -// текстом и Score=nil), сумма баллов (nil считается как 0). Команды в -// результате — в порядке переданного списка; сортировку и места делает // rankTeams. func buildTeamResults( questions []*storytelling.Question, @@ -63,12 +50,6 @@ func buildTeamResults( return res } -// rankTeams сортирует строки итоговой таблицы по сумме баллов (убывание); -// при равенстве суммы выше команда с меньшим числом ходов. Команды с -// одинаковой суммой баллов И одинаковым числом ходов занимают одно место -// (следующее место пропускается — схема 1,2,2,4 по ключу (сумма, ходы)). -// id команды влияет только на порядок показа внутри группы (меньший id -// первым), не на место. Входной срез не изменяется. func rankTeams(rows []TeamResult) []TeamResult { sorted := make([]TeamResult, len(rows)) copy(sorted, rows) diff --git a/internal/services/answer_service/results_test.go b/internal/services/answer_service/results_test.go index 0788f4a..8b2eb54 100644 --- a/internal/services/answer_service/results_test.go +++ b/internal/services/answer_service/results_test.go @@ -17,7 +17,6 @@ func teamResult(id, actions, total int) TeamResult { } func TestRankTeamsSortsByTotalThenActions(t *testing.T) { - // У всех команд разный ключ (сумма, ходы) — места идут подряд 1..N. rows := []TeamResult{ teamResult(3, 5, 10), teamResult(1, 3, 10), @@ -36,14 +35,12 @@ func TestRankTeamsSortsByTotalThenActions(t *testing.T) { t.Fatalf("rankTeams[%d].Place = %d, want %d", i, got[i].Place, wantPlaces[i]) } } - // Команды 1 и 3 имеют одинаковую сумму (10), но разные ходы — разные места. if got[1].Place == got[2].Place { t.Fatalf("равные суммы при разных ходах должны давать разные места: places = %d, %d", got[1].Place, got[2].Place) } } func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) { - // Одинаковая сумма И одинаковое число ходов — одно место у всех трёх. rows := []TeamResult{ teamResult(5, 4, 9), teamResult(2, 4, 9), @@ -51,7 +48,6 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) { } got := rankTeams(rows) - // Внутри группы порядок показа — по id asc (детерминированный). wantIDs := []int{2, 5, 7} for i, id := range wantIDs { if got[i].Team.ID != id { @@ -64,7 +60,6 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) { } func TestRankTeamsSkipsPlaceAfterSharedTie(t *testing.T) { - // Две команды делят 1-е место, следующая получает 3-е (схема 1,1,3). rows := []TeamResult{ teamResult(3, 2, 7), teamResult(1, 5, 10), @@ -116,7 +111,7 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) { "q3": {QuestionCode: "q3", Text: "ответ 3", Score: intPtr(-1)}, }, 2: { - "q2": {QuestionCode: "q2", Text: "ответ 2"}, // балл не выставлен + "q2": {QuestionCode: "q2", Text: "ответ 2"}, }, } @@ -126,7 +121,6 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) { t.Fatalf("len(buildTeamResults) = %d, want 2", len(got)) } - // Команда 1: q1(5), q2 не отвечен, q3(-1) → сумма 4. 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) @@ -143,7 +137,6 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) { t.Fatalf("команда 1: неотвеченный q2 = %+v, want Score=nil и Text=\"\"", first.Answers[1]) } - // Команда 2: все не оценены → сумма 0. 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) diff --git a/internal/services/answer_service/service.go b/internal/services/answer_service/service.go index 0bb671b..6465c02 100644 --- a/internal/services/answer_service/service.go +++ b/internal/services/answer_service/service.go @@ -40,24 +40,15 @@ func NewAnswerService( } var ( - // ErrTeamNotFound — команда не найдена или пароль неверен. - ErrTeamNotFound = errors.New("Команда не найдена") - // ErrGameNotFound — игра не найдена. - ErrGameNotFound = errors.New("Игра не найдена") - // ErrGameFinished — после завершения игры ответы команд больше нельзя - // изменять: организатор оценивает их в панели игры. - ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять") - // ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя. - ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя") - // ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры. + ErrTeamNotFound = errors.New("Команда не найдена") + ErrGameNotFound = errors.New("Игра не найдена") + ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять") + ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя") ErrQuestionNotFound = errors.New("Вопрос не найден") - // ErrGameNotFinished — итоги игры доступны только после её завершения. - ErrGameNotFinished = errors.New("Итоги доступны после завершения игры") - // ErrOfflineAnswers — ответы для offline-игры недоступны через сайт. - ErrOfflineAnswers = errors.New("В offline-игре ответы через сайт недоступны") + ErrGameNotFinished = errors.New("Итоги доступны после завершения игры") + ErrOfflineAnswers = errors.New("В offline-игре ответы через сайт недоступны") ) -// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой. func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password string) (*repos.Team, *repos.Game, error) { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { @@ -79,7 +70,6 @@ func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password return team, game, nil } -// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду. func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenarioID int) ([]*storytelling.Question, map[string]*storytelling.Question, error) { scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioID) if err != nil { @@ -92,9 +82,6 @@ func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenario return scenario.Story.Questions, byCode, nil } -// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов: -// баллы видны только организаторам). Ответы на удалённые из сценария -// вопросы не возвращаются. func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password string) ([]Answer, error) { _, game, err := s.getTeamAndGame(ctx, teamID, password) if err != nil { @@ -124,10 +111,6 @@ func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password return answers, nil } -// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert). -// Доступно только пока игра идёт (статусы run/pause): после завершения -// ответы фиксируются для оценки организатором. Переданные коды должны -// существовать в сценарии игры; пустая строка очищает ответ. func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []Answer) error { _, game, err := s.getTeamAndGame(ctx, teamID, password) if err != nil { @@ -138,7 +121,6 @@ func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, passw } switch game.Status { case "run", "pause": - // игра идёт — можно отвечать default: if game.Status == "finish" { return ErrGameFinished @@ -162,12 +144,6 @@ func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, passw return s.answersRepo.UpsertAnswers(ctx, teamID, rows) } -// GetGameAnswers возвращает полную картину для оценки организатором: -// вопросы сценария с правильными ответами и ответы всех команд с баллами. -// Команда может не дать ответ на вопрос — такой вопрос попадает в список -// ответов команды с пустым текстом и без балла (организатор видит его и -// может выставить балл, в т.ч. штрафной). Ответы на удалённые из сценария -// вопросы не возвращаются (как в GetTeamAnswers). func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAnswers, error) { game, err := s.gamesRepo.GetGameByID(ctx, gameID) if err != nil { @@ -211,8 +187,6 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn teamAnswers := make([]TeamAnswers, 0, len(teams)) for _, team := range teams { byCode := answerByTeamAndCode[team.ID] - // Все вопросы сценария в порядке сценария; неотвеченные — с пустым - // текстом и без балла, чтобы организатор видел пропуски. answers := make([]AnswerScore, 0, len(questions)) for _, question := range questions { if answer, ok := byCode[question.Code]; ok { @@ -234,10 +208,6 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn }, nil } -// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела; -// score == nil сбрасывает балл. Балл может быть любым целым числом -// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать -// в сценарии игры. func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, questionCode string, score *int) error { team, err := s.teamsRepo.GetTeamByID(ctx, teamID) if err != nil { @@ -257,11 +227,6 @@ func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, ques return s.answersRepo.SetAnswerScore(ctx, teamID, questionCode, score) } -// GetGameResults возвращает итоговую таблицу игры: вопросы сценария (шапка -// таблицы, без правильных ответов) и команды, отсортированные по сумме -// баллов, с баллами по каждому вопросу (в порядке сценария), числом ходов и -// местом. Доступно только после завершения игры (статус finish); неоценённые -// вопросы в сумме считаются как 0. func (s *AnswerService) GetGameResults(ctx context.Context, gameID int) (*GameResults, error) { game, err := s.gamesRepo.GetGameByID(ctx, gameID) if err != nil { diff --git a/internal/services/answer_service/service_test.go b/internal/services/answer_service/service_test.go index 3886644..d68ed9e 100644 --- a/internal/services/answer_service/service_test.go +++ b/internal/services/answer_service/service_test.go @@ -39,7 +39,6 @@ func TestAnswerAccessPostgres(t *testing.T) { if err != nil { t.Fatal(err) } - // Отклонённые запросы не должны доходить до хранилищ сценариев и ответов. service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil) for _, tc := range []struct { status string diff --git a/internal/services/file_service/service.go b/internal/services/file_service/service.go index 19de7ba..8771a9e 100644 --- a/internal/services/file_service/service.go +++ b/internal/services/file_service/service.go @@ -13,20 +13,15 @@ import ( "evening_detective_server/internal/modules/string_tools" ) -// Ошибки валидации загружаемого файла. Маппятся в gRPC-статусы в app-слое: -// ErrFileTooLarge → ResourceExhausted, остальные → InvalidArgument. var ( ErrEmptyName = errors.New("пустое имя файла") ErrEmptyData = errors.New("пустые данные файла") ErrInvalidExtension = errors.New("недопустимое расширение файла (разрешены pdf, jpg, jpeg, png, gif, webp, mp3, ogg, wav, mp4, webm)") ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения, аудио или видео)") ErrFileTooLarge = errors.New("файл слишком большой") - // ErrFileNotFound — файл отсутствует в хранилище. - ErrFileNotFound = errors.New("файл не найден") + ErrFileNotFound = errors.New("файл не найден") ) -// allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения -// и аудио/видео (введение и закрывашка загружаются тем же эндпоинтом). var allowedExtensions = map[string]struct{}{ ".pdf": {}, ".jpg": {}, @@ -58,12 +53,6 @@ func NewFileService( } } -// UploadFile сохраняет файл в хранилище под уникальным именем и возвращает -// сохранённое имя и категорию файла (image|pdf|audio|video). -// -// Порядок проверок: имя/данные → расширение → base → размер → содержимое. -// Проверка размера — defense-in-depth: фактический лимит запроса -// устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами. func (s *FileService) UploadFile( ctx context.Context, filename string, @@ -86,8 +75,6 @@ func (s *FileService) UploadFile( return "", "", ErrEmptyName } - // Транслитерация может обнулить base (например, «!!!.pdf»): хранимое - // имя без base недопустимо — проверяем до размера и содержимого. transliterated := string_tools.Transliterate(base) if transliterated == "" { return "", "", ErrEmptyName @@ -97,8 +84,6 @@ func (s *FileService) UploadFile( return "", "", fmt.Errorf("%w (лимит %d МБ)", ErrFileTooLarge, s.maxFileSize>>20) } - // Категория по содержимому (сырой sniff): text/* и octet-stream не - // проходят вообще, расширение должно соответствовать категории. sniffed := http.DetectContentType(data) if ext == ".mp3" && isMP3(data) { sniffed = "audio/mpeg" @@ -124,16 +109,12 @@ func (s *FileService) UploadFile( return storedName, fileType, nil } -// isMP3 распознаёт MP3 без необязательного ID3-тега по заголовку MPEG-фрейма. func isMP3(data []byte) bool { return len(data) >= 4 && data[0] == 0xff && data[1]&0xe0 == 0xe0 && data[1]&0x18 != 0x08 && data[1]&0x06 != 0 && data[2]&0xf0 != 0 && data[2]&0xf0 != 0xf0 && data[2]&0x0c != 0x0c } -// contentTypeCategory возвращает категорию по sniffed-типу: -// application/pdf → pdf; image/* → image; audio/* и application/ogg → audio; -// всё остальное (text/*, application/octet-stream и пр.) → "". func contentTypeCategory(sniffed string) string { switch { case sniffed == "application/pdf": diff --git a/internal/services/file_service/service_test.go b/internal/services/file_service/service_test.go index bd88e8a..7d2e6c9 100644 --- a/internal/services/file_service/service_test.go +++ b/internal/services/file_service/service_test.go @@ -9,7 +9,6 @@ import ( "evening_detective_server/internal/modules/file_storage" ) -// fakeStorage — in-memory реализация IFileStorage для тестов. type fakeStorage struct { files map[string]*file_storage.File } @@ -87,7 +86,6 @@ func TestUploadFileValid(t *testing.T) { if !strings.Contains(name, "_") { t.Errorf("%s: имя %q не содержит уникальный hex-суффикс", c.filename, name) } - // Сохранённый MIME соответствует содержимому (sniffed-тип). stored, err := svc.fileStorage.Get(context.Background(), name) if err != nil { t.Fatalf("Get(%q): %v", name, err) @@ -144,11 +142,9 @@ func TestUploadFileValidation(t *testing.T) { func TestUploadFileTooLarge(t *testing.T) { svc := newService(1024) - // ровно лимит — проходит if _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1024()); err != nil { t.Errorf("ровно лимит: err = %v, want nil", err) } - // больше лимита — ErrFileTooLarge _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1025()) if !errors.Is(err, ErrFileTooLarge) { t.Errorf("больше лимита: err = %v, want ErrFileTooLarge", err) diff --git a/internal/services/game_service/application_mapper.go b/internal/services/game_service/application_mapper.go index 5362f6a..181e7cc 100644 --- a/internal/services/game_service/application_mapper.go +++ b/internal/services/game_service/application_mapper.go @@ -9,9 +9,6 @@ import ( "evening_detective_server/internal/repos" ) -// mapApplications — улики команды для ответа организатору (GetFullGame): -// имена файлов префиксуются доменом идемпотентно, file_type деривируется -// из расширения при пустом значении (легаси-строки таблицы). func mapApplications(o []*repos.Application, domain string) []*storytelling.Application { res := make([]*storytelling.Application, 0, len(o)) for _, item := range o { @@ -33,8 +30,6 @@ func mapApplication(o *repos.Application, domain string) *storytelling.Applicati return app } -// prefixDomain — идемпотентная префиксация ссылки доменом хранилища -// (пустые ссылки и внешние http/https URL не трогаются). func prefixDomain(ref, domain string) string { if ref == "" || strings.HasPrefix(ref, domain) { return ref diff --git a/internal/services/game_service/application_mapper_test.go b/internal/services/game_service/application_mapper_test.go index fa3f38e..ac3ae70 100644 --- a/internal/services/game_service/application_mapper_test.go +++ b/internal/services/game_service/application_mapper_test.go @@ -19,7 +19,6 @@ func TestMapApplications(t *testing.T) { t.Fatalf("len = %d, want 2", len(got)) } - // Относительное имя префиксуется доменом, file_type деривируется. if got[0].Image != testDomain+"clue.pdf" { t.Errorf("Image[0] = %q, want %q", got[0].Image, testDomain+"clue.pdf") } @@ -27,7 +26,6 @@ func TestMapApplications(t *testing.T) { t.Errorf("FileType[0] = %q, want pdf", got[0].FileType) } - // Внешний URL не префиксуется повторно. if got[1].Image != "https://external.example.com/pic.jpg" { t.Errorf("Image[1] = %q, want внешний URL без изменений", got[1].Image) } diff --git a/internal/services/game_service/lifecycle_test.go b/internal/services/game_service/lifecycle_test.go index 1714a8e..e4c8e3a 100644 --- a/internal/services/game_service/lifecycle_test.go +++ b/internal/services/game_service/lifecycle_test.go @@ -40,7 +40,7 @@ func TestServicesLifecyclePostgres(t *testing.T) { } config, err := pgxpool.ParseConfig(dsn) check(err) - config.MaxConns = 1 // Временные таблицы должны оставаться в одном соединении. + config.MaxConns = 1 pool, err := pgxpool.NewWithConfig(ctx, config) check(err) defer pool.Close() diff --git a/internal/services/game_service/service.go b/internal/services/game_service/service.go index fcc6ca3..9ee305f 100644 --- a/internal/services/game_service/service.go +++ b/internal/services/game_service/service.go @@ -25,9 +25,7 @@ type GameService struct { } var ( - // ErrGameNotFound — игра не найдена. - ErrGameNotFound = errors.New("Игра не найдена") - // ErrInvalidGameFormat — формат игры не поддерживается. + ErrGameNotFound = errors.New("Игра не найдена") ErrInvalidGameFormat = errors.New("Формат игры должен быть online или offline") ) diff --git a/internal/services/gameplay_service/application_mapper.go b/internal/services/gameplay_service/application_mapper.go index d2ea035..5d4eaaa 100644 --- a/internal/services/gameplay_service/application_mapper.go +++ b/internal/services/gameplay_service/application_mapper.go @@ -9,11 +9,6 @@ import ( "evening_detective_server/internal/repos" ) -// newApplication — улика для сохранения команде (AddTeamAction): имена -// приходят из истории уже с доменным префиксом (mapStory), в БД хранятся -// относительные пути — префикс снимается идемпотентно; file_type -// деривируется при пустом значении. Name не меняется (очистка — в -// вызывающем коде, cleaner'ом). func newApplication(app *storytelling.Application, domain string) *repos.Application { image := strings.TrimPrefix(app.Image, domain) fileType := app.FileType diff --git a/internal/services/gameplay_service/application_mapper_test.go b/internal/services/gameplay_service/application_mapper_test.go index f726691..d5dfeb5 100644 --- a/internal/services/gameplay_service/application_mapper_test.go +++ b/internal/services/gameplay_service/application_mapper_test.go @@ -12,8 +12,8 @@ func TestNewApplication(t *testing.T) { cases := []struct { name string app *storytelling.Application - image string // ожидаемое хранимое имя (без домена) - ftype string // ожидаемый file_type + image string + ftype string }{ { name: "доменный префикс снимается, file_type сохраняется", diff --git a/internal/services/gameplay_service/team_story.go b/internal/services/gameplay_service/team_story.go index 04446ab..571bbb5 100644 --- a/internal/services/gameplay_service/team_story.go +++ b/internal/services/gameplay_service/team_story.go @@ -2,7 +2,6 @@ package gameplay_service import "evening_detective_server/internal/modules/storytelling" -// filterTeamStory применяется только к истории команды, общей для сайта и MCP. func filterTeamStory(story *storytelling.Story, format string) { if format != "offline" { return diff --git a/internal/services/mcp_service/http_test.go b/internal/services/mcp_service/http_test.go index 9ce39a0..284b67c 100644 --- a/internal/services/mcp_service/http_test.go +++ b/internal/services/mcp_service/http_test.go @@ -15,10 +15,6 @@ import ( "github.com/mark3labs/mcp-go/server" ) -// Интеграционный тест HTTP-«клея»: MCP-сервер монтируется как -// streamable HTTP (эндпоинт /api/mcp) и вызывается через HTTP-клиент mcp-go. -// Проверяются initialize, tools/list (точное множество) и tools/call -// connect — как это будет делать внешний MCP-клиент. func TestStreamableHTTPEndToEnd(t *testing.T) { svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()}, &fakeAnswerSubmitter{}) @@ -29,8 +25,6 @@ func TestStreamableHTTPEndToEnd(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) t.Cleanup(cancel) - // ВАЖНО: клиент POST-ит строго на переданный baseURL без добавления - // пути — эндпоинт /api/mcp передаём явно. mcpClient, err := client.NewStreamableHttpClient(srv.URL + "/api/mcp") if err != nil { t.Fatalf("NewStreamableHttpClient() error = %v", err) @@ -77,8 +71,6 @@ func TestStreamableHTTPEndToEnd(t *testing.T) { } } -// Проверка, что структуры моков сериализуемы (защита от изменения типов -// storytelling/game_service без обновления моков). func TestMockTypesUsable(_ *testing.T) { _ = defaultStory() _ = defaultGame() diff --git a/internal/services/mcp_service/service.go b/internal/services/mcp_service/service.go index c50dc07..6f9a999 100644 --- a/internal/services/mcp_service/service.go +++ b/internal/services/mcp_service/service.go @@ -19,27 +19,17 @@ import ( "github.com/mark3labs/mcp-go/server" ) -// defaultCallTimeout — таймаут на выполнение одного инструмента: -// защита от зависших запросов к БД (10s — как у прежнего HTTP-прокси). const defaultCallTimeout = 10 * time.Second -// GamePlayer — граница доступа к игровым сервисам, реализуемая -// *gameplay_service.GameplayService; интерфейс позволяет тестировать сервис -// без БД. 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 } -// AnswerSubmitter сохраняет ответы команды независимо от прохождения истории. type AnswerSubmitter interface { SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []answer_service.Answer) error } -// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не -// хранит состояние между вызовами: каждый вызов получает id команды и -// пароль явно — либо из ссылки на игру через инструмент connect, либо -// напрямую. type MCPService struct { game GamePlayer answers AnswerSubmitter @@ -124,13 +114,6 @@ type connectArgs struct { password string } -// parseConnectURL разбирает ссылку на игру вида /team-story/{id}?password=... -// и возвращает id команды и пароль. -// -// Ссылка используется ТОЛЬКО как носитель id и пароля: MCP-сервер не -// выполняет HTTP-запросов по ней, поэтому схема и хост не проверяются -// (протокол-относительная ссылка //host/team-story/{id}?password=... -// принимается, «mailto:...» отклоняется на проверке пути). func parseConnectURL(rawURL string) (connectArgs, error) { if strings.TrimSpace(rawURL) == "" { return connectArgs{}, fmt.Errorf("требуется url") @@ -280,10 +263,6 @@ type submitAnswersArgs struct { answers []answer_service.Answer } -// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers: -// answers — массив объектов {questionCode, answer} (JSON-массив приходит -// как []any с map[string]any элементами). Ключ questionCode совпадает с -// REST JSON (camelCase) — единый формат для клиентов. func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) { teamID, ok := argInt64(args["team_id"]) if !ok { @@ -345,8 +324,6 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultText(jsonBody), nil } -// argInt64 достаёт int64 из аргумента инструмента: числа приходят как -// float64 (JSON), строки — как string. func argInt64(v any) (int64, bool) { switch n := v.(type) { case float64: diff --git a/internal/services/mcp_service/service_test.go b/internal/services/mcp_service/service_test.go index babff4a..2d41403 100644 --- a/internal/services/mcp_service/service_test.go +++ b/internal/services/mcp_service/service_test.go @@ -16,8 +16,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" ) -// fakeGamePlayer — мок GamePlayer с предзаданными историей, игрой и -// ошибками (реализует интерфейс GamePlayer, БД не нужна). type fakeGamePlayer struct { story *storytelling.Story game *game_model.Game @@ -82,8 +80,6 @@ func defaultGame() *game_model.Game { } } -// mcpFixture поднимает встроенный MCP-клиент над сервисом с моком -// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис. type mcpFixture struct { client *client.Client answers *fakeAnswerSubmitter @@ -118,7 +114,6 @@ func newMCPFixture(t *testing.T, player *fakeGamePlayer) *mcpFixture { return &mcpFixture{client: mcpClient, answers: answers} } -// call вызывает инструмент с аргументами и возвращает текст результата. func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string { t.Helper() request := mcp.CallToolRequest{} @@ -141,7 +136,6 @@ func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string return text.String() } -// toolNames возвращает имена инструментов сервера. func (f *mcpFixture) toolNames(t *testing.T) []string { t.Helper() toolsResult, err := f.client.ListTools(context.Background(), mcp.ListToolsRequest{}) @@ -199,8 +193,6 @@ func TestConnectRelativeURL(t *testing.T) { } } -// TestConnectProtocolRelativeURL: протокол-относительная ссылка принимается -// (запросы по ней не выполняются — из ссылки берутся только id и пароль). func TestConnectProtocolRelativeURL(t *testing.T) { f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) @@ -313,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": "10", "password": "team-pass-1", "code": "hall", }) @@ -408,7 +400,6 @@ func TestParseConnectURL(t *testing.T) { } } -// TestNewMCPService — конструктор не требует БД и возвращает сервис. func TestNewMCPService(t *testing.T) { svc := NewMCPService(&fakeGamePlayer{}, &fakeAnswerSubmitter{}) if svc == nil { @@ -419,7 +410,6 @@ func TestNewMCPService(t *testing.T) { } } -// TestDefaultCallTimeout — защита от случайного изменения таймаута. func TestDefaultCallTimeout(t *testing.T) { if defaultCallTimeout != 10*time.Second { t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout) diff --git a/internal/services/participation_service/participation.go b/internal/services/participation_service/participation.go index bd539fe..eeea0a9 100644 --- a/internal/services/participation_service/participation.go +++ b/internal/services/participation_service/participation.go @@ -5,7 +5,6 @@ import ( "time" ) -// Participation — данные участия, доступные транспортному и email-сценариям. type Participation struct { TeamID int TeamName string diff --git a/internal/services/participation_service/participation_test.go b/internal/services/participation_service/participation_test.go index 8016579..d980ec3 100644 --- a/internal/services/participation_service/participation_test.go +++ b/internal/services/participation_service/participation_test.go @@ -99,7 +99,7 @@ func TestParticipationPostgres(t *testing.T) { } } state(1, "pending") - state(2, "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; @@ -113,7 +113,6 @@ func TestParticipationPostgres(t *testing.T) { t.Fatal(err) } state(5, "confirmed") - // Независимые рабочие процессы должны захватывать каждую подходящую команду только один раз. var wg sync.WaitGroup ids := make(chan int, 8) errs := make(chan error, 8) @@ -158,7 +157,6 @@ func TestParticipationPostgres(t *testing.T) { 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)`) if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil { t.Fatal(err) @@ -209,7 +207,6 @@ 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) if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil { t.Fatal(err) diff --git a/internal/services/participation_service/service.go b/internal/services/participation_service/service.go index af053cc..a1c42f4 100644 --- a/internal/services/participation_service/service.go +++ b/internal/services/participation_service/service.go @@ -58,10 +58,6 @@ func mapParticipationError(err error) error { } } -// Стабильные токены для разных целей сохраняют работоспособность ссылок при -// повторе после неоднозначной ошибки SMTP. -// В базе хранится только хеш; секрет подписи должен быть постоянным между -// рабочими процессами и перезапусками. func participationToken(secret string, id int) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = fmt.Fprintf(mac, "team-participation:v1:%d", id) @@ -129,7 +125,6 @@ func (s *ParticipationService) sendParticipationEmails(ctx context.Context, send err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location)) } cancel() - // Сохраняем результат даже если отправка исчерпала время задачи. saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second) saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err) saveCancel() diff --git a/internal/services/scenarios_service/scenario_mapper.go b/internal/services/scenarios_service/scenario_mapper.go index 90c1e1c..5241a86 100644 --- a/internal/services/scenarios_service/scenario_mapper.go +++ b/internal/services/scenarios_service/scenario_mapper.go @@ -91,9 +91,6 @@ func mapStory(o string, domain string) (*storytelling.Story, error) { return res, nil } -// prefixDomain — идемпотентная префиксация ссылки доменом хранилища: -// пустые ссылки и внешние http/https URL не трогаются, повторный префикс -// исключён. Единое правило для картинок точек, аудио введения и улик. func prefixDomain(ref, domain string) string { if ref == "" || strings.HasPrefix(ref, domain) { return ref diff --git a/internal/services/scenarios_service/scenario_mapper_test.go b/internal/services/scenarios_service/scenario_mapper_test.go index 5bc75fc..092b38b 100644 --- a/internal/services/scenarios_service/scenario_mapper_test.go +++ b/internal/services/scenarios_service/scenario_mapper_test.go @@ -7,9 +7,6 @@ import ( "evening_detective_server/internal/modules/storytelling" ) -// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя), -// как картинки точек и аудио введения; префиксация идемпотентна, внешние -// URL не трогаются; file_type деривируется при пустом значении. func TestMapStoryPrefixesEvidence(t *testing.T) { storyJSON := `{ "introduction": {"text": "Вступление", "audio": "intro.mp3"}, @@ -38,7 +35,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) { t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png") } - // Улика из хранилища: полный URL + деривированный file_type. app := place.Applications[0] if app.Image != testDomain+"contract.pdf" { t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf") @@ -47,7 +43,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) { t.Errorf("application.FileType = %q, want pdf", app.FileType) } - // Внешний URL не префиксуется, file_type сохраняется. external := place.Applications[1] if external.Image != "https://external.example.com/pic.jpg" { t.Errorf("external.Image = %q, want без изменений", external.Image) @@ -56,7 +51,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) { t.Errorf("external.FileType = %q, want image", external.FileType) } - // Идемпотентность: повторный прогон не даёт двойного префикса. jsonOut, err := convertStory(story) if err != nil { t.Fatalf("convertStory: %v", err) @@ -70,8 +64,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) { } } -// TestNormalizeFileType — невалидный file_type заменяется деривацией из -// расширения файла, известные значения сохраняются. func TestNormalizeFileType(t *testing.T) { cases := []struct { fileType string @@ -92,8 +84,6 @@ func TestNormalizeFileType(t *testing.T) { } } -// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит -// normalizeStory и получает деривированный тип. func TestNormalizeStoryBanana(t *testing.T) { story := &storytelling.Story{ Places: []*storytelling.Place{ @@ -121,14 +111,11 @@ func TestNormalizeStoryBanana(t *testing.T) { } } -// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный -// префикс с улик (в БД хранятся относительные имена). func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) { repo := newFakeScenariosRepo() storage := newFakeStorage() svc := newTestService(repo, storage) - // Сценарий с историей, где улика уже с доменным префиксом. scenario := seedScenario(t, repo, storage) scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}` diff --git a/internal/services/scenarios_service/service.go b/internal/services/scenarios_service/service.go index 680c861..bc44280 100644 --- a/internal/services/scenarios_service/service.go +++ b/internal/services/scenarios_service/service.go @@ -14,8 +14,6 @@ import ( "strings" ) -// IScenariosRepository — граница доступа к данным сценариев, реализуемая -// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД). type IScenariosRepository interface { AddScenario(ctx context.Context, name string, authorID int) (int, error) GetScenariosByAuthorID(ctx context.Context, authorID int) ([]*repos.Scenario, error) @@ -130,32 +128,20 @@ func (s *ScenarioService) UpdateScenarioByID( return s.scenariosRepo.UpdateScenarioByID(ctx, id, name, description, image) } -// ErrScenarioPublished — опубликованный сценарий нельзя снять с публикации -// или удалить: при публикации исключительное право отчуждается Оператору -// (п. 7.2 Пользовательского соглашения, ст. 1234 ГК РФ), а право на отзыв -// действует только до обнародования (ст. 1269 ГК РФ). var ErrScenarioPublished = errors.New("Опубликованный сценарий нельзя снять с публикации или удалить: исключительное право отчуждено Оператору") -// ErrScenarioNotOwner — сценарий принадлежит другому пользователю. var ErrScenarioNotOwner = errors.New("Сценарий принадлежит другому пользователю") -// ErrScenarioNotFound — сценарий не найден или уже удалён. var ErrScenarioNotFound = errors.New("Сценарий не найден") -// ErrInvalidArchive — архив не соответствует формату сценария. var ErrInvalidArchive = errors.New("Некорректный архив сценария") -// ErrInvalidScenarioData — данные истории сценария не проходят валидацию. var ErrInvalidScenarioData = errors.New("Некорректные данные сценария") -// MaxArchiveSize возвращает максимальный размер загружаемого архива. func MaxArchiveSize() int { return scenario_archive.MaxArchiveSize() } -// getScenarioForChange проверяет права актора на изменение сценария: -// не-админ должен быть автором сценария, сценарий не должен быть удалён. -// Возвращает сценарий, чтобы вызывающий код не делал повторное чтение. func (s *ScenarioService) getScenarioForChange( ctx context.Context, id int, @@ -198,15 +184,11 @@ func (s *ScenarioService) UpdateScenarioStatusByID( return err } if !isAdmin && scenario.Status == "public" { - // Повторная публикация уже опубликованного — идемпотентный успех - // (published_at не двигается: CASE WHEN published_at IS NULL). if status == "public" { return nil } return ErrScenarioPublished } - // Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не - // сможет перевести в draft сценарий, опубликованный между чтением и UPDATE. updated, err := s.scenariosRepo.UpdateScenarioStatusByID(ctx, id, status, isAdmin) if err != nil { return err @@ -227,8 +209,6 @@ func (s *ScenarioService) DeleteScenarioByID( if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil { return err } - // Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не - // сможет удалить сценарий, опубликованный между чтением и UPDATE. updated, err := s.scenariosRepo.DeleteScenarioByID(ctx, id, isAdmin) if err != nil { return err @@ -310,7 +290,6 @@ func (s *ScenarioService) DeleteScenarioPlace( return s.updateStory(ctx, id, story) } -// UpdateScenarioBackground устанавливает фон; пустое значение снимает его с дела. func (s *ScenarioService) UpdateScenarioBackground(ctx context.Context, id int, background string, actorID int, isAdmin bool) error { if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil { return err @@ -326,7 +305,6 @@ func (s *ScenarioService) UpdateScenarioBackground(ctx context.Context, id int, return s.updateStory(ctx, id, story) } -// UpdateScenarioIntro сохраняет введение сценария (текст и аудио). func (s *ScenarioService) UpdateScenarioIntro( ctx context.Context, id int, @@ -358,7 +336,6 @@ func (s *ScenarioService) UpdateScenarioConclusion(ctx context.Context, id int, return s.updateStory(ctx, id, story) } -// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария. func (s *ScenarioService) AddScenarioQuestion( ctx context.Context, id int, @@ -377,8 +354,6 @@ func (s *ScenarioService) AddScenarioQuestion( return s.updateStory(ctx, id, story) } -// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода -// оставляет строки team_answers старого кода в БД func (s *ScenarioService) UpdateScenarioQuestion( ctx context.Context, id int, @@ -403,7 +378,6 @@ func (s *ScenarioService) UpdateScenarioQuestion( return s.updateStory(ctx, id, story) } -// DeleteScenarioQuestion удаляет вопрос дела по его коду. func (s *ScenarioService) DeleteScenarioQuestion( ctx context.Context, id int, @@ -435,7 +409,6 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S return mapStory(storyString, s.domain) } -// DownloadArchive собирает ZIP-архив сценария func (s *ScenarioService) DownloadArchive( ctx context.Context, id int, @@ -472,9 +445,6 @@ func (s *ScenarioService) DownloadArchive( return data, scenario.Name, nil } -// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются -// под новыми случайными именами (не перетирают чужие файлы), ссылки -// переписываются. Новый сценарий всегда draft — статус из архива игнорируется. func (s *ScenarioService) UploadArchive( ctx context.Context, data []byte, @@ -486,7 +456,6 @@ func (s *ScenarioService) UploadArchive( } scenario := bundle.Scenario - // При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов. uploaded := map[string]string{} ok := false defer func() { @@ -499,7 +468,6 @@ func (s *ScenarioService) UploadArchive( }() rewrite := func(ref string) (string, error) { - // Берём только ссылки images/... из архива; остальное — как есть. archiveRef := strings.TrimPrefix(ref, s.domain) if !strings.HasPrefix(archiveRef, "images/") { return ref, nil @@ -570,7 +538,6 @@ func (s *ScenarioService) UploadArchive( return 0, err } if err := s.scenariosRepo.UpdateScenarioByID(ctx, id, scenario.Name, scenario.Description, image); err != nil { - // Зачищаем строку сценария (best-effort), как и файлы. _, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true) return 0, err } @@ -604,9 +571,6 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString) } -// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды, -// нормализует file_type улик и возвращает JSON истории. -// Общая для редактирования и импорта. func normalizeStory(story *storytelling.Story) (string, error) { if story.Conclusion != nil && story.Conclusion.Video != "" && file_storage.FileType(story.Conclusion.Video) != "video" { return "", errors.New("видео закрывашки должно быть в формате MP4 или WebM") @@ -625,16 +589,12 @@ func normalizeStory(story *storytelling.Story) (string, error) { continue } for _, application := range place.Applications { - // file_type принимает только известные значения; невалидные - // (включая значения из импортированных архивов) заменяются - // деривацией из расширения файла. application.FileType = normalizeFileType(application.FileType, application.Image) } cleanPlaces = append(cleanPlaces, place) } story.Places = cleanPlaces - // Коды вопросов уникальны, пустые коды отбрасываются (как у точек). questionCodes := map[string]struct{}{} for _, question := range story.Questions { questionCodes[question.Code] = struct{}{} @@ -654,10 +614,6 @@ func normalizeStory(story *storytelling.Story) (string, error) { return convertStory(story) } -// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}. -// Пустое/невалидное значение при сохранении заменяется деривацией из -// расширения файла; пустое значение в legacy-строках деривируется при -// чтении (mapStory). func normalizeFileType(fileType, image string) string { switch fileType { case "pdf", "image", "audio": diff --git a/internal/services/scenarios_service/service_test.go b/internal/services/scenarios_service/service_test.go index 7290ea7..aff6abc 100644 --- a/internal/services/scenarios_service/service_test.go +++ b/internal/services/scenarios_service/service_test.go @@ -92,7 +92,6 @@ func TestConclusionLifecycle(t *testing.T) { } } -// fakeStorage — in-memory реализация IFileStorage. type fakeStorage struct { mu sync.Mutex files map[string]*file_storage.File @@ -148,7 +147,6 @@ func (s *fakeStorage) names() []string { return res } -// fakeScenariosRepo — минимальная in-memory реализация IScenariosRepository. type fakeScenariosRepo struct { mu sync.Mutex byID map[int]*repos.Scenario @@ -316,7 +314,6 @@ func TestPublishDraftScenario(t *testing.T) { } } -// seedScenario кладёт в repo сценарий с историей и изображениями в storage. func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario { t.Helper() description := "Детективная история" @@ -345,14 +342,12 @@ func TestUploadArchive(t *testing.T) { srcStorage := newFakeStorage() seedScenario(t, repo, srcStorage) - // Собираем архив как при скачивании (Pack через storage). svc := newTestService(repo, srcStorage) archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false) if err != nil { t.Fatalf("DownloadArchive: %v", err) } - // Импортируем в «чистые» repo и storage. importRepo := newFakeScenariosRepo() importStorage := newFakeStorage() importSvc := newTestService(importRepo, importStorage) @@ -382,7 +377,6 @@ func TestUploadArchive(t *testing.T) { t.Errorf("Author = %+v, want id 42", saved.Author) } - // Ссылки переписаны на новые имена без префикса images/. if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" { t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image) } @@ -400,12 +394,10 @@ func TestUploadArchive(t *testing.T) { if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") { t.Errorf("application image = %+v, want новое имя", club.Applications) } - // Одна и та же картинка в двух точках — одно новое имя. if story.Places[1].Image != club.Image { t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image) } - // Файлы загружены в storage под новыми именами с правильным содержимым. files := importStorage.names() if len(files) != 3 { t.Fatalf("storage files = %v, want 3", files) @@ -446,8 +438,6 @@ func TestUploadArchiveWithIntro(t *testing.T) { _ = srcStorage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")}) _ = srcStorage.Put(context.Background(), &file_storage.File{Name: "intro.mp3", Data: []byte("intro-audio-bytes")}) - // Экспорт (Pack кладёт аудио введения в архив), затем импорт в «чистые» - // repo и storage: ссылки должны быть переписаны на новые имена. svc := newTestService(repo, srcStorage) archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false) if err != nil { @@ -478,7 +468,6 @@ func TestUploadArchiveWithIntro(t *testing.T) { t.Errorf("Introduction.Audio = %q, want новое случайное имя без images/", story.Introduction.Audio) } - // Аудио введения загружено в storage под новым именем. files := importStorage.names() found := false for _, name := range files { @@ -500,7 +489,6 @@ func TestUploadArchiveMissingImage(t *testing.T) { storage := newFakeStorage() svc := newTestService(repo, storage) - // Архив ссылается на images/missing.png, файла в архиве нет. archive := buildRawArchive(t, map[string][]byte{ scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{ Version: scenario_archive.Version, @@ -564,7 +552,6 @@ func TestUploadArchiveCleanupOnRepoError(t *testing.T) { if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil { t.Fatal("UploadArchive должен вернуть ошибку репозитория") } - // Загруженные файлы удалены (best-effort), orphan-объектов не осталось. if len(storage.names()) != 0 { t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names()) } @@ -589,7 +576,6 @@ func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) { if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil { t.Fatal("UploadArchive должен вернуть ошибку репозитория") } - // Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены. if got := repo.get(1); got == nil || !got.IsDeleted { t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got) } @@ -638,7 +624,6 @@ func TestDownloadArchiveNotOwner(t *testing.T) { if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) { t.Errorf("err = %v, want ErrScenarioNotOwner", err) } - // Админ может скачать чужой сценарий. if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil { t.Errorf("админ: err = %v", err) } @@ -672,7 +657,6 @@ func TestUpdateScenarioIntro(t *testing.T) { seedScenario(t, repo, storage) svc := newTestService(repo, storage) - // Установка введения автором. err := svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{ Text: "Введение", Audio: "intro.mp3", @@ -681,7 +665,6 @@ func TestUpdateScenarioIntro(t *testing.T) { t.Fatalf("UpdateScenarioIntro: %v", err) } - // В хранилище — голое имя аудио, без домена. parsed := &storytelling.Story{} if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil { t.Fatalf("json.Unmarshal: %v", err) @@ -696,7 +679,6 @@ func TestUpdateScenarioIntro(t *testing.T) { t.Errorf("Introduction.Audio = %q, want %q (без домена)", parsed.Introduction.Audio, "intro.mp3") } - // Наружу — аудио с доменом. full, err := svc.GetFullScenarioByID(context.Background(), 1) if err != nil { t.Fatalf("GetFullScenarioByID: %v", err) @@ -708,7 +690,6 @@ func TestUpdateScenarioIntro(t *testing.T) { t.Errorf("Introduction.Audio = %q, want %q", full.Story.Introduction.Audio, testDomain+"intro.mp3") } - // Редактирование точки не должно затрагивать введение и голое имя аудио. err = svc.UpdateScenarioPlace(context.Background(), 1, "club", &storytelling.Place{ Code: "club", Name: "Клуб", @@ -725,7 +706,6 @@ func TestUpdateScenarioIntro(t *testing.T) { t.Errorf("введение повреждено после UpdateScenarioPlace: %+v", parsed.Introduction) } - // Очистка введения (nil) — поле исчезает из истории. err = svc.UpdateScenarioIntro(context.Background(), 1, nil, 7, false) if err != nil { t.Fatalf("UpdateScenarioIntro(nil): %v", err) @@ -738,14 +718,12 @@ func TestUpdateScenarioIntro(t *testing.T) { t.Errorf("Introduction = %+v, want nil после очистки", parsed.Introduction) } - // Чужой автор не может менять введение. err = svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{Text: "x"}, 99, false) if !errors.Is(err, ErrScenarioNotOwner) { t.Errorf("err = %v, want ErrScenarioNotOwner", err) } } -// buildRawArchive собирает zip из произвольных записей (для тестов импорта). func buildRawArchive(t *testing.T, entries map[string][]byte) []byte { t.Helper() var buf bytes.Buffer @@ -780,7 +758,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) { seedScenario(t, repo, storage) svc := newTestService(repo, storage) - // Добавление вопроса автором. err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{ Code: "q1", Text: "Кто убийца?", @@ -805,7 +782,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) { if len(full.Story.Questions) != 2 { t.Fatalf("len(Questions) = %d, want 2", len(full.Story.Questions)) } - // Вопросы добавляются в конец списка: q1 добавлен первым. if full.Story.Questions[0].Code != "q1" || full.Story.Questions[0].Answer != "Дворецкий" { t.Errorf("Questions[0] = %+v, want q1 с правильным ответом", full.Story.Questions[0]) } @@ -813,7 +789,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) { t.Errorf("Questions[1] = %+v, want q2 с правильным ответом", full.Story.Questions[1]) } - // Обновление вопроса по коду. err = svc.UpdateScenarioQuestion(context.Background(), 1, "q1", &storytelling.Question{ Code: "q1", Text: "Кто убийца на самом деле?", @@ -839,7 +814,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) { t.Error("вопрос q1 не найден после обновления") } - // Удаление вопроса. err = svc.DeleteScenarioQuestion(context.Background(), 1, "q2", 7, false) if err != nil { t.Fatalf("DeleteScenarioQuestion: %v", err) @@ -852,7 +826,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) { t.Errorf("Questions = %+v, want только q1", full.Story.Questions) } - // Чужой автор не может менять вопросы. err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q3"}, 99, false) if !errors.Is(err, ErrScenarioNotOwner) { t.Errorf("AddScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err) @@ -862,7 +835,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) { t.Errorf("DeleteScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err) } - // Админ может менять вопросы чужого сценария. err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{ Code: "q3", Text: "Вопрос админа", }, 1, true) @@ -877,7 +849,6 @@ func TestNormalizeStoryQuestionCodes(t *testing.T) { seedScenario(t, repo, storage) svc := newTestService(repo, storage) - // Дубликат кода вопроса — ошибка. err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false) if err != nil { t.Fatalf("AddScenarioQuestion q1: %v", err) @@ -887,7 +858,6 @@ func TestNormalizeStoryQuestionCodes(t *testing.T) { t.Errorf("AddScenarioQuestion с дублем кода: err = %v, want ошибку о дубле", err) } - // Пустой код — вопрос отбрасывается при сохранении. err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "", Text: "без кода"}, 7, false) if err != nil { t.Fatalf("AddScenarioQuestion с пустым кодом: %v", err) diff --git a/internal/services/team_service/registration.go b/internal/services/team_service/registration.go index 4e8e95f..a1f65e2 100644 --- a/internal/services/team_service/registration.go +++ b/internal/services/team_service/registration.go @@ -11,14 +11,10 @@ import ( ) var ( - // ErrInvalidCaptainEmail — почта капитана команды некорректна. ErrInvalidCaptainEmail = errors.New("Укажите корректную почту капитана") - // ErrTeamNotFound — команда не найдена. - ErrTeamNotFound = errors.New("Команда не найдена") - // ErrInvalidRegistration — входные данные регистрации команды некорректны. + ErrTeamNotFound = errors.New("Команда не найдена") ErrInvalidRegistration = errors.New("Укажите название команды (до 255 символов), почту капитана и положительное количество участников") - // ErrRegistrationClosed — игра не найдена или регистрация в неё закрыта. - ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта") + ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта") ) // Registration contains data required to register a team. diff --git a/internal/services/team_service/team_qr.go b/internal/services/team_service/team_qr.go index 6c8dac8..7c2ee1d 100644 --- a/internal/services/team_service/team_qr.go +++ b/internal/services/team_service/team_qr.go @@ -12,10 +12,8 @@ import ( ) var ( - // ErrGameNotFound — игра для генерации QR-кодов не найдена. ErrGameNotFound = errors.New("Игра не найдена") - // ErrNoCards — для игры нет активных команд, которым нужны QR-коды. - ErrNoCards = errors.New("нет карточек") + ErrNoCards = errors.New("нет карточек") ) func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_qr_pdf.Card, string, error) { diff --git a/internal/services/users_service/change_password_test.go b/internal/services/users_service/change_password_test.go index f9c5aac..4b376bd 100644 --- a/internal/services/users_service/change_password_test.go +++ b/internal/services/users_service/change_password_test.go @@ -48,8 +48,6 @@ func TestChangeUserPassword(t *testing.T) { r.byID[u.ID] = u r.users[u.Email] = u } - // Пустые JWT, репозиторий токенов и отправитель писем подтверждают, - // что операция их не использует. s := &UsersService{usersRepo: r} err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password) if !errors.Is(err, tc.want) { @@ -91,7 +89,6 @@ func TestLoginAfterPasswordChangePreservesEmailCase(t *testing.T) { if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil { t.Fatal(err) } - // Эта ошибка возможна только после успешного поиска email и проверки bcrypt. _, _, err := svc.Login(ctx, " User@Example.COM ", "new password 123") if err == nil || err.Error() != "Не получилось получить доступ" { t.Fatalf("lookup/password verification failed: %v", err) diff --git a/internal/services/users_service/emails.go b/internal/services/users_service/emails.go index 1fe23c4..8d9b16b 100644 --- a/internal/services/users_service/emails.go +++ b/internal/services/users_service/emails.go @@ -6,17 +6,11 @@ import ( "strings" ) -// Письма содержат текстовую и HTML-версию; sender собирает multipart/alternative. - const ( - // welcomeSubject — тема письма о регистрации: конкретика без КАПСА и - // кликбейта, до 50 символов (видно на мобильных целиком). welcomeSubject = "Твой доступ к «Вечернему детективу»" - // resetSubject — тема письма о сбросе пароля. - resetSubject = "Новый пароль для «Вечернего детектива»" + resetSubject = "Новый пароль для «Вечернего детектива»" ) -// welcomePlain возвращает текстовую версию письма о регистрации. func welcomePlain(username, email, password, loginURL string) string { var b strings.Builder b.WriteString(greeting(username)) @@ -39,7 +33,6 @@ func welcomePlain(username, email, password, loginURL string) string { return sanitizeLine(b.String()) } -// welcomeHTML возвращает HTML-версию письма о регистрации. func welcomeHTML(username, email, password, loginURL string) string { var content strings.Builder content.WriteString("

Привет, ") @@ -54,7 +47,6 @@ func welcomeHTML(username, email, password, loginURL string) string { return wrapHTML(content.String(), loginURL) } -// resetPlain возвращает текстовую версию письма о сбросе пароля. func resetPlain(username, email, password, loginURL string) string { var b strings.Builder b.WriteString(greeting(username)) @@ -77,7 +69,6 @@ func resetPlain(username, email, password, loginURL string) string { return sanitizeLine(b.String()) } -// resetHTML возвращает HTML-версию письма о сбросе пароля. func resetHTML(username, email, password, loginURL string) string { var content strings.Builder content.WriteString("

Привет, ") @@ -93,8 +84,6 @@ func resetHTML(username, email, password, loginURL string) string { return wrapHTML(content.String(), loginURL) } -// greeting возвращает приветствие с именем пользователя; при пустом имени — -// без обращения, чтобы не было «Привет, !». func greeting(username string) string { username = sanitizeLine(strings.TrimSpace(username)) if username == "" { @@ -103,8 +92,6 @@ func greeting(username string) string { return fmt.Sprintf("Привет, %s!", username) } -// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке, -// совместимой с Gmail. func credentialsBlockHTML(email, password string) string { return ` @@ -114,7 +101,6 @@ func credentialsBlockHTML(email, password string) string {
` } -// buttonHTML — кнопка входа с inline-стилями и табличным фоном. func buttonHTML(link, label string) string { return ` @@ -125,8 +111,6 @@ func buttonHTML(link, label string) string {
` } -// wrapHTML — светлая палитра по умолчанию, тёмная через media query. -// Клиенты без поддержки CSS темы могут перекрашивать обычные цвета сами. func wrapHTML(content, baseURL string) string { return ` @@ -181,7 +165,6 @@ func wrapHTML(content, baseURL string) string { ` } -// Шрифт находится в public/email фронтенда. func emailAssetURL(baseURL, filename string) string { if baseURL == "" { return "" @@ -189,15 +172,10 @@ func emailAssetURL(baseURL, filename string) string { return html.EscapeString(strings.TrimRight(baseURL, "/") + "/email/" + filename) } -// sanitizeLine убирает переносы строк из пользовательского ввода, -// вставляемого в тело письма (защита от инъекции строк в DATA-фазу SMTP). func sanitizeLine(s string) string { return strings.NewReplacer("\r", "", "\n", "").Replace(s) } -// loginURL нормализует адрес фронтенда для ссылки в письме: срезает -// завершающий слэш и допускает только http(s). Пустое или некорректное -// значение — ссылка в письма не добавляется. func loginURL(baseURL string) string { baseURL = strings.TrimSpace(baseURL) baseURL = strings.TrimRight(baseURL, "/") diff --git a/internal/services/users_service/emails_test.go b/internal/services/users_service/emails_test.go index e602e00..a7584b8 100644 --- a/internal/services/users_service/emails_test.go +++ b/internal/services/users_service/emails_test.go @@ -40,7 +40,7 @@ func TestEmailThemeAndContent(t *testing.T) { } } if n.Type == html.ElementNode && n.Data == "a" { - protected = true // У кнопки собственные согласованные цвета фона и текста. + protected = true } if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" { if !protected { diff --git a/internal/services/users_service/service.go b/internal/services/users_service/service.go index 29d3027..7dc1742 100644 --- a/internal/services/users_service/service.go +++ b/internal/services/users_service/service.go @@ -22,19 +22,12 @@ import ( "golang.org/x/crypto/bcrypt" ) -// Версии документов, акцепт которых фиксируется. Должны совпадать с -// редакциями, опубликованными по постоянным URL (/api/terms и /api/privacy, -// rpc GetTerms/GetPrivacy, текст встраивается из пакета docs): при изменении -// текста документа версия ОБЯЗАНА увеличиваться, иначе повторный акцепт -// не будет зафиксирован как согласие с новой редакцией // (UNIQUE (user_id, agreement_type, version)). const ( termsVersion = "1.0" privacyVersion = "1.0" ) -// usersRepository — граница доступа к данным пользователей, реализуемая -// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД. type usersRepository interface { AddUserWithAgreements( ctx context.Context, @@ -60,9 +53,7 @@ type UsersService struct { emailSender email_sender.IEmailSender processorJWT processor_jwt.IProcessorJWT refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo - // appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в - // письмах; пустое значение — ссылка не добавляется. - appBaseURL string + appBaseURL string } // NewUsersService creates a users service. @@ -85,7 +76,6 @@ func NewUsersService( } var ( - // ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений. ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности") // ErrInvalidEmail indicates that an email address is invalid. ErrInvalidEmail = errors.New("Некорректный email") @@ -126,8 +116,6 @@ func (s *UsersService) AddUser( if err != nil { return err } - // Создание пользователя и фиксация акцептов — в одной транзакции: - // без записей о согласиях пользователь не существует. id, err := s.usersRepo.AddUserWithAgreements( ctx, username, @@ -153,12 +141,6 @@ func (s *UsersService) AddUser( return err } - // Консистентность ответа и состояния БД: если письмо с паролем не - // ушло, пользователь не должен оставаться в системе «полусозданным» - // (клиент получит ошибку и повторит регистрацию). Компенсация — - // удаление созданного пользователя. Выполняется вне отменённого - // контекста: письмо могло упасть именно из-за истечения таймаута ctx, - // и тогда тот же ctx не дал бы выполнить удаление. err = s.emailSender.Send(ctx, email_sender.Message{ To: email, Subject: welcomeSubject, @@ -175,9 +157,6 @@ func (s *UsersService) AddUser( return nil } -// DeleteAccount удаляет учётную запись и все персональные данные пользователя -// (ст. 21 152-ФЗ). Требует подтверждения паролем: обладатель украденного -// access-токена не должен мочь удалить чужой аккаунт. func (s *UsersService) DeleteAccount( ctx context.Context, userID int, @@ -210,8 +189,6 @@ func (s *UsersService) RefreshPassword( return err } - // Анти-enumeration: ответ одинаков для существующего и несуществующего - // адреса, письмо отправляется только реальному пользователю. user, err := s.usersRepo.GetUserByEmail(ctx, email) if err != nil { if errors.Is(err, users_repo.ErrUserNotFound) { @@ -229,9 +206,6 @@ func (s *UsersService) RefreshPassword( return err } - // Письмо отправляется ДО смены пароля: при сбое отправки пароль - // остаётся прежним и аккаунт не блокируется (иначе старый пароль уже - // не работает, а новый пользователь так и не узнал). err = s.emailSender.Send(ctx, email_sender.Message{ To: user.Email, Subject: resetSubject, @@ -245,7 +219,6 @@ func (s *UsersService) RefreshPassword( return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword)) } -// validateEmail проверяет, что строка является одиночным email-адресом. func validateEmail(email string) error { if email == "" { return ErrInvalidEmail @@ -254,7 +227,6 @@ func validateEmail(email string) error { if err != nil { return ErrInvalidEmail } - // Отклоняем форму "Имя " и всё, что не является чистым адресом. if !strings.EqualFold(addr.Address, email) { return ErrInvalidEmail } @@ -267,7 +239,6 @@ func (s *UsersService) Login( email string, password string, ) (string, string, error) { - // Сохраняем регистр email, как при регистрации; убираем только внешние пробелы. user, err := s.usersRepo.GetUserByEmail( ctx, strings.TrimSpace(email), @@ -418,11 +389,9 @@ var ( ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор") // ErrInvalidPasswordChange indicates that the password change request is invalid. ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов") - // ErrUserNotFound — целевой пользователь не найден. - ErrUserNotFound = errors.New("Пользователь не найден") + ErrUserNotFound = errors.New("Пользователь не найден") ) -// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются. // ChangeUserPassword changes a user's password subject to authorization checks. func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error { actor, err := s.usersRepo.GetUserByID(ctx, actorID) diff --git a/internal/services/users_service/service_test.go b/internal/services/users_service/service_test.go index 521baa4..706454b 100644 --- a/internal/services/users_service/service_test.go +++ b/internal/services/users_service/service_test.go @@ -36,8 +36,6 @@ func (f *fakeSender) sent() []email_sender.Message { return append([]email_sender.Message(nil), f.messages...) } -// fakeRepo — минимальная in-memory реализация usersRepository для тестов -// сервиса без БД. type fakeRepo struct { mu sync.Mutex users map[string]*repos.User @@ -149,8 +147,6 @@ func newTestService(repo usersRepository, sender email_sender.IEmailSender) *Use } func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService { - // Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих - // тестах не используется. pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none") if err != nil { panic(err) @@ -175,7 +171,6 @@ func TestAddUserSuccess(t *testing.T) { t.Fatalf("AddUser: %v", err) } - // Убираем внешние пробелы, сохраняя исходный регистр email. if _, ok := repo.users["User@Example.COM"]; !ok { t.Errorf("пользователь не создан с исходным регистром email: %+v", repo.users) } @@ -196,8 +191,6 @@ func TestAddUserSuccess(t *testing.T) { if msgs[0].HTML == "" { t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма") } - // APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется, - // но каркас письма (шапка сервиса) обязан присутствовать. if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") { t.Error("HTML-версия письма не содержит шапку сервиса") } @@ -220,7 +213,6 @@ func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) { if len(msgs) != 1 { t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs)) } - // Завершающий слэш срезается, ссылка есть и в plain, и в HTML-версии. if !strings.Contains(msgs[0].Body, "https://evening-detective.crabs-games.art") { t.Errorf("plain-версия не содержит ссылку на вход:\n%s", msgs[0].Body) } @@ -250,7 +242,6 @@ func TestAddUserSendFailureCompensates(t *testing.T) { t.Fatal("ожидалась ошибка при сбое отправки письма") } - // Компенсация: клиенту ошибка, пользователя в БД нет. if repo.userCount() != 0 { t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users) } @@ -288,7 +279,6 @@ func TestRefreshPasswordSuccess(t *testing.T) { sender := &fakeSender{} svc := newTestService(repo, sender) - // Внешние пробелы убираются, регистр остаётся исходным. err := svc.RefreshPassword(context.Background(), " User@Example.COM ") if err != nil { t.Fatalf("RefreshPassword: %v", err) @@ -325,7 +315,6 @@ func TestRefreshPasswordUnknownEmail(t *testing.T) { sender := &fakeSender{} svc := newTestService(repo, sender) - // Анти-enumeration: для несуществующего адреса — успех и без письма. if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil { t.Fatalf("для несуществующего email ожидался успех, получено %v", err) } diff --git a/internal/services/users_service/user.go b/internal/services/users_service/user.go index 43fdaea..8a05946 100644 --- a/internal/services/users_service/user.go +++ b/internal/services/users_service/user.go @@ -2,7 +2,6 @@ package users_service import "time" -// User — пользователь, доступный прикладным сценариям и выдаче наружу. // User describes a user exposed by the service layer. type User struct { ID int