package service import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) type tokenPayload struct { ID string `json:"id"` Name string `json:"name"` Login string `json:"login"` Roles []RoleCode `json:"roles"` ExpiresAt int64 `json:"exp"` } type apiError struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } func normalizeContentType(value string) ContentType { switch strings.ToLower(strings.TrimSpace(value)) { case "research": return ContentTypeArticle case "announcement": return ContentTypeNews default: return ContentType(strings.TrimSpace(value)) } } func normalizeStatus(value string) ContentStatus { switch strings.ToLower(strings.TrimSpace(value)) { case "draft", "черновик": return ContentStatusDraft case "moderation", "pending", "на модерации": return ContentStatusModeration case "review", "на проверке": return ContentStatusReview case "published", "опубликовано": return ContentStatusPublished case "returned", "возвращен": return ContentStatusReturned case "archived", "архив": return ContentStatusArchived default: return ContentStatus(strings.TrimSpace(value)) } } func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool { path := strings.TrimPrefix(r.URL.Path, "/api") if path == "" { path = "/" } switch cfg.Domain { case "auth": return handleAuth(w, r, path) case "user": return handleUser(w, r, path) case "content": return handleContent(w, r, path) case "taxonomy": return handleTaxonomy(w, r, path) case "speaker": return handleSpeaker(w, r, path) case "subscription": return handleSubscription(w, r, path) case "notification": return handleNotification(w, r, path) case "comment": return handleComment(w, r, path) case "search": return handleSearch(w, r, path) case "analytics": return handleAnalytics(w, r, path) case "audit": return handleAudit(w, r, path) case "media": return handleMedia(w, r, path) default: return false } } func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool { switch { case r.Method == http.MethodPost && path == "/auth/login": var payload struct { Login string `json:"login"` Email string `json:"email"` Password string `json:"password"` } if !decodeJSON(w, r, &payload) { return true } login := firstNonEmpty(payload.Login, payload.Email) if strings.TrimSpace(login) == "" || strings.TrimSpace(payload.Password) == "" { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль") return true } user, ok, err := backendStore.UserByLogin(r.Context(), login) if err != nil { writeStoreError(w, err) return true } if !ok { writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль") return true } if err := checkPassword(user.PasswordHash, payload.Password); err != nil { writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль") return true } token, err := makeToken(user) if err != nil { writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен") return true } writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user}) return true case r.Method == http.MethodPost && path == "/auth/register": var payload struct { Login string `json:"login"` Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` } if !decodeJSON(w, r, &payload) { return true } login := firstNonEmpty(payload.Login, payload.Email) if strings.TrimSpace(login) == "" || len(payload.Password) < 8 { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов") return true } name := strings.TrimSpace(payload.Name) if name == "" { name = "Демо-пользователь" } user, err := backendStore.AddUser(r.Context(), login, name, payload.Password) if err != nil { writeStoreError(w, err) return true } token, err := makeToken(user) if err != nil { writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен") return true } writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": user}) return true case r.Method == http.MethodGet && path == "/auth/me": user, ok := requireAuth(w, r) if !ok { return true } writeJSON(w, http.StatusOK, map[string]any{"user": user}) return true case r.Method == http.MethodPost && path == "/auth/logout": writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return true case r.Method == http.MethodPost && path == "/auth/change-password": user, ok := requireAuth(w, r) if !ok { return true } var payload struct { NextPassword string `json:"nextPassword"` } if !decodeJSON(w, r, &payload) { return true } if len(payload.NextPassword) < 8 { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов") return true } if err := backendStore.UpdatePassword(r.Context(), user.ID, payload.NextPassword); err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return true default: return false } } func handleUser(w http.ResponseWriter, r *http.Request, path string) bool { switch { case r.Method == http.MethodGet && (path == "/users" || path == "/admin/users"): if _, ok := requireRole(w, r, RoleAdministrator); !ok { return true } items, err := backendStore.ListUsers(r.Context()) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"): if _, ok := requireRole(w, r, RoleAdministrator); !ok { return true } writeJSON(w, http.StatusOK, map[string]any{"items": []map[string]any{ {"code": RoleAdministrator, "permissions": []string{"*"}}, {"code": RoleEditor, "permissions": []string{"content:create", "content:update", "comments:moderate"}}, {"code": RoleManager, "permissions": []string{"content:create", "content:publish", "subscriptions:manage"}}, {"code": RoleUser, "permissions": []string{"content:read", "comments:create", "subscriptions:create"}}, }}) return true default: return false } } func handleContent(w http.ResponseWriter, r *http.Request, path string) bool { switch { case r.Method == http.MethodGet && path == "/content": query := r.URL.Query() user, _ := userFromRequest(r) items, err := backendStore.ListContent(r.Context(), ContentFilter{ Term: query.Get("q"), Category: query.Get("category"), ContentType: string(normalizeContentType(query.Get("type"))), SortMode: query.Get("sort"), Status: string(normalizeStatus(query.Get("status"))), AuthorID: query.Get("authorId"), Mine: query.Get("mine") == "true", User: user, Exclude: query.Get("exclude"), Limit: func() int { if query.Get("limit") == "" { return 0 } var n int fmt.Sscanf(query.Get("limit"), "%d", &n) return n }(), }) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true case r.Method == http.MethodGet && path == "/events": items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"}) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."}) return true case r.Method == http.MethodGet && strings.HasPrefix(path, "/content/"): id := strings.TrimPrefix(path, "/content/") item, ok, err := backendStore.ContentByID(r.Context(), id) if err != nil { writeStoreError(w, err) return true } if !ok { writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден") return true } writeJSON(w, http.StatusOK, map[string]any{"item": item}) return true case r.Method == http.MethodPost && path == "/content": user, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager) if !ok { return true } var payload ContentItem if !decodeJSON(w, r, &payload) { return true } payload.Type = normalizeContentType(string(payload.Type)) payload.Status = normalizeStatus(string(payload.Status)) payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt) payload.Body = firstNonEmpty(payload.Body, payload.Content) if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала") return true } item := ContentItem{ ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()), Title: payload.Title, Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"), Body: firstNonEmpty(payload.Body, "Демо-описание материала."), Type: payload.Type, Category: payload.Category, Tags: payload.Tags, Author: user.Name, PublishedAt: time.Now().Format("2006-01-02"), Visibility: VisibilityAuthenticated, Status: firstStatus(payload.Status, ContentStatusDraft), ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"), MediaURL: payload.MediaURL, MediaKind: payload.MediaKind, MimeType: payload.MimeType, FileName: payload.FileName, FileSize: payload.FileSize, ModeratorComment: payload.ModeratorComment, ReviewComment: payload.ReviewComment, } stored, err := backendStore.AddContent(r.Context(), item) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusCreated, map[string]any{"item": stored}) return true case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"): if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok { return true } var payload ContentItem if !decodeJSON(w, r, &payload) { return true } payload.Type = normalizeContentType(string(payload.Type)) payload.Status = normalizeStatus(string(payload.Status)) payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt) payload.Body = firstNonEmpty(payload.Body, payload.Content) item, ok, err := backendStore.PatchContent(r.Context(), strings.TrimPrefix(path, "/content/"), payload) if err != nil { writeStoreError(w, err) return true } if !ok { writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден") return true } writeJSON(w, http.StatusOK, map[string]any{"item": item}) return true case r.Method == http.MethodDelete && strings.HasPrefix(path, "/content/"): if _, ok := requireRole(w, r, RoleAdministrator); !ok { return true } deleted, err := backendStore.DeleteContent(r.Context(), strings.TrimPrefix(path, "/content/")) if err != nil { writeStoreError(w, err) return true } if !deleted { writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден") return true } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return true default: return false } } func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool { if r.Method != http.MethodGet { return false } switch path { case "/categories": items, err := backendStore.ListCategories(r.Context()) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true case "/tags": items, err := backendStore.ListTags(r.Context()) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true default: return false } } func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool { if r.Method == http.MethodGet && path == "/speakers" { items, err := backendStore.ListSpeakers(r.Context()) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true } return false } func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool { switch { case r.Method == http.MethodGet && path == "/media": items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"}) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"): file, ok, err := backendStore.FileByID(r.Context(), strings.TrimPrefix(path, "/media/files/")) if err != nil { writeStoreError(w, err) return true } if !ok { writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден") return true } w.Header().Set("Content-Type", firstNonEmpty(file.MimeType, "application/octet-stream")) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", file.Name)) http.ServeContent(w, r, file.Name, time.Now(), bytes.NewReader(file.Data)) return true case r.Method == http.MethodPost && path == "/media": user, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager) if !ok { return true } r.Body = http.MaxBytesReader(w, r.Body, 64<<20) if err := r.ParseMultipartForm(64 << 20); err != nil { writeAPIError(w, http.StatusBadRequest, "INVALID_MULTIPART", "Не удалось прочитать multipart-запрос") return true } uploaded, header, err := r.FormFile("file") if err != nil { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Прикрепите файл") return true } defer uploaded.Close() data, err := io.ReadAll(uploaded) if err != nil || len(data) == 0 { writeAPIError(w, http.StatusBadRequest, "INVALID_FILE", "Файл пустой или поврежден") return true } mimeType := header.Header.Get("Content-Type") if mimeType == "" || mimeType == "application/octet-stream" { sample := data if len(sample) > 512 { sample = sample[:512] } mimeType = http.DetectContentType(sample) } mediaKind := inferMediaKind(mimeType, header.Filename) contentType := ContentType(strings.TrimSpace(r.FormValue("type"))) if contentType == "" { contentType = defaultContentTypeForMediaKind(mediaKind) } if !isValidContentType(contentType) { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите корректный тип материала") return true } stored, err := backendStore.AddFile(r.Context(), StoredFile{ ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()), Name: firstNonEmpty(header.Filename, "uploaded-file"), MimeType: mimeType, Size: int64(len(data)), Data: data, }) if err != nil { writeStoreError(w, err) return true } category := strings.TrimSpace(r.FormValue("category")) if category == "" { category = defaultCategoryForMediaKind(mediaKind) } item := ContentItem{ ID: fmt.Sprintf("demo-media-%d", time.Now().UnixNano()), Title: firstNonEmpty(r.FormValue("title"), stored.Name), Lead: firstNonEmpty(r.FormValue("lead"), "Демонстрационный медиаматериал с загруженным файлом."), Body: firstNonEmpty(r.FormValue("body"), "Файл загружен в in-memory demo-хранилище и доступен только до перезапуска сервиса."), Type: contentType, Category: category, Tags: parseTags(r.FormValue("tags")), Author: user.Name, PublishedAt: time.Now().Format("2006-01-02"), Visibility: VisibilityAuthenticated, Status: ContentStatusDraft, Views: 0, ImageTone: "from-university-800 via-slate-700 to-sky-300", MediaURL: "/api/media/files/" + stored.ID, MediaKind: mediaKind, MimeType: stored.MimeType, FileName: stored.Name, FileSize: stored.Size, } created, err := backendStore.AddContent(r.Context(), item) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusCreated, map[string]any{"item": created}) return true } return false } func handleSearch(w http.ResponseWriter, r *http.Request, path string) bool { if r.Method == http.MethodGet && path == "/search" { query := r.URL.Query() items, err := backendStore.ListContent(r.Context(), ContentFilter{Term: query.Get("q"), Category: query.Get("category"), ContentType: query.Get("type"), SortMode: query.Get("sort")}) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true } return false } func handleSubscription(w http.ResponseWriter, r *http.Request, path string) bool { if path != "/subscriptions" { return false } user, ok := requireAuth(w, r) if !ok { return true } switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]any{"items": user.Subscriptions}) return true case http.MethodPost: var payload struct { Target string `json:"target"` } if !decodeJSON(w, r, &payload) { return true } if strings.TrimSpace(payload.Target) == "" { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки") return true } items, err := backendStore.UpsertSubscription(r.Context(), user.ID, payload.Target) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true default: return false } } func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool { switch { case r.Method == http.MethodGet && path == "/notifications": items, err := backendStore.ListNotifications(r.Context()) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"): if _, ok := requireAuth(w, r); !ok { return true } id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read") updated, err := backendStore.MarkNotificationRead(r.Context(), id) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"item": updated}) return true default: return false } } func handleComment(w http.ResponseWriter, r *http.Request, path string) bool { if !strings.HasPrefix(path, "/comments/") { return false } contentID := strings.TrimPrefix(path, "/comments/") switch r.Method { case http.MethodGet: items, err := backendStore.ListComments(r.Context(), contentID) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true case http.MethodPost: user, ok := requireAuth(w, r) if !ok { return true } var payload struct { Text string `json:"text"` } if !decodeJSON(w, r, &payload) { return true } if strings.TrimSpace(payload.Text) == "" { writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым") return true } item, err := backendStore.AddComment(r.Context(), contentID, user, strings.TrimSpace(payload.Text)) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusCreated, map[string]any{"item": item}) return true default: return false } } func handleAnalytics(w http.ResponseWriter, r *http.Request, path string) bool { if r.Method != http.MethodGet || (path != "/analytics/summary" && path != "/admin/dashboard") { return false } user, ok := requireAuth(w, r) if !ok { return true } items, err := backendStore.ListContent(r.Context(), ContentFilter{}) if err != nil { writeStoreError(w, err) return true } speakers, err := backendStore.ListSpeakers(r.Context()) if err != nil { writeStoreError(w, err) return true } users, err := backendStore.ListUsers(r.Context()) if err != nil { writeStoreError(w, err) return true } comments, err := backendStore.CountComments(r.Context()) if err != nil { writeStoreError(w, err) return true } totalViews := 0 moderationQueue := 0 for _, item := range items { totalViews += item.Views if item.Status != ContentStatusPublished { moderationQueue++ } } subscribers := 0 for _, speaker := range speakers { subscribers += speaker.Subscribers } if path == "/admin/dashboard" { writeJSON(w, http.StatusOK, map[string]any{ "users": len(users), "materials": len(items), "views": totalViews, "pending": moderationQueue, "viewsByDay": []map[string]any{ {"label": "Пн", "value": 120}, {"label": "Вт", "value": 180}, {"label": "Ср", "value": 160}, {"label": "Чт", "value": 220}, {"label": "Пт", "value": 260}, {"label": "Сб", "value": 140}, {"label": "Вс", "value": 110}, }, "roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser}, }) return true } popular := append([]ContentItem(nil), items...) materials := 0 for _, item := range items { if item.Author == user.Name { materials++ } } writeJSON(w, http.StatusOK, map[string]any{"materials": materials, "comments": comments, "totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(users), "popular": popular}) return true } func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool { if r.Method == http.MethodGet && (path == "/admin/audit" || path == "/audit") { if _, ok := requireRole(w, r, RoleAdministrator); !ok { return true } items, err := backendStore.ListAudit(r.Context()) if err != nil { writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"items": items}) return true } return false } func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { defer r.Body.Close() decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { writeAPIError(w, http.StatusBadRequest, "INVALID_JSON", "Некорректный JSON-запрос") return false } return true } func writeAPIError(w http.ResponseWriter, status int, code, message string) { payload := apiError{} payload.Error.Code = code payload.Error.Message = message writeJSON(w, status, payload) } func writeStoreError(w http.ResponseWriter, err error) { writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error()) } func makeToken(user UserProfile) (string, error) { payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles, ExpiresAt: time.Now().UTC().Add(tokenTTL()).Unix()} return signToken(payload) } func userFromRequest(r *http.Request) (UserProfile, bool) { header := r.Header.Get("Authorization") if !strings.HasPrefix(header, "Bearer ") { return UserProfile{}, false } token := strings.TrimPrefix(header, "Bearer ") payload, err := parseToken(token) if err != nil { return UserProfile{}, false } user, ok, err := backendStore.UserByID(r.Context(), payload.ID) if err != nil { return UserProfile{}, false } if !ok { return UserProfile{}, false } return user, true } func requireAuth(w http.ResponseWriter, r *http.Request) (UserProfile, bool) { user, ok := userFromRequest(r) if !ok { writeAPIError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Требуется аутентификация") return UserProfile{}, false } return user, true } func requireRole(w http.ResponseWriter, r *http.Request, role RoleCode) (UserProfile, bool) { return requireAnyRole(w, r, role) } func requireAnyRole(w http.ResponseWriter, r *http.Request, roles ...RoleCode) (UserProfile, bool) { user, ok := requireAuth(w, r) if !ok { return UserProfile{}, false } for _, actual := range user.Roles { for _, expected := range roles { if actual == expected { return user, true } } } writeAPIError(w, http.StatusForbidden, "FORBIDDEN", "Недостаточно прав доступа") return UserProfile{}, false } func firstNonEmpty(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } func firstStatus(value, fallback ContentStatus) ContentStatus { if strings.TrimSpace(string(value)) == "" { return fallback } return value } func inferMediaKind(mimeType, fileName string) string { lowerName := strings.ToLower(fileName) switch { case strings.HasPrefix(mimeType, "image/"): return "image" case strings.HasPrefix(mimeType, "video/"): return "video" case strings.HasPrefix(mimeType, "audio/"): return "audio" case mimeType == "application/pdf" || strings.HasSuffix(lowerName, ".pdf") || strings.HasSuffix(lowerName, ".doc") || strings.HasSuffix(lowerName, ".docx") || strings.HasSuffix(lowerName, ".ppt") || strings.HasSuffix(lowerName, ".pptx") || strings.HasSuffix(lowerName, ".xls") || strings.HasSuffix(lowerName, ".xlsx") || strings.HasSuffix(lowerName, ".txt") || strings.HasSuffix(lowerName, ".rtf"): return "document" default: return "other" } } func defaultContentTypeForMediaKind(kind string) ContentType { switch kind { case "video": return ContentTypeVideo case "audio": return ContentTypeAudio case "image": return ContentTypeGraphic default: return ContentTypeArticle } } func defaultCategoryForMediaKind(kind string) string { switch kind { case "video": return "Видео" case "audio": return "Аудио" case "image": return "Графика" default: return "Статьи" } } func isValidContentType(contentType ContentType) bool { switch contentType { case ContentTypeNews, ContentTypeArticle, ContentTypeVideo, ContentTypeAudio, ContentTypeGraphic, ContentTypeEvent: return true default: return false } } func parseTags(value string) []string { parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) tags := make([]string, 0, len(parts)) seen := map[string]bool{} for _, part := range parts { tag := strings.TrimSpace(part) if tag == "" || seen[tag] { continue } seen[tag] = true tags = append(tags, tag) } if len(tags) == 0 { return []string{"демо", "файл"} } return tags }