This commit is contained in:
2026-08-20 23:17:52 +07:00
parent 6d37610348
commit 7656100fe6
19 changed files with 2808 additions and 455 deletions
+176 -12
View File
@@ -2,28 +2,51 @@ 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 *scenarios_repo.ScenariosRepo
scenariosRepo scenariosRepository
cleaner cleaner.ICleaner
fileStorage file_storage.IFileStorage
domain string
}
func NewScenarioService(
scenariosRepo *scenarios_repo.ScenariosRepo,
scenariosRepo scenariosRepository,
cleaner cleaner.ICleaner,
domain string,
fileStorage file_storage.IFileStorage,
) *ScenarioService {
return &ScenarioService{
scenariosRepo: scenariosRepo,
cleaner: cleaner,
fileStorage: fileStorage,
domain: domain,
}
}
@@ -260,14 +283,160 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S
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 {
mapCodes := map[string]struct{}{}
for _, place := range story.Places {
mapCodes[place.Code] = struct{}{}
place.Image = strings.TrimPrefix(place.Image, s.domain)
}
if len(mapCodes) != len(story.Places) {
return errors.New("Такой код точки уже существует")
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))
@@ -279,10 +448,5 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
}
story.Places = cleanPlaces
storyString, err := convertStory(story)
if err != nil {
return err
}
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
return convertStory(story)
}
@@ -0,0 +1,539 @@
package scenarios_service
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"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"
)
const testDomain = "http://storage.test/api/files/"
// fakeStorage — in-memory реализация IFileStorage.
type fakeStorage struct {
mu sync.Mutex
files map[string]*file_storage.File
getErr error
}
func newFakeStorage() *fakeStorage {
return &fakeStorage{files: map[string]*file_storage.File{}}
}
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
s.mu.Lock()
defer s.mu.Unlock()
cp := *f
cp.Data = append([]byte(nil), f.Data...)
s.files[f.Name] = &cp
return nil
}
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.getErr != nil {
return nil, s.getErr
}
f, ok := s.files[name]
if !ok {
return nil, fmt.Errorf("file not found: %s", name)
}
cp := *f
cp.Data = append([]byte(nil), f.Data...)
return &cp, nil
}
func (s *fakeStorage) Delete(_ context.Context, name string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.files, name)
return nil
}
func (s *fakeStorage) MimeType(filename string) string {
return ""
}
func (s *fakeStorage) names() []string {
s.mu.Lock()
defer s.mu.Unlock()
res := make([]string, 0, len(s.files))
for name := range s.files {
res = append(res, name)
}
return res
}
// fakeScenariosRepo — минимальная in-memory реализация scenariosRepository.
type fakeScenariosRepo struct {
mu sync.Mutex
byID map[int]*repos.Scenario
nextID int
addErr error
updateErr error
storyErr error
}
func newFakeScenariosRepo() *fakeScenariosRepo {
return &fakeScenariosRepo{
byID: map[int]*repos.Scenario{},
nextID: 0,
}
}
func (r *fakeScenariosRepo) AddScenario(_ context.Context, name string, authorId int) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.addErr != nil {
return 0, r.addErr
}
r.nextID++
description := ""
r.byID[r.nextID] = &repos.Scenario{
ID: r.nextID,
Name: name,
Description: &description,
Author: &repos.User{ID: authorId},
Status: "draft",
}
return r.nextID, nil
}
func (r *fakeScenariosRepo) GetScenariosByAuthorID(_ context.Context, authorId int) ([]*repos.Scenario, error) {
r.mu.Lock()
defer r.mu.Unlock()
var res []*repos.Scenario
for _, s := range r.byID {
if s.Author != nil && s.Author.ID == authorId {
res = append(res, s)
}
}
return res, nil
}
func (r *fakeScenariosRepo) GetScenariosByStatus(_ context.Context, status string) ([]*repos.Scenario, error) {
r.mu.Lock()
defer r.mu.Unlock()
var res []*repos.Scenario
for _, s := range r.byID {
if s.Status == status {
res = append(res, s)
}
}
return res, nil
}
func (r *fakeScenariosRepo) GetScenarioByID(_ context.Context, id int) (*repos.Scenario, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return nil, scenarios_repo.ErrScenarioNotFound
}
return s, nil
}
func (r *fakeScenariosRepo) UpdateScenarioByID(_ context.Context, id int, name string, description string, image string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.updateErr != nil {
return r.updateErr
}
s, ok := r.byID[id]
if !ok {
return scenarios_repo.ErrScenarioNotFound
}
s.Name = name
s.Description = &description
s.Image = &image
return nil
}
func (r *fakeScenariosRepo) UpdateScenarioStatusByID(_ context.Context, id int, status string, allowPublished bool) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return false, scenarios_repo.ErrScenarioNotFound
}
if !allowPublished && s.Status == "public" {
return false, nil
}
s.Status = status
return true, nil
}
func (r *fakeScenariosRepo) DeleteScenarioByID(_ context.Context, id int, allowPublished bool) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return false, scenarios_repo.ErrScenarioNotFound
}
if !allowPublished && s.Status == "public" {
return false, nil
}
s.IsDeleted = true
return true, nil
}
func (r *fakeScenariosRepo) GetStoryByScenarioID(_ context.Context, id int) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.storyErr != nil {
return "", r.storyErr
}
s, ok := r.byID[id]
if !ok {
return "", scenarios_repo.ErrScenarioNotFound
}
return s.Scenario, nil
}
func (r *fakeScenariosRepo) UpdateStoryByScenarioID(_ context.Context, id int, story string) error {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return scenarios_repo.ErrScenarioNotFound
}
s.Scenario = story
return nil
}
func (r *fakeScenariosRepo) get(id int) *repos.Scenario {
r.mu.Lock()
defer r.mu.Unlock()
return r.byID[id]
}
func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioService {
if repo == nil {
repo = newFakeScenariosRepo()
}
if storage == nil {
storage = newFakeStorage()
}
return NewScenarioService(repo, nil, testDomain, storage)
}
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
t.Helper()
description := "Детективная история"
image := "cover.png"
scenario := &repos.Scenario{
ID: 1,
Name: "Ночной клуб",
Description: &description,
Image: &image,
Author: &repos.User{ID: 7},
Status: "draft",
Scenario: `{"places":[
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
]}`,
}
repo.byID[scenario.ID] = scenario
_ = storage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
_ = storage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
_ = storage.Put(context.Background(), &file_storage.File{Name: "ticket.jpg", Data: []byte("ticket-bytes")})
return scenario
}
func TestUploadArchive(t *testing.T) {
repo := newFakeScenariosRepo()
srcStorage := newFakeStorage()
seedScenario(t, repo, srcStorage)
// Собираем архив как при скачивании (Pack через storage).
svc := newTestService(repo, srcStorage)
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
if err != nil {
t.Fatalf("DownloadArchive: %v", err)
}
// Импортируем в «чистые» repo и storage.
importRepo := newFakeScenariosRepo()
importStorage := newFakeStorage()
importSvc := newTestService(importRepo, importStorage)
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
if err != nil {
t.Fatalf("UploadArchive: %v", err)
}
if id != 1 {
t.Errorf("id = %d, want 1", id)
}
saved := importRepo.get(id)
if saved == nil {
t.Fatal("сценарий не создан")
}
if saved.Name != "Ночной клуб" {
t.Errorf("Name = %q", saved.Name)
}
if saved.Description == nil || *saved.Description != "Детективная история" {
t.Errorf("Description = %v", saved.Description)
}
if saved.Status != "draft" {
t.Errorf("Status = %q, want draft", saved.Status)
}
if saved.Author == nil || saved.Author.ID != 42 {
t.Errorf("Author = %+v, want id 42", saved.Author)
}
// Ссылки переписаны на новые имена без префикса images/.
if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
}
story := &storytelling.Story{}
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
t.Fatalf("story не разобрался: %v", err)
}
if len(story.Places) != 2 {
t.Fatalf("places = %d, want 2", len(story.Places))
}
club := story.Places[0]
if club.Image == "" || strings.HasPrefix(club.Image, "images/") || club.Image == "club.png" {
t.Errorf("place image = %q, want новое имя", club.Image)
}
if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
t.Errorf("application image = %+v, want новое имя", club.Applications)
}
// Одна и та же картинка в двух точках — одно новое имя.
if story.Places[1].Image != club.Image {
t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
}
// Файлы загружены в storage под новыми именами с правильным содержимым.
files := importStorage.names()
if len(files) != 3 {
t.Fatalf("storage files = %v, want 3", files)
}
contents := map[string]bool{}
for _, name := range files {
file, err := importStorage.Get(context.Background(), name)
if err != nil {
t.Fatalf("Get(%q): %v", name, err)
}
contents[string(file.Data)] = true
}
for _, want := range []string{"cover-bytes", "club-bytes", "ticket-bytes"} {
if !contents[want] {
t.Errorf("в storage нет содержимого %q, есть: %v", want, contents)
}
}
}
func TestUploadArchiveMissingImage(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
// Архив ссылается на images/missing.png, файла в архиве нет.
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "images/missing.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
}),
})
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
t.Fatal("UploadArchive должен вернуть ошибку при отсутствующем файле")
}
if len(storage.names()) != 0 {
t.Errorf("storage не должен содержать файлов: %v", storage.names())
}
}
func TestUploadArchiveExternalURLKept(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "https://example.com/cover.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P", Image: "http://other.example/x.png"}}},
}),
})
id, err := svc.UploadArchive(context.Background(), archive, 1)
if err != nil {
t.Fatalf("UploadArchive: %v", err)
}
saved := repo.get(id)
if saved.Image == nil || *saved.Image != "https://example.com/cover.png" {
t.Errorf("Image = %v, внешний URL должен остаться без изменений", saved.Image)
}
if len(storage.names()) != 0 {
t.Errorf("storage не должен содержать файлов: %v", storage.names())
}
}
func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
repo := newFakeScenariosRepo()
repo.addErr = errors.New("db is down")
storage := newFakeStorage()
svc := newTestService(repo, storage)
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "images/x.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
}),
"images/x.png": []byte("x"),
})
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
}
// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
if len(storage.names()) != 0 {
t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
}
}
func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
repo := newFakeScenariosRepo()
repo.updateErr = errors.New("db is down")
storage := newFakeStorage()
svc := newTestService(repo, storage)
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "images/x.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
}),
"images/x.png": []byte("x"),
})
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
}
// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
if got := repo.get(1); got == nil || !got.IsDeleted {
t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
}
if len(storage.names()) != 0 {
t.Errorf("после частичного сбоя storage должен быть пуст: %v", storage.names())
}
}
func TestDownloadArchive(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
svc := newTestService(repo, storage)
data, name, err := svc.DownloadArchive(context.Background(), 1, 7, false)
if err != nil {
t.Fatalf("DownloadArchive: %v", err)
}
if name != "Ночной клуб" {
t.Errorf("name = %q", name)
}
bundle, err := scenario_archive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Name != "Ночной клуб" {
t.Errorf("scenario name = %q", bundle.Scenario.Name)
}
if bundle.Scenario.Image != "images/cover.png" {
t.Errorf("scenario image = %q", bundle.Scenario.Image)
}
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
if _, ok := bundle.Files[want]; !ok {
t.Errorf("в архиве нет %q", want)
}
}
}
func TestDownloadArchiveNotOwner(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
svc := newTestService(repo, storage)
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
}
// Админ может скачать чужой сценарий.
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
t.Errorf("админ: err = %v", err)
}
}
func TestDownloadArchiveNotFound(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
if _, _, err := svc.DownloadArchive(context.Background(), 404, 1, true); !errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
t.Errorf("err = %v, want ErrScenarioNotFound", err)
}
}
func TestDownloadArchiveMissingImage(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
_ = storage.Delete(context.Background(), "ticket.jpg")
svc := newTestService(repo, storage)
if _, _, err := svc.DownloadArchive(context.Background(), 1, 7, false); err == nil {
t.Fatal("DownloadArchive должен вернуть ошибку при недоступном изображении")
}
}
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("Create(%q): %v", name, err)
}
if _, err := w.Write(content); err != nil {
t.Fatalf("Write(%q): %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
return buf.Bytes()
}
func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return b
}