package scenarios_service import ( "context" "errors" "evening_detective_server/internal/modules/cleaner" "evening_detective_server/internal/modules/file_storage" "evening_detective_server/internal/modules/scenario_archive" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos" "evening_detective_server/internal/repos/scenarios_repo" "fmt" "path/filepath" "strings" ) // scenariosRepository — граница доступа к данным сценариев, реализуемая // *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД). type scenariosRepository interface { AddScenario(ctx context.Context, name string, authorId int) (int, error) GetScenariosByAuthorID(ctx context.Context, authorId int) ([]*repos.Scenario, error) GetScenariosByStatus(ctx context.Context, status string) ([]*repos.Scenario, error) GetScenarioByID(ctx context.Context, id int) (*repos.Scenario, error) UpdateScenarioByID(ctx context.Context, id int, name string, description string, image string) error UpdateScenarioStatusByID(ctx context.Context, id int, status string, allowPublished bool) (bool, error) DeleteScenarioByID(ctx context.Context, id int, allowPublished bool) (bool, error) GetStoryByScenarioID(ctx context.Context, id int) (string, error) UpdateStoryByScenarioID(ctx context.Context, id int, story string) error } type ScenarioService struct { scenariosRepo scenariosRepository cleaner cleaner.ICleaner fileStorage file_storage.IFileStorage scenarioArchive scenario_archive.IScenarioArchive domain string } func NewScenarioService( scenariosRepo scenariosRepository, cleaner cleaner.ICleaner, domain string, fileStorage file_storage.IFileStorage, scenarioArchive scenario_archive.IScenarioArchive, ) *ScenarioService { return &ScenarioService{ scenariosRepo: scenariosRepo, cleaner: cleaner, fileStorage: fileStorage, scenarioArchive: scenarioArchive, domain: domain, } } func (s *ScenarioService) AddScenario( ctx context.Context, name string, authorId int, ) (int, error) { id, err := s.scenariosRepo.AddScenario(ctx, name, authorId) if err != nil { return 0, err } return id, nil } func (s *ScenarioService) GetScenariosByAuthorID( ctx context.Context, authorId int, ) ([]*Scenario, error) { scenarios, err := s.scenariosRepo.GetScenariosByAuthorID(ctx, authorId) if err != nil { return nil, err } return mapScenariosLight(scenarios, s.domain), nil } func (s *ScenarioService) GetScenariosCatalog( ctx context.Context, ) ([]*Scenario, error) { scenarios, err := s.scenariosRepo.GetScenariosByStatus(ctx, "public") if err != nil { return nil, err } return mapScenariosLight(scenarios, s.domain), nil } func (s *ScenarioService) GetFullScenarioByID( ctx context.Context, id int, ) (*Scenario, error) { scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id) if err != nil { return nil, err } return mapScenario(scenario, s.domain) } func (s *ScenarioService) GetScenarioByID( ctx context.Context, id int, ) (*Scenario, error) { scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id) if err != nil { return nil, err } return MapScenarioLight(scenario, s.domain), nil } func (s *ScenarioService) UpdateScenarioByID( ctx context.Context, id int, name string, description string, image string, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } return s.scenariosRepo.UpdateScenarioByID(ctx, id, name, description, image) } // ErrScenarioPublished — опубликованный сценарий нельзя снять с публикации // или удалить: при публикации исключительное право отчуждается Оператору // (п. 7.2 Пользовательского соглашения, ст. 1234 ГК РФ), а право на отзыв // действует только до обнародования (ст. 1269 ГК РФ). var ErrScenarioPublished = errors.New("Опубликованный сценарий нельзя снять с публикации или удалить: исключительное право отчуждено Оператору") // ErrScenarioNotOwner — сценарий принадлежит другому пользователю. var ErrScenarioNotOwner = errors.New("Сценарий принадлежит другому пользователю") // getScenarioForChange проверяет права актора на изменение сценария: // не-админ должен быть автором сценария, сценарий не должен быть удалён. // Возвращает сценарий, чтобы вызывающий код не делал повторное чтение. func (s *ScenarioService) getScenarioForChange( ctx context.Context, id int, actorId int, isAdmin bool, ) (*repos.Scenario, error) { scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id) if err != nil { return nil, err } if scenario.IsDeleted { return nil, scenarios_repo.ErrScenarioNotFound } if isAdmin { return scenario, nil } if scenario.Author == nil || scenario.Author.ID != actorId { return nil, ErrScenarioNotOwner } return scenario, nil } func (s *ScenarioService) UpdateScenarioStatusByID( ctx context.Context, id int, status string, actorId int, isAdmin bool, ) error { scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin) if err != nil { 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 } if !updated { return ErrScenarioPublished } return nil } func (s *ScenarioService) DeleteScenarioByID( ctx context.Context, id int, actorId int, isAdmin bool, ) error { 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 } if !updated { return ErrScenarioPublished } return nil } func (s *ScenarioService) AddScenarioPlace( ctx context.Context, id int, place *storytelling.Place, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } story.Places = append( []*storytelling.Place{place}, story.Places..., ) return s.updateStory(ctx, id, story) } func (s *ScenarioService) UpdateScenarioPlace( ctx context.Context, id int, code string, place *storytelling.Place, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } for i := range story.Places { if story.Places[i].Code == code { story.Places[i] = place break } } return s.updateStory(ctx, id, story) } func (s *ScenarioService) DeleteScenarioPlace( ctx context.Context, id int, code string, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } for i := range story.Places { if story.Places[i].Code == code { story.Places = append(story.Places[:i], story.Places[i+1:]...) break } } return s.updateStory(ctx, id, story) } // UpdateScenarioIntro сохраняет введение сценария (текст и аудио). func (s *ScenarioService) UpdateScenarioIntro( ctx context.Context, id int, introduction *storytelling.Introduction, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } story.Introduction = introduction return s.updateStory(ctx, id, story) } func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.Story, error) { storyString, err := s.scenariosRepo.GetStoryByScenarioID(ctx, id) if err != nil { return nil, err } return mapStory(storyString, s.domain) } // DownloadArchive собирает ZIP-архив сценария func (s *ScenarioService) DownloadArchive( ctx context.Context, id int, actorId int, isAdmin bool, ) ([]byte, string, error) { scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin) if err != nil { return nil, "", err } data, err := s.scenarioArchive.Pack( ctx, scenario, s.domain, func(ctx context.Context, name string) ([]byte, error) { file, err := s.fileStorage.Get(ctx, name) if err != nil { return nil, err } return file.Data, nil }, ) if err != nil { return nil, "", err } return data, scenario.Name, nil } // UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются // под новыми случайными именами (не перетирают чужие файлы), ссылки // переписываются. Новый сценарий всегда draft — статус из архива игнорируется. func (s *ScenarioService) UploadArchive( ctx context.Context, data []byte, authorId int, ) (int, error) { bundle, err := s.scenarioArchive.Unpack(data) if err != nil { return 0, err } scenario := bundle.Scenario // При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов. uploaded := map[string]string{} ok := false defer func() { if ok { return } for _, name := range uploaded { _ = s.fileStorage.Delete(ctx, name) } }() rewrite := func(ref string) (string, error) { // Берём только ссылки images/... из архива; остальное — как есть. archiveRef := strings.TrimPrefix(ref, s.domain) if !strings.HasPrefix(archiveRef, "images/") { return ref, nil } if name, exists := uploaded[archiveRef]; exists { return name, nil } content, exists := bundle.Files[archiveRef] if !exists { return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef) } name, err := file_storage.NewName() if err != nil { return "", err } name += strings.ToLower(filepath.Ext(archiveRef)) if err := s.fileStorage.Put(ctx, &file_storage.File{ Name: name, Data: content, Mime: s.fileStorage.MimeType(name), }); err != nil { return "", fmt.Errorf("не удалось сохранить изображение %q: %w", archiveRef, err) } uploaded[archiveRef] = name return name, nil } image, err := rewrite(scenario.Image) if err != nil { return 0, err } for _, place := range scenario.Story.Places { place.Image, err = rewrite(place.Image) if err != nil { return 0, err } for _, application := range place.Applications { application.Image, err = rewrite(application.Image) if err != nil { return 0, err } } } if scenario.Story.Introduction != nil { scenario.Story.Introduction.Audio, err = rewrite(scenario.Story.Introduction.Audio) if err != nil { return 0, err } } storyJSON, err := normalizeStory(scenario.Story) if err != nil { return 0, err } id, err := s.scenariosRepo.AddScenario(ctx, scenario.Name, authorId) if err != nil { 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 } if err := s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyJSON); err != nil { _, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true) return 0, err } ok = true return id, nil } func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error { for _, place := range story.Places { place.Image = strings.TrimPrefix(place.Image, s.domain) for _, application := range place.Applications { application.Image = strings.TrimPrefix(application.Image, s.domain) } } if story.Introduction != nil { story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain) } storyString, err := normalizeStory(story) if err != nil { return err } return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString) } // normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды, // нормализует file_type улик и возвращает JSON истории. // Общая для редактирования и импорта. func normalizeStory(story *storytelling.Story) (string, error) { codes := map[string]struct{}{} for _, place := range story.Places { codes[place.Code] = struct{}{} } if len(codes) != len(story.Places) { return "", errors.New("Такой код точки уже существует") } cleanPlaces := make([]*storytelling.Place, 0, len(story.Places)) for _, place := range story.Places { if place.Code == "" { continue } for _, application := range place.Applications { // file_type принимает только известные значения; невалидные // (включая значения из импортированных архивов) заменяются // деривацией из расширения файла. application.FileType = normalizeFileType(application.FileType, application.Image) } cleanPlaces = append(cleanPlaces, place) } story.Places = cleanPlaces return convertStory(story) } // normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}. // Пустое/невалидное значение при сохранении заменяется деривацией из // расширения файла; пустое значение в legacy-строках деривируется при // чтении (mapStory). func normalizeFileType(fileType, image string) string { switch fileType { case "pdf", "image", "audio": return fileType default: return file_storage.FileType(image) } }