diff --git a/.dsh/skills/create-go-module/SKILL.md b/.dsh/skills/create-go-module/SKILL.md new file mode 100644 index 0000000..b8cf546 --- /dev/null +++ b/.dsh/skills/create-go-module/SKILL.md @@ -0,0 +1,94 @@ +--- +name: create-go-module +description: Создаёт Go-модуль в internal/modules// по конвенциям репозитория — обязательный interface.go с интерфейсом (имя начинается с I) и всеми публичными типами, в папке как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор New*, возвращающий интерфейс). Загружай при создании нового модуля или заготовки модуля. +whenToUse: Пользователь просит создать модуль/компонент/сервис/заготовку в internal/modules, либо «сделать по конвенциям проекта», либо явно упоминает interface.go или интерфейс с префиксом I. +--- + +# Создание Go-модуля (internal/modules) + +## Контекст + +В этом проекте переиспользуемые компоненты живут в `internal/modules/<имя>/` — по одному модулю на папку. Публичный контракт модуля описывается в **обязательном** файле `interface.go`. Эталонные примеры действующих модулей: `internal/modules/password_generator`, `internal/modules/email_sender`, `internal/modules/cleaner`, `internal/modules/storytelling`, `internal/modules/processor_jwt`. + +## Обязательные правила + +1. **Папка модуля** — `internal/modules//` (например, `email_sender`). Имя Go-пакета равно имени папки. Никаких вложенных подпакетов внутри модуля. +2. **Файл `interface.go` обязателен.** В нём живёт ВЕСЬ публичный контракт модуля: + - интерфейс(ы) модуля; + - публичные типы: структуры-данные (DTO), перечисления, константы, именованные ошибки. + Ничего другого (реализация, приватные хелперы) в этом файле нет. +3. **Имя интерфейса начинается с `I`** — `I`, например `IEmailSender`, `IPasswordGenerator`, `ICleaner`, `IStory`. Интерфейс без префикса `I` — недопустимо. В интерфейсе — только сигнатуры методов, без полей. +4. **Реализация — в отдельном файле** (например, `<короткое_имя>.go` или `service.go`): неэкспортируемая структура (строчная, например `type emailSender struct`), методы которой реализуют интерфейс, и конструктор `func New(...) I`, возвращающий **интерфейс**, а не структуру. +5. **В папке модуля — как минимум одна реализация интерфейса.** Модуль не может состоять из одного `interface.go`: рядом с ним всегда лежит неэкспортируемая структура, реализующая `I` (см. правило 4), и конструктор, возвращающий интерфейс. Если реализаций несколько — каждая в своём файле. +6. **Сигнатуры методов**: операции с внешним миром (сеть, файлы, БД, таймеры) принимают `context.Context` первым аргументом и возвращают `error`; чистые вычисления — без контекста (пример: `Generate() (string, error)` у `password_generator`). +7. **Комментарии — на русском.** Каждый публичный идентификатор имеет doc-комментарий, начинающийся с имени символа. Пояснения сложных мест — тоже по-русски. +8. **Ошибки** оборачиваются через `fmt.Errorf(": ...: %w", err)` — префикс с именем модуля. +9. **JSON-теги** у сериализуемых структур — `json:"..."` (эталон: `storytelling/interface.go`). +10. **Зависимости между модулями**: вместо импорта чужого модуля объявляется минимальный локальный интерфейс в файле `dependency.go` (эталон: `internal/modules/storytelling/dependency.go` объявляет собственный `ICleaner`). Это исключает циклические импорты и связывает модули только через их интерфейсы. +11. **Тесты** — для модуля желателен файл `*_test.go` рядом с реализацией (примеры: `email_sender/sender_test.go`, `cleaner/service_test.go`). + +## Шаги + +1. **Определи имя модуля.** Если имя неоднозначно — уточни у пользователя. Приведи имя к двум формам: + - snake_case — папка и пакет: «генератор паролей» → `password_generator`; + - CamelCase — имена типов и интерфейса: `PasswordGenerator` → `IPasswordGenerator`. +2. **Создай папку** `internal/modules//`. +3. **Напиши `interface.go`** (обязательно): сначала публичные типы/константы, затем интерфейс `I`. +4. **Напиши файл(ы) реализации** (обязательно, минимум один) — неэкспортируемая структура + конструктор `New(...) I`. +5. **При необходимости** добавь `dependency.go` (локальные интерфейсы зависимостей) и `*_test.go`. +6. **Проверь сборку** из корня проекта: `go build ./...` и `go vet ./...`. Ошибки — исправь. + +## Шаблон interface.go + +```go +package + +import "context" + +// — описание публичного типа. +type struct { + Field string `json:"field"` +} + +// I — контракт модуля . +type I interface { + // — описание метода. + (ctx context.Context, in ) error +} +``` + +## Шаблон реализации + +```go +package + +import ( + "context" + "fmt" +) + +// — реализация I. +type struct { + // внутренние поля +} + +// New создаёт реализацию I. +func New(...) I { + return &{} +} + +func (s *) (ctx context.Context, in ) error { + // TODO: реализация + return fmt.Errorf(": не реализовано") +} +``` + +## Критерии готовности + +- [ ] Папка `internal/modules//` создана, имя пакета равно имени папки. +- [ ] `interface.go` существует; все публичные типы, константы и интерфейсы описаны именно в нём. +- [ ] Имя интерфейса начинается с `I`. +- [ ] В папке модуля есть как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор). +- [ ] Реализация неэкспортируемая; конструктор возвращает интерфейс, а не структуру. +- [ ] doc-комментарии на русском у всех публичных идентификаторов. +- [ ] `go build ./...` и `go vet ./...` проходят без ошибок. diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 890e040..08fecd3 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index ae7bc10..6940b25 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -11,6 +11,7 @@ import ( "evening_detective_server/internal/modules/processor_jwt" "evening_detective_server/internal/modules/scenario_archive" "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/modules/string_tools" "evening_detective_server/internal/repos/actions_repo" "evening_detective_server/internal/repos/applications_repo" "evening_detective_server/internal/repos/games_repo" @@ -90,7 +91,7 @@ func main() { if err != nil { log.Fatalf("Unable to create connection rustfs: %v\n", err) } - fileService := file_service.NewFileService(fileStorage) + fileService := file_service.NewFileService(fileStorage, string_tools.NewStringTools()) scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool) cleaner := cleaner.NewCleaner() scenarioService := scenarios_service.NewScenarioService( @@ -98,6 +99,7 @@ func main() { cleaner, os.Getenv("FILE_PREFIX_DOMAIN"), fileStorage, + scenario_archive.NewScenarioArchive(), ) gameRepo := games_repo.NewGamesRepo(dbpool) teamRepo := teams_repo.NewTeamsRepo(dbpool) diff --git a/internal/app/server.go b/internal/app/server.go index 65acbdd..4703dea 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -28,6 +28,9 @@ import ( "google.golang.org/grpc/status" ) +// stringTools — транслитерация имён файлов для HTTP-заголовков. +var stringTools = string_tools.NewStringTools() + type server struct { proto.UnsafeEveningDetectiveServerServer @@ -603,7 +606,7 @@ func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.Downloa // Имя файла транслитерируется (заголовки HTTP — только ASCII) и // санитайзится от header injection; заголовок пробрасывается gateway. - transliterated := sanitizeFilename(string_tools.Transliterate(name)) + transliterated := sanitizeFilename(stringTools.Transliterate(name)) if transliterated == "" { transliterated = "scenario" } diff --git a/internal/app/story_mapper.go b/internal/app/story_mapper.go index 0b9b7a5..865b958 100644 --- a/internal/app/story_mapper.go +++ b/internal/app/story_mapper.go @@ -9,6 +9,9 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// textFormatter — форматирование текста при конвертации входящих данных. +var textFormatter = formatter_utils.NewFormatter() + func mapScenarios(o []*scenarios_service.Scenario) []*proto.Scenario { res := make([]*proto.Scenario, 0, len(o)) for _, item := range o { @@ -117,8 +120,8 @@ func mapKey(o *storytelling.Key) *proto.Key { func convertPlace(o *proto.Place) *storytelling.Place { return &storytelling.Place{ Code: o.Code, - Name: formatter_utils.FormatString(o.Name), - Text: formatter_utils.FormatText(o.Text), + Name: textFormatter.FormatString(o.Name), + Text: textFormatter.FormatText(o.Text), Image: o.Image, Hidden: o.Hidden, Applications: convertApplications(o.Applications), @@ -153,7 +156,7 @@ func convertKeys(o []*proto.Key) []*storytelling.Key { func convertApplication(o *proto.Application) *storytelling.Application { return &storytelling.Application{ - Name: formatter_utils.FormatString(o.Name), + Name: textFormatter.FormatString(o.Name), Image: o.Image, } } @@ -161,13 +164,13 @@ func convertApplication(o *proto.Application) *storytelling.Application { func convertDoor(o *proto.Door) *storytelling.Door { return &storytelling.Door{ Code: o.Code, - Name: formatter_utils.FormatString(o.Name), + Name: textFormatter.FormatString(o.Name), Keys: convertKeys(o.Keys), } } func convertKey(o *proto.Key) *storytelling.Key { return &storytelling.Key{ - Name: formatter_utils.FormatString(o.Name), + Name: textFormatter.FormatString(o.Name), } } diff --git a/internal/modules/formatter_utils/interface.go b/internal/modules/formatter_utils/interface.go new file mode 100644 index 0000000..9c08f6b --- /dev/null +++ b/internal/modules/formatter_utils/interface.go @@ -0,0 +1,7 @@ +package formatter_utils + +// IFormatter — форматирование текста сценария для отображения. +type IFormatter interface { + FormatText(text string) string + FormatString(text string) string +} diff --git a/internal/modules/formatter_utils/service.go b/internal/modules/formatter_utils/service.go index 9a330de..7ffbece 100644 --- a/internal/modules/formatter_utils/service.go +++ b/internal/modules/formatter_utils/service.go @@ -5,7 +5,16 @@ import ( "strings" ) -func FormatText(text string) string { +type formatter struct{} + +// NewFormatter создаёт реализацию IFormatter. +func NewFormatter() IFormatter { + return &formatter{} +} + +// FormatText форматирует многострочный текст: обрезает пробелы по краям +// строк, превращает "--" в тире и добавляет отступ абзаца. +func (f *formatter) FormatText(text string) string { scanner := bufio.NewScanner(strings.NewReader(text)) scanner.Split(bufio.ScanLines) @@ -35,7 +44,9 @@ func FormatText(text string) string { return res.String() } -func FormatString(text string) string { +// FormatString форматирует одиночную строку: обрезает пробелы и заменяет +// "--" на тире. +func (f *formatter) FormatString(text string) string { l := strings.TrimSpace(text) if strings.HasPrefix(l, "--") { l = strings.Replace(l, "--", "—", 1) diff --git a/internal/modules/formatter_utils/service_test.go b/internal/modules/formatter_utils/service_test.go index 221af7b..3d1358f 100644 --- a/internal/modules/formatter_utils/service_test.go +++ b/internal/modules/formatter_utils/service_test.go @@ -29,9 +29,10 @@ func Test_service_FormatText(t *testing.T) { want: " — Привет", }, } + formatter := NewFormatter() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := FormatText(tt.text) + got := formatter.FormatText(tt.text) if got != tt.want { t.Errorf("FormatText() = %v, want %v", got, tt.want) } diff --git a/internal/modules/roles/interface.go b/internal/modules/roles/interface.go new file mode 100644 index 0000000..cfefd38 --- /dev/null +++ b/internal/modules/roles/interface.go @@ -0,0 +1,22 @@ +package roles + +// Роли пользователей системы. +const ( + Admin = "admin" + Author = "author" + Organizer = "organizer" + User = "user" +) + +// AllRoles — список всех ролей. +var AllRoles = []string{ + Admin, + Author, + Organizer, + User, +} + +// IHasRole — контракт субъекта, у которого можно проверить наличие роли. +type IHasRole interface { + HasRole(role string) bool +} diff --git a/internal/modules/roles/roles.go b/internal/modules/roles/roles.go index bc4ee3e..317f803 100644 --- a/internal/modules/roles/roles.go +++ b/internal/modules/roles/roles.go @@ -1,23 +1,7 @@ package roles -const ( - Admin = "admin" - Author = "author" - Organizer = "organizer" - User = "user" -) - -var AllRoles = []string{ - Admin, - Author, - Organizer, - User, -} - -type IHasRole interface { - HasRole(role string) bool -} - +// 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 new file mode 100644 index 0000000..ac1c113 --- /dev/null +++ b/internal/modules/roles/subject.go @@ -0,0 +1,19 @@ +package roles + +import "slices" + +// subject — реализация IHasRole: субъект с фиксированным набором ролей. +type subject struct { + roles []string +} + +// NewSubject создаёт субъект с заданными ролями (например, для тестов +// авторизации или in-memory проверок прав). +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/roles/subject_test.go b/internal/modules/roles/subject_test.go new file mode 100644 index 0000000..08ddc63 --- /dev/null +++ b/internal/modules/roles/subject_test.go @@ -0,0 +1,25 @@ +package roles + +import "testing" + +func Test_subject_HasRole(t *testing.T) { + subject := NewSubject(Author, Organizer) + + if !subject.HasRole(Author) { + t.Errorf("HasRole(%q) = false, want true", Author) + } + if !subject.HasRole(Organizer) { + t.Errorf("HasRole(%q) = false, want true", Organizer) + } + if subject.HasRole(Admin) { + t.Errorf("HasRole(%q) = true, want false", Admin) + } +} + +func Test_subject_HasRoleEmpty(t *testing.T) { + subject := NewSubject() + + if subject.HasRole(User) { + t.Errorf("HasRole(%q) = true, want false для субъекта без ролей", User) + } +} diff --git a/internal/modules/scenario_archive/archive.go b/internal/modules/scenario_archive/archive.go index 240d8ad..7d60b7a 100644 --- a/internal/modules/scenario_archive/archive.go +++ b/internal/modules/scenario_archive/archive.go @@ -20,12 +20,6 @@ import ( "evening_detective_server/internal/repos" ) -// Имя файла с описанием сценария внутри архива. -const FileName = "scenario.json" - -// Текущая версия формата scenario.json. -const Version = 1 - // Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается // по фактически распакованным байтам — размеры в заголовках ZIP подделываются. var ( @@ -36,28 +30,19 @@ var ( maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке ) +type scenarioArchive struct{} + +// NewScenarioArchive создаёт реализацию IScenarioArchive. +func NewScenarioArchive() IScenarioArchive { + return &scenarioArchive{} +} + // MaxArchiveSize — максимальный размер входящего архива (для проверок // в хендлере и на HTTP-слое). func MaxArchiveSize() int { return maxArchiveSize } -// ScenarioJSON — описание сценария в архиве; Story — в формате колонки -// scenarios.scenario в БД. -type ScenarioJSON struct { - Version int `json:"version"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Image string `json:"image,omitempty"` - Story *storytelling.Story `json:"story"` -} - -// Bundle — результат разбора входящего архива. -type Bundle struct { - Scenario *ScenarioJSON - Files map[string][]byte -} - // ImagePath возвращает путь изображения внутри архива (images/<имя>) и true // для ссылок нашего хранилища: относительное имя или URL с доменом (legacy). // Внешние URL и пустые ссылки — ("", false): в архив не кладутся. @@ -76,7 +61,7 @@ func ImagePath(ref, domain string) (string, bool) { // Pack собирает ZIP-архив сценария: ссылки на изображения хранилища // переписываются в images/<имя>, файлы запрашиваются через getFile; внешние // URL остаются без изменений. Архив детерминирован (порядок записей и метки). -func Pack( +func (a *scenarioArchive) Pack( ctx context.Context, scenario *repos.Scenario, domain string, @@ -223,7 +208,7 @@ func strPtrValue(p *string) string { } // Unpack разбирает входящий архив с проверкой лимитов и путей записей. -func Unpack(data []byte) (*Bundle, error) { +func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) { if len(data) == 0 { return nil, errors.New("архив пуст") } diff --git a/internal/modules/scenario_archive/archive_test.go b/internal/modules/scenario_archive/archive_test.go index 48ba065..30841aa 100644 --- a/internal/modules/scenario_archive/archive_test.go +++ b/internal/modules/scenario_archive/archive_test.go @@ -16,6 +16,9 @@ import ( const testDomain = "http://storage.test/api/files/" +// testArchive — экземпляр модуля для тестов. +var testArchive = NewScenarioArchive() + // buildScenario собирает сценарий с историей и ссылками на изображения. func buildScenario() *repos.Scenario { description := "Детективная история" @@ -53,12 +56,12 @@ func newMemoryFiles() memoryFiles { func TestPackUnpackRoundTrip(t *testing.T) { files := newMemoryFiles() - data, err := Pack(context.Background(), buildScenario(), testDomain, files.get) + data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get) if err != nil { t.Fatalf("Pack: %v", err) } - bundle, err := Unpack(data) + bundle, err := testArchive.Unpack(data) if err != nil { t.Fatalf("Unpack: %v", err) } @@ -117,7 +120,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) { return files.get(ctx, name) } - data, err := Pack(context.Background(), scenario, testDomain, getFile) + data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile) if err != nil { t.Fatalf("Pack: %v", err) } @@ -125,7 +128,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) { t.Errorf("getFile вызван для внешних URL: %v", fetched) } - bundle, err := Unpack(data) + bundle, err := testArchive.Unpack(data) if err != nil { t.Fatalf("Unpack: %v", err) } @@ -147,11 +150,11 @@ func TestPackLegacyFullURLImage(t *testing.T) { Image: ptr(testDomain + "cover.png"), Scenario: `{"places":[]}`, } - data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get) + data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get) if err != nil { t.Fatalf("Pack: %v", err) } - bundle, err := Unpack(data) + bundle, err := testArchive.Unpack(data) if err != nil { t.Fatalf("Unpack: %v", err) } @@ -164,7 +167,7 @@ func TestPackMissingImage(t *testing.T) { files := newMemoryFiles() delete(files, "ticket.jpg") - _, err := Pack(context.Background(), buildScenario(), testDomain, files.get) + _, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get) if err == nil { t.Fatal("Pack должен вернуть ошибку при недоступном изображении") } @@ -175,12 +178,12 @@ func TestPackMissingImage(t *testing.T) { func TestPackDeterministic(t *testing.T) { files := newMemoryFiles() - first, err := Pack(context.Background(), buildScenario(), testDomain, files.get) + first, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get) if err != nil { t.Fatalf("Pack: %v", err) } time.Sleep(10 * time.Millisecond) - second, err := Pack(context.Background(), buildScenario(), testDomain, files.get) + second, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get) if err != nil { t.Fatalf("Pack: %v", err) } @@ -190,7 +193,7 @@ func TestPackDeterministic(t *testing.T) { } func TestPackNilScenario(t *testing.T) { - _, err := Pack(context.Background(), nil, testDomain, nil) + _, err := testArchive.Pack(context.Background(), nil, testDomain, nil) if err == nil { t.Fatal("Pack(nil) должен вернуть ошибку") } @@ -207,7 +210,7 @@ func TestPackImageNameCollision(t *testing.T) { {"code":"b","name":"B","text":"t","image":"dir/a.png"} ]}`, } - _, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get) + _, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get) if err == nil { t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений") } @@ -257,7 +260,7 @@ func TestUnpackRejectsBadPaths(t *testing.T) { "images/x.png": []byte("x"), name: []byte("evil"), }) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatalf("Unpack должен отклонить путь %q", name) } }) @@ -265,30 +268,30 @@ func TestUnpackRejectsBadPaths(t *testing.T) { } func TestUnpackEmpty(t *testing.T) { - if _, err := Unpack(nil); err == nil { + if _, err := testArchive.Unpack(nil); err == nil { t.Fatal("Unpack(nil) должен вернуть ошибку") } - if _, err := Unpack([]byte{}); err == nil { - t.Fatal("Unpack(пусто) должен вернуть ошибку") + if _, err := testArchive.Unpack([]byte{}); err == nil { + t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку") } } func TestUnpackNotAZip(t *testing.T) { - if _, err := Unpack([]byte("this is not a zip")); err == nil { + if _, err := testArchive.Unpack([]byte("this is not a zip")); err == nil { t.Fatal("Unpack должен отклонить не-zip данные") } } func TestUnpackMissingScenarioJSON(t *testing.T) { data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")}) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен требовать scenario.json") } } func TestUnpackInvalidScenarioJSON(t *testing.T) { data := writeZip(t, map[string][]byte{FileName: []byte("{not json")}) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен отклонить битый scenario.json") } } @@ -296,7 +299,7 @@ func TestUnpackInvalidScenarioJSON(t *testing.T) { func TestUnpackBadVersion(t *testing.T) { b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}}) data := writeZip(t, map[string][]byte{FileName: b}) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен отклонить неизвестную версию") } } @@ -304,7 +307,7 @@ func TestUnpackBadVersion(t *testing.T) { func TestUnpackEmptyName(t *testing.T) { b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}}) data := writeZip(t, map[string][]byte{FileName: b}) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен требовать название") } } @@ -312,7 +315,7 @@ func TestUnpackEmptyName(t *testing.T) { func TestUnpackNilStory(t *testing.T) { b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"}) data := writeZip(t, map[string][]byte{FileName: b}) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен требовать story") } } @@ -326,7 +329,7 @@ func TestUnpackEntrySizeLimit(t *testing.T) { FileName: validScenarioJSON(), "images/big.png": bytes.Repeat([]byte("x"), 8), }) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен отклонить запись больше maxEntrySize") } } @@ -341,7 +344,7 @@ func TestUnpackTotalSizeLimit(t *testing.T) { "images/a.png": []byte("aaa"), "images/b.png": []byte("bbb"), }) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize") } } @@ -352,7 +355,7 @@ func TestUnpackArchiveSizeLimit(t *testing.T) { defer func() { maxArchiveSize = prev }() data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()}) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен отклонить архив больше maxArchiveSize") } } @@ -367,7 +370,7 @@ func TestUnpackEntriesLimit(t *testing.T) { "images/a.png": []byte("a"), "images/b.png": []byte("b"), }) - if _, err := Unpack(data); err == nil { + if _, err := testArchive.Unpack(data); err == nil { t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries") } } @@ -388,7 +391,7 @@ func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) { t.Fatalf("Close: %v", err) } - if _, err := Unpack(buf.Bytes()); err == nil { + if _, err := testArchive.Unpack(buf.Bytes()); err == nil { t.Fatal("Unpack должен отклонить дубли scenario.json") } } @@ -420,7 +423,7 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) { t.Fatalf("Close: %v", err) } - if _, err := Unpack(buf.Bytes()); err == nil { + if _, err := testArchive.Unpack(buf.Bytes()); err == nil { t.Fatal("Unpack должен отклонить дубли файлов") } } @@ -444,7 +447,7 @@ func TestPackTotalSizeLimit(t *testing.T) { "a.png": bytes.Repeat([]byte("x"), 6), "b.png": bytes.Repeat([]byte("y"), 6), } - if _, err := Pack(context.Background(), scenario, testDomain, files.get); err == nil { + if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil { t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize") } } diff --git a/internal/modules/scenario_archive/interface.go b/internal/modules/scenario_archive/interface.go new file mode 100644 index 0000000..ecde2e8 --- /dev/null +++ b/internal/modules/scenario_archive/interface.go @@ -0,0 +1,47 @@ +package scenario_archive + +import ( + "context" + + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/repos" +) + +// Имя файла с описанием сценария внутри архива. +const FileName = "scenario.json" + +// Текущая версия формата scenario.json. +const Version = 1 + +// ScenarioJSON — описание сценария в архиве; Story — в формате колонки +// scenarios.scenario в БД. +type ScenarioJSON struct { + Version int `json:"version"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Image string `json:"image,omitempty"` + Story *storytelling.Story `json:"story"` +} + +// Bundle — результат разбора входящего архива. +type Bundle struct { + Scenario *ScenarioJSON + Files map[string][]byte +} + +// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор +// ZIP-архива сценария. +type IScenarioArchive interface { + // Pack собирает ZIP-архив сценария: ссылки на изображения хранилища + // переписываются в images/<имя>, файлы запрашиваются через getFile; + // внешние URL остаются без изменений. + Pack( + ctx context.Context, + scenario *repos.Scenario, + domain string, + getFile func(ctx context.Context, name string) ([]byte, error), + ) ([]byte, error) + + // Unpack разбирает входящий архив с проверкой лимитов и путей записей. + Unpack(data []byte) (*Bundle, error) +} diff --git a/internal/modules/string_tools/interface.go b/internal/modules/string_tools/interface.go new file mode 100644 index 0000000..0162eed --- /dev/null +++ b/internal/modules/string_tools/interface.go @@ -0,0 +1,7 @@ +package string_tools + +// IStringTools — утилиты работы со строками. +type IStringTools interface { + // Transliterate выполняет транслитерацию русского текста в латиницу. + Transliterate(text string) string +} diff --git a/internal/modules/string_tools/transliterate.go b/internal/modules/string_tools/transliterate.go index 6dfe414..4cd50b3 100644 --- a/internal/modules/string_tools/transliterate.go +++ b/internal/modules/string_tools/transliterate.go @@ -44,9 +44,16 @@ var ( ) ) +type stringTools struct{} + +// NewStringTools создаёт реализацию IStringTools. +func NewStringTools() IStringTools { + return &stringTools{} +} + // Transliterate выполняет транслитерацию русского текста в латиницу, // заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.) -func Transliterate(text string) string { +func (s *stringTools) Transliterate(text string) string { // Приводим к нижнему регистру и транслитерируем result := replacer.Replace(strings.ToLower(text)) diff --git a/internal/services/file_service/service.go b/internal/services/file_service/service.go index 98b5421..601f56e 100644 --- a/internal/services/file_service/service.go +++ b/internal/services/file_service/service.go @@ -8,13 +8,16 @@ import ( type FileService struct { fileStorage file_storage.IFileStorage + stringTools string_tools.IStringTools } func NewFileService( fileStorage file_storage.IFileStorage, + stringTools string_tools.IStringTools, ) *FileService { return &FileService{ fileStorage: fileStorage, + stringTools: stringTools, } } @@ -22,7 +25,7 @@ func (s *FileService) UploadFile( ctx context.Context, file *file_storage.File, ) (string, error) { - file.Name = string_tools.Transliterate(file.Name) + file.Name = s.stringTools.Transliterate(file.Name) if err := s.fileStorage.Put(ctx, file); err != nil { return "", err } diff --git a/internal/services/scenarios_service/service.go b/internal/services/scenarios_service/service.go index 7619a62..57ddfd2 100644 --- a/internal/services/scenarios_service/service.go +++ b/internal/services/scenarios_service/service.go @@ -31,10 +31,11 @@ type scenariosRepository interface { } type ScenarioService struct { - scenariosRepo scenariosRepository - cleaner cleaner.ICleaner - fileStorage file_storage.IFileStorage - domain string + scenariosRepo scenariosRepository + cleaner cleaner.ICleaner + fileStorage file_storage.IFileStorage + scenarioArchive scenario_archive.IScenarioArchive + domain string } func NewScenarioService( @@ -42,12 +43,14 @@ func NewScenarioService( cleaner cleaner.ICleaner, domain string, fileStorage file_storage.IFileStorage, + scenarioArchive scenario_archive.IScenarioArchive, ) *ScenarioService { return &ScenarioService{ - scenariosRepo: scenariosRepo, - cleaner: cleaner, - fileStorage: fileStorage, - domain: domain, + scenariosRepo: scenariosRepo, + cleaner: cleaner, + fileStorage: fileStorage, + scenarioArchive: scenarioArchive, + domain: domain, } } @@ -294,7 +297,7 @@ func (s *ScenarioService) DownloadArchive( if err != nil { return nil, "", err } - data, err := scenario_archive.Pack( + data, err := s.scenarioArchive.Pack( ctx, scenario, s.domain, @@ -320,7 +323,7 @@ func (s *ScenarioService) UploadArchive( data []byte, authorId int, ) (int, error) { - bundle, err := scenario_archive.Unpack(data) + bundle, err := s.scenarioArchive.Unpack(data) if err != nil { return 0, err } diff --git a/internal/services/scenarios_service/service_test.go b/internal/services/scenarios_service/service_test.go index 6ecab70..1e047dd 100644 --- a/internal/services/scenarios_service/service_test.go +++ b/internal/services/scenarios_service/service_test.go @@ -226,7 +226,7 @@ func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioServ if storage == nil { storage = newFakeStorage() } - return NewScenarioService(repo, nil, testDomain, storage) + return NewScenarioService(repo, nil, testDomain, storage, scenario_archive.NewScenarioArchive()) } // seedScenario кладёт в repo сценарий с историей и изображениями в storage. @@ -455,7 +455,7 @@ func TestDownloadArchive(t *testing.T) { t.Errorf("name = %q", name) } - bundle, err := scenario_archive.Unpack(data) + bundle, err := scenario_archive.NewScenarioArchive().Unpack(data) if err != nil { t.Fatalf("Unpack: %v", err) }