generated from VLADIMIR/template
clear
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: create-go-module
|
||||||
|
description: Создаёт Go-модуль в internal/modules/<snake_case>/ по конвенциям репозитория — обязательный interface.go с интерфейсом (имя начинается с I) и всеми публичными типами, в папке как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор New*, возвращающий интерфейс). Загружай при создании нового модуля или заготовки модуля.
|
||||||
|
whenToUse: Пользователь просит создать модуль/компонент/сервис/заготовку в internal/modules, либо «сделать по конвенциям проекта», либо явно упоминает interface.go или интерфейс с префиксом I.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Создание Go-модуля (internal/modules)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
В этом проекте переиспользуемые компоненты живут в `internal/modules/<имя>/` — по одному модулю на папку. Публичный контракт модуля описывается в **обязательном** файле `interface.go`. Эталонные примеры действующих модулей: `internal/modules/password_generator`, `internal/modules/email_sender`, `internal/modules/cleaner`, `internal/modules/storytelling`, `internal/modules/processor_jwt`.
|
||||||
|
|
||||||
|
## Обязательные правила
|
||||||
|
|
||||||
|
1. **Папка модуля** — `internal/modules/<snake_case>/` (например, `email_sender`). Имя Go-пакета равно имени папки. Никаких вложенных подпакетов внутри модуля.
|
||||||
|
2. **Файл `interface.go` обязателен.** В нём живёт ВЕСЬ публичный контракт модуля:
|
||||||
|
- интерфейс(ы) модуля;
|
||||||
|
- публичные типы: структуры-данные (DTO), перечисления, константы, именованные ошибки.
|
||||||
|
Ничего другого (реализация, приватные хелперы) в этом файле нет.
|
||||||
|
3. **Имя интерфейса начинается с `I`** — `I<CamelCase>`, например `IEmailSender`, `IPasswordGenerator`, `ICleaner`, `IStory`. Интерфейс без префикса `I` — недопустимо. В интерфейсе — только сигнатуры методов, без полей.
|
||||||
|
4. **Реализация — в отдельном файле** (например, `<короткое_имя>.go` или `service.go`): неэкспортируемая структура (строчная, например `type emailSender struct`), методы которой реализуют интерфейс, и конструктор `func New<CamelCase>(...) I<CamelCase>`, возвращающий **интерфейс**, а не структуру.
|
||||||
|
5. **В папке модуля — как минимум одна реализация интерфейса.** Модуль не может состоять из одного `interface.go`: рядом с ним всегда лежит неэкспортируемая структура, реализующая `I<CamelCase>` (см. правило 4), и конструктор, возвращающий интерфейс. Если реализаций несколько — каждая в своём файле.
|
||||||
|
6. **Сигнатуры методов**: операции с внешним миром (сеть, файлы, БД, таймеры) принимают `context.Context` первым аргументом и возвращают `error`; чистые вычисления — без контекста (пример: `Generate() (string, error)` у `password_generator`).
|
||||||
|
7. **Комментарии — на русском.** Каждый публичный идентификатор имеет doc-комментарий, начинающийся с имени символа. Пояснения сложных мест — тоже по-русски.
|
||||||
|
8. **Ошибки** оборачиваются через `fmt.Errorf("<snake_case>: ...: %w", err)` — префикс с именем модуля.
|
||||||
|
9. **JSON-теги** у сериализуемых структур — `json:"..."` (эталон: `storytelling/interface.go`).
|
||||||
|
10. **Зависимости между модулями**: вместо импорта чужого модуля объявляется минимальный локальный интерфейс в файле `dependency.go` (эталон: `internal/modules/storytelling/dependency.go` объявляет собственный `ICleaner`). Это исключает циклические импорты и связывает модули только через их интерфейсы.
|
||||||
|
11. **Тесты** — для модуля желателен файл `*_test.go` рядом с реализацией (примеры: `email_sender/sender_test.go`, `cleaner/service_test.go`).
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
1. **Определи имя модуля.** Если имя неоднозначно — уточни у пользователя. Приведи имя к двум формам:
|
||||||
|
- snake_case — папка и пакет: «генератор паролей» → `password_generator`;
|
||||||
|
- CamelCase — имена типов и интерфейса: `PasswordGenerator` → `IPasswordGenerator`.
|
||||||
|
2. **Создай папку** `internal/modules/<snake_case>/`.
|
||||||
|
3. **Напиши `interface.go`** (обязательно): сначала публичные типы/константы, затем интерфейс `I<CamelCase>`.
|
||||||
|
4. **Напиши файл(ы) реализации** (обязательно, минимум один) — неэкспортируемая структура + конструктор `New<CamelCase>(...) I<CamelCase>`.
|
||||||
|
5. **При необходимости** добавь `dependency.go` (локальные интерфейсы зависимостей) и `*_test.go`.
|
||||||
|
6. **Проверь сборку** из корня проекта: `go build ./...` и `go vet ./...`. Ошибки — исправь.
|
||||||
|
|
||||||
|
## Шаблон interface.go
|
||||||
|
|
||||||
|
```go
|
||||||
|
package <snake_case>
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// <TypeName> — описание публичного типа.
|
||||||
|
type <TypeName> struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// I<CamelCase> — контракт модуля <snake_case>.
|
||||||
|
type I<CamelCase> interface {
|
||||||
|
// <Method> — описание метода.
|
||||||
|
<Method>(ctx context.Context, in <TypeName>) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаблон реализации
|
||||||
|
|
||||||
|
```go
|
||||||
|
package <snake_case>
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// <lowerCamelCase> — реализация I<CamelCase>.
|
||||||
|
type <lowerCamelCase> struct {
|
||||||
|
// внутренние поля
|
||||||
|
}
|
||||||
|
|
||||||
|
// New<CamelCase> создаёт реализацию I<CamelCase>.
|
||||||
|
func New<CamelCase>(...) I<CamelCase> {
|
||||||
|
return &<lowerCamelCase>{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *<lowerCamelCase>) <Method>(ctx context.Context, in <TypeName>) error {
|
||||||
|
// TODO: реализация
|
||||||
|
return fmt.Errorf("<snake_case>: не реализовано")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Критерии готовности
|
||||||
|
|
||||||
|
- [ ] Папка `internal/modules/<snake_case>/` создана, имя пакета равно имени папки.
|
||||||
|
- [ ] `interface.go` существует; все публичные типы, константы и интерфейсы описаны именно в нём.
|
||||||
|
- [ ] Имя интерфейса начинается с `I`.
|
||||||
|
- [ ] В папке модуля есть как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор).
|
||||||
|
- [ ] Реализация неэкспортируемая; конструктор возвращает интерфейс, а не структуру.
|
||||||
|
- [ ] doc-комментарии на русском у всех публичных идентификаторов.
|
||||||
|
- [ ] `go build ./...` и `go vet ./...` проходят без ошибок.
|
||||||
Binary file not shown.
@@ -11,6 +11,7 @@ import (
|
|||||||
"evening_detective_server/internal/modules/processor_jwt"
|
"evening_detective_server/internal/modules/processor_jwt"
|
||||||
"evening_detective_server/internal/modules/scenario_archive"
|
"evening_detective_server/internal/modules/scenario_archive"
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
|
"evening_detective_server/internal/modules/string_tools"
|
||||||
"evening_detective_server/internal/repos/actions_repo"
|
"evening_detective_server/internal/repos/actions_repo"
|
||||||
"evening_detective_server/internal/repos/applications_repo"
|
"evening_detective_server/internal/repos/applications_repo"
|
||||||
"evening_detective_server/internal/repos/games_repo"
|
"evening_detective_server/internal/repos/games_repo"
|
||||||
@@ -90,7 +91,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to create connection rustfs: %v\n", err)
|
log.Fatalf("Unable to create connection rustfs: %v\n", err)
|
||||||
}
|
}
|
||||||
fileService := file_service.NewFileService(fileStorage)
|
fileService := file_service.NewFileService(fileStorage, string_tools.NewStringTools())
|
||||||
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
|
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
|
||||||
cleaner := cleaner.NewCleaner()
|
cleaner := cleaner.NewCleaner()
|
||||||
scenarioService := scenarios_service.NewScenarioService(
|
scenarioService := scenarios_service.NewScenarioService(
|
||||||
@@ -98,6 +99,7 @@ func main() {
|
|||||||
cleaner,
|
cleaner,
|
||||||
os.Getenv("FILE_PREFIX_DOMAIN"),
|
os.Getenv("FILE_PREFIX_DOMAIN"),
|
||||||
fileStorage,
|
fileStorage,
|
||||||
|
scenario_archive.NewScenarioArchive(),
|
||||||
)
|
)
|
||||||
gameRepo := games_repo.NewGamesRepo(dbpool)
|
gameRepo := games_repo.NewGamesRepo(dbpool)
|
||||||
teamRepo := teams_repo.NewTeamsRepo(dbpool)
|
teamRepo := teams_repo.NewTeamsRepo(dbpool)
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// stringTools — транслитерация имён файлов для HTTP-заголовков.
|
||||||
|
var stringTools = string_tools.NewStringTools()
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
proto.UnsafeEveningDetectiveServerServer
|
proto.UnsafeEveningDetectiveServerServer
|
||||||
|
|
||||||
@@ -603,7 +606,7 @@ func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.Downloa
|
|||||||
|
|
||||||
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
|
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
|
||||||
// санитайзится от header injection; заголовок пробрасывается gateway.
|
// санитайзится от header injection; заголовок пробрасывается gateway.
|
||||||
transliterated := sanitizeFilename(string_tools.Transliterate(name))
|
transliterated := sanitizeFilename(stringTools.Transliterate(name))
|
||||||
if transliterated == "" {
|
if transliterated == "" {
|
||||||
transliterated = "scenario"
|
transliterated = "scenario"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// textFormatter — форматирование текста при конвертации входящих данных.
|
||||||
|
var textFormatter = formatter_utils.NewFormatter()
|
||||||
|
|
||||||
func mapScenarios(o []*scenarios_service.Scenario) []*proto.Scenario {
|
func mapScenarios(o []*scenarios_service.Scenario) []*proto.Scenario {
|
||||||
res := make([]*proto.Scenario, 0, len(o))
|
res := make([]*proto.Scenario, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
@@ -117,8 +120,8 @@ func mapKey(o *storytelling.Key) *proto.Key {
|
|||||||
func convertPlace(o *proto.Place) *storytelling.Place {
|
func convertPlace(o *proto.Place) *storytelling.Place {
|
||||||
return &storytelling.Place{
|
return &storytelling.Place{
|
||||||
Code: o.Code,
|
Code: o.Code,
|
||||||
Name: formatter_utils.FormatString(o.Name),
|
Name: textFormatter.FormatString(o.Name),
|
||||||
Text: formatter_utils.FormatText(o.Text),
|
Text: textFormatter.FormatText(o.Text),
|
||||||
Image: o.Image,
|
Image: o.Image,
|
||||||
Hidden: o.Hidden,
|
Hidden: o.Hidden,
|
||||||
Applications: convertApplications(o.Applications),
|
Applications: convertApplications(o.Applications),
|
||||||
@@ -153,7 +156,7 @@ func convertKeys(o []*proto.Key) []*storytelling.Key {
|
|||||||
|
|
||||||
func convertApplication(o *proto.Application) *storytelling.Application {
|
func convertApplication(o *proto.Application) *storytelling.Application {
|
||||||
return &storytelling.Application{
|
return &storytelling.Application{
|
||||||
Name: formatter_utils.FormatString(o.Name),
|
Name: textFormatter.FormatString(o.Name),
|
||||||
Image: o.Image,
|
Image: o.Image,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,13 +164,13 @@ func convertApplication(o *proto.Application) *storytelling.Application {
|
|||||||
func convertDoor(o *proto.Door) *storytelling.Door {
|
func convertDoor(o *proto.Door) *storytelling.Door {
|
||||||
return &storytelling.Door{
|
return &storytelling.Door{
|
||||||
Code: o.Code,
|
Code: o.Code,
|
||||||
Name: formatter_utils.FormatString(o.Name),
|
Name: textFormatter.FormatString(o.Name),
|
||||||
Keys: convertKeys(o.Keys),
|
Keys: convertKeys(o.Keys),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertKey(o *proto.Key) *storytelling.Key {
|
func convertKey(o *proto.Key) *storytelling.Key {
|
||||||
return &storytelling.Key{
|
return &storytelling.Key{
|
||||||
Name: formatter_utils.FormatString(o.Name),
|
Name: textFormatter.FormatString(o.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package formatter_utils
|
||||||
|
|
||||||
|
// IFormatter — форматирование текста сценария для отображения.
|
||||||
|
type IFormatter interface {
|
||||||
|
FormatText(text string) string
|
||||||
|
FormatString(text string) string
|
||||||
|
}
|
||||||
@@ -5,7 +5,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FormatText(text string) string {
|
type formatter struct{}
|
||||||
|
|
||||||
|
// NewFormatter создаёт реализацию IFormatter.
|
||||||
|
func NewFormatter() IFormatter {
|
||||||
|
return &formatter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatText форматирует многострочный текст: обрезает пробелы по краям
|
||||||
|
// строк, превращает "--" в тире и добавляет отступ абзаца.
|
||||||
|
func (f *formatter) FormatText(text string) string {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(text))
|
scanner := bufio.NewScanner(strings.NewReader(text))
|
||||||
|
|
||||||
scanner.Split(bufio.ScanLines)
|
scanner.Split(bufio.ScanLines)
|
||||||
@@ -35,7 +44,9 @@ func FormatText(text string) string {
|
|||||||
return res.String()
|
return res.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatString(text string) string {
|
// FormatString форматирует одиночную строку: обрезает пробелы и заменяет
|
||||||
|
// "--" на тире.
|
||||||
|
func (f *formatter) FormatString(text string) string {
|
||||||
l := strings.TrimSpace(text)
|
l := strings.TrimSpace(text)
|
||||||
if strings.HasPrefix(l, "--") {
|
if strings.HasPrefix(l, "--") {
|
||||||
l = strings.Replace(l, "--", "—", 1)
|
l = strings.Replace(l, "--", "—", 1)
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ func Test_service_FormatText(t *testing.T) {
|
|||||||
want: " — Привет",
|
want: " — Привет",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
formatter := NewFormatter()
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := FormatText(tt.text)
|
got := formatter.FormatText(tt.text)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("FormatText() = %v, want %v", got, tt.want)
|
t.Errorf("FormatText() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package roles
|
||||||
|
|
||||||
|
// Роли пользователей системы.
|
||||||
|
const (
|
||||||
|
Admin = "admin"
|
||||||
|
Author = "author"
|
||||||
|
Organizer = "organizer"
|
||||||
|
User = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllRoles — список всех ролей.
|
||||||
|
var AllRoles = []string{
|
||||||
|
Admin,
|
||||||
|
Author,
|
||||||
|
Organizer,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IHasRole — контракт субъекта, у которого можно проверить наличие роли.
|
||||||
|
type IHasRole interface {
|
||||||
|
HasRole(role string) bool
|
||||||
|
}
|
||||||
@@ -1,23 +1,7 @@
|
|||||||
package roles
|
package roles
|
||||||
|
|
||||||
const (
|
// HasRole возвращает true, если у субъекта есть роль; администратор
|
||||||
Admin = "admin"
|
// проходит проверку любой роли.
|
||||||
Author = "author"
|
|
||||||
Organizer = "organizer"
|
|
||||||
User = "user"
|
|
||||||
)
|
|
||||||
|
|
||||||
var AllRoles = []string{
|
|
||||||
Admin,
|
|
||||||
Author,
|
|
||||||
Organizer,
|
|
||||||
User,
|
|
||||||
}
|
|
||||||
|
|
||||||
type IHasRole interface {
|
|
||||||
HasRole(role string) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func HasRole(claims IHasRole, role string) bool {
|
func HasRole(claims IHasRole, role string) bool {
|
||||||
if claims.HasRole(Admin) {
|
if claims.HasRole(Admin) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package roles
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// subject — реализация IHasRole: субъект с фиксированным набором ролей.
|
||||||
|
type subject struct {
|
||||||
|
roles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubject создаёт субъект с заданными ролями (например, для тестов
|
||||||
|
// авторизации или in-memory проверок прав).
|
||||||
|
func NewSubject(roles ...string) IHasRole {
|
||||||
|
return &subject{roles: roles}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole возвращает true, если роль есть в наборе ролей субъекта.
|
||||||
|
func (s *subject) HasRole(role string) bool {
|
||||||
|
return slices.Contains(s.roles, role)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package roles
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func Test_subject_HasRole(t *testing.T) {
|
||||||
|
subject := NewSubject(Author, Organizer)
|
||||||
|
|
||||||
|
if !subject.HasRole(Author) {
|
||||||
|
t.Errorf("HasRole(%q) = false, want true", Author)
|
||||||
|
}
|
||||||
|
if !subject.HasRole(Organizer) {
|
||||||
|
t.Errorf("HasRole(%q) = false, want true", Organizer)
|
||||||
|
}
|
||||||
|
if subject.HasRole(Admin) {
|
||||||
|
t.Errorf("HasRole(%q) = true, want false", Admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_subject_HasRoleEmpty(t *testing.T) {
|
||||||
|
subject := NewSubject()
|
||||||
|
|
||||||
|
if subject.HasRole(User) {
|
||||||
|
t.Errorf("HasRole(%q) = true, want false для субъекта без ролей", User)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,6 @@ import (
|
|||||||
"evening_detective_server/internal/repos"
|
"evening_detective_server/internal/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Имя файла с описанием сценария внутри архива.
|
|
||||||
const FileName = "scenario.json"
|
|
||||||
|
|
||||||
// Текущая версия формата scenario.json.
|
|
||||||
const Version = 1
|
|
||||||
|
|
||||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||||
var (
|
var (
|
||||||
@@ -36,28 +30,19 @@ var (
|
|||||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type scenarioArchive struct{}
|
||||||
|
|
||||||
|
// NewScenarioArchive создаёт реализацию IScenarioArchive.
|
||||||
|
func NewScenarioArchive() IScenarioArchive {
|
||||||
|
return &scenarioArchive{}
|
||||||
|
}
|
||||||
|
|
||||||
// MaxArchiveSize — максимальный размер входящего архива (для проверок
|
// MaxArchiveSize — максимальный размер входящего архива (для проверок
|
||||||
// в хендлере и на HTTP-слое).
|
// в хендлере и на HTTP-слое).
|
||||||
func MaxArchiveSize() int {
|
func MaxArchiveSize() int {
|
||||||
return maxArchiveSize
|
return maxArchiveSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
|
||||||
// scenarios.scenario в БД.
|
|
||||||
type ScenarioJSON struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Image string `json:"image,omitempty"`
|
|
||||||
Story *storytelling.Story `json:"story"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle — результат разбора входящего архива.
|
|
||||||
type Bundle struct {
|
|
||||||
Scenario *ScenarioJSON
|
|
||||||
Files map[string][]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
|
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
|
||||||
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
|
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
|
||||||
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
|
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
|
||||||
@@ -76,7 +61,7 @@ func ImagePath(ref, domain string) (string, bool) {
|
|||||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||||
func Pack(
|
func (a *scenarioArchive) Pack(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
scenario *repos.Scenario,
|
scenario *repos.Scenario,
|
||||||
domain string,
|
domain string,
|
||||||
@@ -223,7 +208,7 @@ func strPtrValue(p *string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||||
func Unpack(data []byte) (*Bundle, error) {
|
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil, errors.New("архив пуст")
|
return nil, errors.New("архив пуст")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
const testDomain = "http://storage.test/api/files/"
|
const testDomain = "http://storage.test/api/files/"
|
||||||
|
|
||||||
|
// testArchive — экземпляр модуля для тестов.
|
||||||
|
var testArchive = NewScenarioArchive()
|
||||||
|
|
||||||
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
||||||
func buildScenario() *repos.Scenario {
|
func buildScenario() *repos.Scenario {
|
||||||
description := "Детективная история"
|
description := "Детективная история"
|
||||||
@@ -53,12 +56,12 @@ func newMemoryFiles() memoryFiles {
|
|||||||
|
|
||||||
func TestPackUnpackRoundTrip(t *testing.T) {
|
func TestPackUnpackRoundTrip(t *testing.T) {
|
||||||
files := newMemoryFiles()
|
files := newMemoryFiles()
|
||||||
data, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Pack: %v", err)
|
t.Fatalf("Pack: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle, err := Unpack(data)
|
bundle, err := testArchive.Unpack(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unpack: %v", err)
|
t.Fatalf("Unpack: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +120,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
|||||||
return files.get(ctx, name)
|
return files.get(ctx, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := Pack(context.Background(), scenario, testDomain, getFile)
|
data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Pack: %v", err)
|
t.Fatalf("Pack: %v", err)
|
||||||
}
|
}
|
||||||
@@ -125,7 +128,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
|||||||
t.Errorf("getFile вызван для внешних URL: %v", fetched)
|
t.Errorf("getFile вызван для внешних URL: %v", fetched)
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle, err := Unpack(data)
|
bundle, err := testArchive.Unpack(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unpack: %v", err)
|
t.Fatalf("Unpack: %v", err)
|
||||||
}
|
}
|
||||||
@@ -147,11 +150,11 @@ func TestPackLegacyFullURLImage(t *testing.T) {
|
|||||||
Image: ptr(testDomain + "cover.png"),
|
Image: ptr(testDomain + "cover.png"),
|
||||||
Scenario: `{"places":[]}`,
|
Scenario: `{"places":[]}`,
|
||||||
}
|
}
|
||||||
data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Pack: %v", err)
|
t.Fatalf("Pack: %v", err)
|
||||||
}
|
}
|
||||||
bundle, err := Unpack(data)
|
bundle, err := testArchive.Unpack(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unpack: %v", err)
|
t.Fatalf("Unpack: %v", err)
|
||||||
}
|
}
|
||||||
@@ -164,7 +167,7 @@ func TestPackMissingImage(t *testing.T) {
|
|||||||
files := newMemoryFiles()
|
files := newMemoryFiles()
|
||||||
delete(files, "ticket.jpg")
|
delete(files, "ticket.jpg")
|
||||||
|
|
||||||
_, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
_, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
|
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
|
||||||
}
|
}
|
||||||
@@ -175,12 +178,12 @@ func TestPackMissingImage(t *testing.T) {
|
|||||||
|
|
||||||
func TestPackDeterministic(t *testing.T) {
|
func TestPackDeterministic(t *testing.T) {
|
||||||
files := newMemoryFiles()
|
files := newMemoryFiles()
|
||||||
first, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
first, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Pack: %v", err)
|
t.Fatalf("Pack: %v", err)
|
||||||
}
|
}
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
second, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
second, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Pack: %v", err)
|
t.Fatalf("Pack: %v", err)
|
||||||
}
|
}
|
||||||
@@ -190,7 +193,7 @@ func TestPackDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPackNilScenario(t *testing.T) {
|
func TestPackNilScenario(t *testing.T) {
|
||||||
_, err := Pack(context.Background(), nil, testDomain, nil)
|
_, err := testArchive.Pack(context.Background(), nil, testDomain, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Pack(nil) должен вернуть ошибку")
|
t.Fatal("Pack(nil) должен вернуть ошибку")
|
||||||
}
|
}
|
||||||
@@ -207,7 +210,7 @@ func TestPackImageNameCollision(t *testing.T) {
|
|||||||
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
|
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
|
||||||
]}`,
|
]}`,
|
||||||
}
|
}
|
||||||
_, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
_, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
|
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
|
||||||
}
|
}
|
||||||
@@ -257,7 +260,7 @@ func TestUnpackRejectsBadPaths(t *testing.T) {
|
|||||||
"images/x.png": []byte("x"),
|
"images/x.png": []byte("x"),
|
||||||
name: []byte("evil"),
|
name: []byte("evil"),
|
||||||
})
|
})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatalf("Unpack должен отклонить путь %q", name)
|
t.Fatalf("Unpack должен отклонить путь %q", name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -265,30 +268,30 @@ func TestUnpackRejectsBadPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnpackEmpty(t *testing.T) {
|
func TestUnpackEmpty(t *testing.T) {
|
||||||
if _, err := Unpack(nil); err == nil {
|
if _, err := testArchive.Unpack(nil); err == nil {
|
||||||
t.Fatal("Unpack(nil) должен вернуть ошибку")
|
t.Fatal("Unpack(nil) должен вернуть ошибку")
|
||||||
}
|
}
|
||||||
if _, err := Unpack([]byte{}); err == nil {
|
if _, err := testArchive.Unpack([]byte{}); err == nil {
|
||||||
t.Fatal("Unpack(пусто) должен вернуть ошибку")
|
t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnpackNotAZip(t *testing.T) {
|
func TestUnpackNotAZip(t *testing.T) {
|
||||||
if _, err := Unpack([]byte("this is not a zip")); err == nil {
|
if _, err := testArchive.Unpack([]byte("this is not a zip")); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить не-zip данные")
|
t.Fatal("Unpack должен отклонить не-zip данные")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnpackMissingScenarioJSON(t *testing.T) {
|
func TestUnpackMissingScenarioJSON(t *testing.T) {
|
||||||
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
|
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен требовать scenario.json")
|
t.Fatal("Unpack должен требовать scenario.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
||||||
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
|
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить битый scenario.json")
|
t.Fatal("Unpack должен отклонить битый scenario.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +299,7 @@ func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
|||||||
func TestUnpackBadVersion(t *testing.T) {
|
func TestUnpackBadVersion(t *testing.T) {
|
||||||
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
|
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
|
||||||
data := writeZip(t, map[string][]byte{FileName: b})
|
data := writeZip(t, map[string][]byte{FileName: b})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить неизвестную версию")
|
t.Fatal("Unpack должен отклонить неизвестную версию")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,7 +307,7 @@ func TestUnpackBadVersion(t *testing.T) {
|
|||||||
func TestUnpackEmptyName(t *testing.T) {
|
func TestUnpackEmptyName(t *testing.T) {
|
||||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
|
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
|
||||||
data := writeZip(t, map[string][]byte{FileName: b})
|
data := writeZip(t, map[string][]byte{FileName: b})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен требовать название")
|
t.Fatal("Unpack должен требовать название")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +315,7 @@ func TestUnpackEmptyName(t *testing.T) {
|
|||||||
func TestUnpackNilStory(t *testing.T) {
|
func TestUnpackNilStory(t *testing.T) {
|
||||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
|
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
|
||||||
data := writeZip(t, map[string][]byte{FileName: b})
|
data := writeZip(t, map[string][]byte{FileName: b})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен требовать story")
|
t.Fatal("Unpack должен требовать story")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +329,7 @@ func TestUnpackEntrySizeLimit(t *testing.T) {
|
|||||||
FileName: validScenarioJSON(),
|
FileName: validScenarioJSON(),
|
||||||
"images/big.png": bytes.Repeat([]byte("x"), 8),
|
"images/big.png": bytes.Repeat([]byte("x"), 8),
|
||||||
})
|
})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
|
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +344,7 @@ func TestUnpackTotalSizeLimit(t *testing.T) {
|
|||||||
"images/a.png": []byte("aaa"),
|
"images/a.png": []byte("aaa"),
|
||||||
"images/b.png": []byte("bbb"),
|
"images/b.png": []byte("bbb"),
|
||||||
})
|
})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
|
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,7 +355,7 @@ func TestUnpackArchiveSizeLimit(t *testing.T) {
|
|||||||
defer func() { maxArchiveSize = prev }()
|
defer func() { maxArchiveSize = prev }()
|
||||||
|
|
||||||
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
|
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
|
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,7 +370,7 @@ func TestUnpackEntriesLimit(t *testing.T) {
|
|||||||
"images/a.png": []byte("a"),
|
"images/a.png": []byte("a"),
|
||||||
"images/b.png": []byte("b"),
|
"images/b.png": []byte("b"),
|
||||||
})
|
})
|
||||||
if _, err := Unpack(data); err == nil {
|
if _, err := testArchive.Unpack(data); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
|
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +391,7 @@ func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
|
|||||||
t.Fatalf("Close: %v", err)
|
t.Fatalf("Close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить дубли scenario.json")
|
t.Fatal("Unpack должен отклонить дубли scenario.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,7 +423,7 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
|||||||
t.Fatalf("Close: %v", err)
|
t.Fatalf("Close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
|
||||||
t.Fatal("Unpack должен отклонить дубли файлов")
|
t.Fatal("Unpack должен отклонить дубли файлов")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +447,7 @@ func TestPackTotalSizeLimit(t *testing.T) {
|
|||||||
"a.png": bytes.Repeat([]byte("x"), 6),
|
"a.png": bytes.Repeat([]byte("x"), 6),
|
||||||
"b.png": bytes.Repeat([]byte("y"), 6),
|
"b.png": bytes.Repeat([]byte("y"), 6),
|
||||||
}
|
}
|
||||||
if _, err := Pack(context.Background(), scenario, testDomain, files.get); err == nil {
|
if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil {
|
||||||
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
|
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package scenario_archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
|
"evening_detective_server/internal/repos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Имя файла с описанием сценария внутри архива.
|
||||||
|
const FileName = "scenario.json"
|
||||||
|
|
||||||
|
// Текущая версия формата scenario.json.
|
||||||
|
const Version = 1
|
||||||
|
|
||||||
|
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
||||||
|
// scenarios.scenario в БД.
|
||||||
|
type ScenarioJSON struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Story *storytelling.Story `json:"story"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle — результат разбора входящего архива.
|
||||||
|
type Bundle struct {
|
||||||
|
Scenario *ScenarioJSON
|
||||||
|
Files map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор
|
||||||
|
// ZIP-архива сценария.
|
||||||
|
type IScenarioArchive interface {
|
||||||
|
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||||
|
// переписываются в images/<имя>, файлы запрашиваются через getFile;
|
||||||
|
// внешние URL остаются без изменений.
|
||||||
|
Pack(
|
||||||
|
ctx context.Context,
|
||||||
|
scenario *repos.Scenario,
|
||||||
|
domain string,
|
||||||
|
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||||
|
) ([]byte, error)
|
||||||
|
|
||||||
|
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||||
|
Unpack(data []byte) (*Bundle, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package string_tools
|
||||||
|
|
||||||
|
// IStringTools — утилиты работы со строками.
|
||||||
|
type IStringTools interface {
|
||||||
|
// Transliterate выполняет транслитерацию русского текста в латиницу.
|
||||||
|
Transliterate(text string) string
|
||||||
|
}
|
||||||
@@ -44,9 +44,16 @@ var (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type stringTools struct{}
|
||||||
|
|
||||||
|
// NewStringTools создаёт реализацию IStringTools.
|
||||||
|
func NewStringTools() IStringTools {
|
||||||
|
return &stringTools{}
|
||||||
|
}
|
||||||
|
|
||||||
// Transliterate выполняет транслитерацию русского текста в латиницу,
|
// Transliterate выполняет транслитерацию русского текста в латиницу,
|
||||||
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
||||||
func Transliterate(text string) string {
|
func (s *stringTools) Transliterate(text string) string {
|
||||||
|
|
||||||
// Приводим к нижнему регистру и транслитерируем
|
// Приводим к нижнему регистру и транслитерируем
|
||||||
result := replacer.Replace(strings.ToLower(text))
|
result := replacer.Replace(strings.ToLower(text))
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import (
|
|||||||
|
|
||||||
type FileService struct {
|
type FileService struct {
|
||||||
fileStorage file_storage.IFileStorage
|
fileStorage file_storage.IFileStorage
|
||||||
|
stringTools string_tools.IStringTools
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileService(
|
func NewFileService(
|
||||||
fileStorage file_storage.IFileStorage,
|
fileStorage file_storage.IFileStorage,
|
||||||
|
stringTools string_tools.IStringTools,
|
||||||
) *FileService {
|
) *FileService {
|
||||||
return &FileService{
|
return &FileService{
|
||||||
fileStorage: fileStorage,
|
fileStorage: fileStorage,
|
||||||
|
stringTools: stringTools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ func (s *FileService) UploadFile(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
file *file_storage.File,
|
file *file_storage.File,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
file.Name = string_tools.Transliterate(file.Name)
|
file.Name = s.stringTools.Transliterate(file.Name)
|
||||||
if err := s.fileStorage.Put(ctx, file); err != nil {
|
if err := s.fileStorage.Put(ctx, file); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ type scenariosRepository interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ScenarioService struct {
|
type ScenarioService struct {
|
||||||
scenariosRepo scenariosRepository
|
scenariosRepo scenariosRepository
|
||||||
cleaner cleaner.ICleaner
|
cleaner cleaner.ICleaner
|
||||||
fileStorage file_storage.IFileStorage
|
fileStorage file_storage.IFileStorage
|
||||||
domain string
|
scenarioArchive scenario_archive.IScenarioArchive
|
||||||
|
domain string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScenarioService(
|
func NewScenarioService(
|
||||||
@@ -42,12 +43,14 @@ func NewScenarioService(
|
|||||||
cleaner cleaner.ICleaner,
|
cleaner cleaner.ICleaner,
|
||||||
domain string,
|
domain string,
|
||||||
fileStorage file_storage.IFileStorage,
|
fileStorage file_storage.IFileStorage,
|
||||||
|
scenarioArchive scenario_archive.IScenarioArchive,
|
||||||
) *ScenarioService {
|
) *ScenarioService {
|
||||||
return &ScenarioService{
|
return &ScenarioService{
|
||||||
scenariosRepo: scenariosRepo,
|
scenariosRepo: scenariosRepo,
|
||||||
cleaner: cleaner,
|
cleaner: cleaner,
|
||||||
fileStorage: fileStorage,
|
fileStorage: fileStorage,
|
||||||
domain: domain,
|
scenarioArchive: scenarioArchive,
|
||||||
|
domain: domain,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +297,7 @@ func (s *ScenarioService) DownloadArchive(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
data, err := scenario_archive.Pack(
|
data, err := s.scenarioArchive.Pack(
|
||||||
ctx,
|
ctx,
|
||||||
scenario,
|
scenario,
|
||||||
s.domain,
|
s.domain,
|
||||||
@@ -320,7 +323,7 @@ func (s *ScenarioService) UploadArchive(
|
|||||||
data []byte,
|
data []byte,
|
||||||
authorId int,
|
authorId int,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
bundle, err := scenario_archive.Unpack(data)
|
bundle, err := s.scenarioArchive.Unpack(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioServ
|
|||||||
if storage == nil {
|
if storage == nil {
|
||||||
storage = newFakeStorage()
|
storage = newFakeStorage()
|
||||||
}
|
}
|
||||||
return NewScenarioService(repo, nil, testDomain, storage)
|
return NewScenarioService(repo, nil, testDomain, storage, scenario_archive.NewScenarioArchive())
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
||||||
@@ -455,7 +455,7 @@ func TestDownloadArchive(t *testing.T) {
|
|||||||
t.Errorf("name = %q", name)
|
t.Errorf("name = %q", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle, err := scenario_archive.Unpack(data)
|
bundle, err := scenario_archive.NewScenarioArchive().Unpack(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unpack: %v", err)
|
t.Fatalf("Unpack: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user