generated from VLADIMIR/template
453 lines
13 KiB
Go
453 lines
13 KiB
Go
package scenarios_service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"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
|
|
domain string
|
|
}
|
|
|
|
func NewScenarioService(
|
|
scenariosRepo scenariosRepository,
|
|
cleaner cleaner.ICleaner,
|
|
domain string,
|
|
fileStorage file_storage.IFileStorage,
|
|
) *ScenarioService {
|
|
return &ScenarioService{
|
|
scenariosRepo: scenariosRepo,
|
|
cleaner: cleaner,
|
|
fileStorage: fileStorage,
|
|
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)
|
|
}
|
|
|
|
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 := scenario_archive.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 := scenario_archive.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 := newStorageName(archiveRef)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// newStorageName — случайное имя в хранилище (hex + расширение): исключает
|
|
// коллизии с файлами других сценариев в общем бакете.
|
|
func newStorageName(archivePath string) (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err)
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(archivePath))
|
|
return hex.EncodeToString(b[:]) + ext, 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)
|
|
}
|
|
storyString, err := normalizeStory(story)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
|
}
|
|
|
|
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
|
|
// и возвращает 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
|
|
}
|
|
cleanPlaces = append(cleanPlaces, place)
|
|
}
|
|
story.Places = cleanPlaces
|
|
|
|
return convertStory(story)
|
|
}
|