Files
evening_detective_server/internal/services/scenarios_service/service.go
T
2026-10-03 13:00:50 +07:00

669 lines
21 KiB
Go

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"
)
// 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)
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
}
// ScenarioService handles scenario operations.
type ScenarioService struct {
scenariosRepo IScenariosRepository
cleaner cleaner.ICleaner
fileStorage file_storage.IFileStorage
scenarioArchive scenario_archive.IScenarioArchive
domain string
}
// NewScenarioService creates a scenario service.
func NewScenarioService(
scenariosRepo IScenariosRepository,
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,
}
}
// AddScenario creates a scenario.
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
}
// GetScenariosByAuthorID returns scenarios created by an author.
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
}
// GetScenariosCatalog returns scenarios available in the catalog.
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
}
// GetFullScenarioByID returns a scenario with its full story.
func (s *ScenarioService) GetFullScenarioByID(
ctx context.Context,
id int,
) (*Scenario, error) {
scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id)
if err != nil {
return nil, mapScenarioError(err)
}
return mapScenario(scenario, s.domain)
}
// GetScenarioByID returns a scenario by ID.
func (s *ScenarioService) GetScenarioByID(
ctx context.Context,
id int,
) (*Scenario, error) {
scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id)
if err != nil {
return nil, mapScenarioError(err)
}
return MapScenarioLight(scenario, s.domain), nil
}
// UpdateScenarioByID updates a scenario's metadata.
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("Сценарий принадлежит другому пользователю")
// 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,
actorID int,
isAdmin bool,
) (*repos.Scenario, error) {
scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id)
if err != nil {
return nil, mapScenarioError(err)
}
if scenario.IsDeleted {
return nil, ErrScenarioNotFound
}
if isAdmin {
return scenario, nil
}
if scenario.Author == nil || scenario.Author.ID != actorID {
return nil, ErrScenarioNotOwner
}
return scenario, nil
}
func mapScenarioError(err error) error {
if errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
return ErrScenarioNotFound
}
return err
}
// UpdateScenarioStatusByID changes a scenario's publication status.
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
}
// DeleteScenarioByID removes a scenario.
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
}
// AddScenarioPlace adds a place to a scenario story.
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)
}
// UpdateScenarioPlace updates a place in a scenario 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)
}
// DeleteScenarioPlace removes a place from a scenario 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)
}
// 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
}
if background != "" && file_storage.FileType(background) != "image" {
return errors.New("Фон дела должен быть изображением")
}
story, err := s.getStory(ctx, id)
if err != nil {
return err
}
story.Background = background
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)
}
// UpdateScenarioConclusion updates a scenario conclusion.
func (s *ScenarioService) UpdateScenarioConclusion(ctx context.Context, id int, conclusion *storytelling.Conclusion, 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.Conclusion = conclusion
return s.updateStory(ctx, id, story)
}
// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария.
func (s *ScenarioService) AddScenarioQuestion(
ctx context.Context,
id int,
question *storytelling.Question,
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.Questions = append(story.Questions, question)
return s.updateStory(ctx, id, story)
}
// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода
// оставляет строки team_answers старого кода в БД
func (s *ScenarioService) UpdateScenarioQuestion(
ctx context.Context,
id int,
code string,
question *storytelling.Question,
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.Questions {
if story.Questions[i].Code == code {
story.Questions[i] = question
break
}
}
return s.updateStory(ctx, id, story)
}
// DeleteScenarioQuestion удаляет вопрос дела по его коду.
func (s *ScenarioService) DeleteScenarioQuestion(
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.Questions {
if story.Questions[i].Code == code {
story.Questions = append(story.Questions[:i], story.Questions[i+1:]...)
break
}
}
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 {
if errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
return nil, "", ErrScenarioNotFound
}
return nil, "", err
}
data, err := s.scenarioArchive.Pack(
ctx,
&scenario_archive.Scenario{
Name: scenario.Name,
Description: scenario.Description,
Image: scenario.Image,
Story: scenario.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, fmt.Errorf("%w: %v", ErrInvalidArchive, 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("%w: изображение %q не найдено в архиве", ErrInvalidArchive, 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
}
scenario.Story.Background, err = rewrite(scenario.Story.Background)
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
}
}
if scenario.Story.Conclusion != nil {
scenario.Story.Conclusion.Video, err = rewrite(scenario.Story.Conclusion.Video)
if err != nil {
return 0, err
}
}
storyJSON, err := normalizeStory(scenario.Story)
if err != nil {
return 0, fmt.Errorf("%w: %v", ErrInvalidArchive, 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 {
if story.Conclusion != nil {
story.Conclusion.Video = strings.TrimPrefix(story.Conclusion.Video, s.domain)
}
story.Background = strings.TrimPrefix(story.Background, s.domain)
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 fmt.Errorf("%w: %v", ErrInvalidScenarioData, err)
}
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")
}
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
// Коды вопросов уникальны, пустые коды отбрасываются (как у точек).
questionCodes := map[string]struct{}{}
for _, question := range story.Questions {
questionCodes[question.Code] = struct{}{}
}
if len(questionCodes) != len(story.Questions) {
return "", errors.New("Такой код вопроса уже существует")
}
cleanQuestions := make([]*storytelling.Question, 0, len(story.Questions))
for _, question := range story.Questions {
if question.Code == "" {
continue
}
cleanQuestions = append(cleanQuestions, question)
}
story.Questions = cleanQuestions
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)
}
}