Files
evening_detective_server/internal/services/scenarios_service/service.go
T
2026-10-04 00:20:29 +07:00

635 lines
17 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"
)
type IScenariosRepository interface {
AddScenario(ctx context.Context, name string, authorID int) (int, error)
GetScenariosByAuthorID(ctx context.Context, authorID int) ([]*repos.Scenario, error)
GetAllScenarios(ctx context.Context) ([]*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
}
// GetAllScenarios returns all non-deleted scenarios.
func (s *ScenarioService) GetAllScenarios(ctx context.Context) ([]*Scenario, error) {
scenarios, err := s.scenariosRepo.GetAllScenarios(ctx)
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)
}
var ErrScenarioPublished = errors.New("опубликованный сценарий нельзя снять с публикации или удалить: исключительное право отчуждено оператору")
var ErrScenarioNotOwner = errors.New("сценарий принадлежит другому пользователю")
var ErrScenarioNotFound = errors.New("сценарий не найден")
var ErrInvalidArchive = errors.New("некорректный архив сценария")
var ErrInvalidScenarioData = errors.New("некорректные данные сценария")
func MaxArchiveSize() int {
return scenario_archive.MaxArchiveSize()
}
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" {
if status == "public" {
return nil
}
return ErrScenarioPublished
}
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
}
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)
}
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)
}
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)
}
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)
}
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)
}
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)
}
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
}
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
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) {
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 {
_, _ = 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)
}
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 {
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)
}
func normalizeFileType(fileType, image string) string {
switch fileType {
case "pdf", "image", "audio":
return fileType
default:
return file_storage.FileType(image)
}
}