first commit 2
This commit is contained in:
17
services/cmd/analytics/main.go
Normal file
17
services/cmd/analytics/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Analytics Service",
|
||||
Domain: "analytics",
|
||||
DefaultPort: "8090",
|
||||
Capabilities: []string{
|
||||
"views",
|
||||
"user-activity",
|
||||
"subscribers",
|
||||
"reports",
|
||||
},
|
||||
})
|
||||
}
|
||||
15
services/cmd/audit/main.go
Normal file
15
services/cmd/audit/main.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Audit Service",
|
||||
Domain: "audit",
|
||||
DefaultPort: "8091",
|
||||
Capabilities: []string{
|
||||
"action-log",
|
||||
"admin-log",
|
||||
},
|
||||
})
|
||||
}
|
||||
17
services/cmd/auth/main.go
Normal file
17
services/cmd/auth/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Auth Service",
|
||||
Domain: "auth",
|
||||
DefaultPort: "8081",
|
||||
Capabilities: []string{
|
||||
"registration",
|
||||
"login",
|
||||
"token-authentication",
|
||||
"password-change",
|
||||
},
|
||||
})
|
||||
}
|
||||
15
services/cmd/comment/main.go
Normal file
15
services/cmd/comment/main.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Comment Service",
|
||||
Domain: "comment",
|
||||
DefaultPort: "8088",
|
||||
Capabilities: []string{
|
||||
"comments",
|
||||
"comment-moderation-hooks",
|
||||
},
|
||||
})
|
||||
}
|
||||
17
services/cmd/content/main.go
Normal file
17
services/cmd/content/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Content Service",
|
||||
Domain: "content",
|
||||
DefaultPort: "8083",
|
||||
Capabilities: []string{
|
||||
"publications",
|
||||
"drafts",
|
||||
"moderation",
|
||||
"publication-lifecycle",
|
||||
},
|
||||
})
|
||||
}
|
||||
16
services/cmd/media/main.go
Normal file
16
services/cmd/media/main.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Media Service",
|
||||
Domain: "media",
|
||||
DefaultPort: "8092",
|
||||
Capabilities: []string{
|
||||
"upload-validation",
|
||||
"file-metadata",
|
||||
"cdn-links",
|
||||
},
|
||||
})
|
||||
}
|
||||
16
services/cmd/notification/main.go
Normal file
16
services/cmd/notification/main.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Notification Service",
|
||||
Domain: "notification",
|
||||
DefaultPort: "8087",
|
||||
Capabilities: []string{
|
||||
"subscription-notifications",
|
||||
"read-status",
|
||||
"notification-center",
|
||||
},
|
||||
})
|
||||
}
|
||||
16
services/cmd/search/main.go
Normal file
16
services/cmd/search/main.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Search Service",
|
||||
Domain: "search",
|
||||
DefaultPort: "8089",
|
||||
Capabilities: []string{
|
||||
"full-text-search",
|
||||
"filters",
|
||||
"sorting",
|
||||
},
|
||||
})
|
||||
}
|
||||
15
services/cmd/speaker/main.go
Normal file
15
services/cmd/speaker/main.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Speaker Service",
|
||||
Domain: "speaker",
|
||||
DefaultPort: "8085",
|
||||
Capabilities: []string{
|
||||
"speaker-profiles",
|
||||
"speaker-subscriptions",
|
||||
},
|
||||
})
|
||||
}
|
||||
17
services/cmd/subscription/main.go
Normal file
17
services/cmd/subscription/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Subscription Service",
|
||||
Domain: "subscription",
|
||||
DefaultPort: "8086",
|
||||
Capabilities: []string{
|
||||
"category-subscriptions",
|
||||
"tag-subscriptions",
|
||||
"speaker-subscriptions",
|
||||
"personal-feed",
|
||||
},
|
||||
})
|
||||
}
|
||||
15
services/cmd/taxonomy/main.go
Normal file
15
services/cmd/taxonomy/main.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable Taxonomy Service",
|
||||
Domain: "taxonomy",
|
||||
DefaultPort: "8084",
|
||||
Capabilities: []string{
|
||||
"categories",
|
||||
"tags",
|
||||
},
|
||||
})
|
||||
}
|
||||
17
services/cmd/user/main.go
Normal file
17
services/cmd/user/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "fable/services/internal/service"
|
||||
|
||||
func main() {
|
||||
service.MustRun(service.Config{
|
||||
Name: "Fable User Service",
|
||||
Domain: "user",
|
||||
DefaultPort: "8082",
|
||||
Capabilities: []string{
|
||||
"profiles",
|
||||
"users",
|
||||
"roles",
|
||||
"permissions",
|
||||
},
|
||||
})
|
||||
}
|
||||
3
services/go.mod
Normal file
3
services/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module fable/services
|
||||
|
||||
go 1.26
|
||||
702
services/internal/service/api.go
Normal file
702
services/internal/service/api.go
Normal file
@@ -0,0 +1,702 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"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"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &payload) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(payload.Login) == "" || strings.TrimSpace(payload.Password) == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль")
|
||||
return true
|
||||
}
|
||||
user, ok := backendStore.userByLogin(payload.Login)
|
||||
if !ok {
|
||||
user, _ = backendStore.userByLogin("demo_admin")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"token": makeToken(user), "user": user})
|
||||
return true
|
||||
case r.Method == http.MethodPost && path == "/auth/register":
|
||||
var payload struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !decodeJSON(w, r, &payload) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(payload.Login) == "" || len(payload.Password) < 8 {
|
||||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
|
||||
return true
|
||||
}
|
||||
name := strings.TrimSpace(payload.Name)
|
||||
if name == "" {
|
||||
name = "Демо-пользователь"
|
||||
}
|
||||
user := backendStore.addUser(payload.Login, name)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "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":
|
||||
if _, ok := requireAuth(w, r); !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
|
||||
}
|
||||
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
|
||||
}
|
||||
backendStore.mu.RLock()
|
||||
items := append([]UserProfile(nil), backendStore.Users...)
|
||||
backendStore.mu.RUnlock()
|
||||
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":
|
||||
backendStore.mu.RLock()
|
||||
items := append([]ContentItem(nil), backendStore.Content...)
|
||||
backendStore.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
return true
|
||||
case r.Method == http.MethodGet && path == "/events":
|
||||
backendStore.mu.RLock()
|
||||
items := make([]ContentItem, 0)
|
||||
for _, item := range backendStore.Content {
|
||||
if item.Type == ContentTypeEvent {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
backendStore.mu.RUnlock()
|
||||
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 := backendStore.contentByID(id)
|
||||
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
|
||||
}
|
||||
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: 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,
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
|
||||
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
|
||||
}
|
||||
item, ok := backendStore.patchContent(strings.TrimPrefix(path, "/content/"), payload)
|
||||
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
|
||||
}
|
||||
if !backendStore.deleteContent(strings.TrimPrefix(path, "/content/")) {
|
||||
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
|
||||
}
|
||||
backendStore.mu.RLock()
|
||||
defer backendStore.mu.RUnlock()
|
||||
switch path {
|
||||
case "/categories":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Categories...)})
|
||||
return true
|
||||
case "/tags":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Tags...)})
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||
if r.Method == http.MethodGet && path == "/speakers" {
|
||||
backendStore.mu.RLock()
|
||||
items := append([]Speaker(nil), backendStore.Speakers...)
|
||||
backendStore.mu.RUnlock()
|
||||
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":
|
||||
backendStore.mu.RLock()
|
||||
items := make([]ContentItem, 0)
|
||||
for _, item := range backendStore.Content {
|
||||
if item.Type == ContentTypeVideo || item.Type == ContentTypeAudio || item.Type == ContentTypeGraphic {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
backendStore.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
return true
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"):
|
||||
file, ok := backendStore.fileByID(strings.TrimPrefix(path, "/media/files/"))
|
||||
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 := backendStore.addFile(StoredFile{
|
||||
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
|
||||
Name: firstNonEmpty(header.Filename, "uploaded-file"),
|
||||
MimeType: mimeType,
|
||||
Size: int64(len(data)),
|
||||
Data: data,
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
|
||||
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()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.searchContent(query.Get("q"), query.Get("category"), query.Get("type"), query.Get("sort"))})
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.upsertSubscription(user.ID, payload.Target)})
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/notifications":
|
||||
backendStore.mu.RLock()
|
||||
items := append([]NotificationItem(nil), backendStore.Notifications...)
|
||||
backendStore.mu.RUnlock()
|
||||
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")
|
||||
backendStore.mu.Lock()
|
||||
var updated *NotificationItem
|
||||
for index := range backendStore.Notifications {
|
||||
if backendStore.Notifications[index].ID == id {
|
||||
backendStore.Notifications[index].Read = true
|
||||
updated = &backendStore.Notifications[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
backendStore.mu.Unlock()
|
||||
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:
|
||||
backendStore.mu.RLock()
|
||||
items := make([]CommentItem, 0)
|
||||
for _, comment := range backendStore.Comments {
|
||||
if comment.ContentID == contentID {
|
||||
items = append(items, comment)
|
||||
}
|
||||
}
|
||||
backendStore.mu.RUnlock()
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addComment(contentID, user, strings.TrimSpace(payload.Text))})
|
||||
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
|
||||
}
|
||||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||||
return true
|
||||
}
|
||||
backendStore.mu.RLock()
|
||||
defer backendStore.mu.RUnlock()
|
||||
totalViews := 0
|
||||
moderationQueue := 0
|
||||
for _, item := range backendStore.Content {
|
||||
totalViews += item.Views
|
||||
if item.Status != ContentStatusPublished {
|
||||
moderationQueue++
|
||||
}
|
||||
}
|
||||
subscribers := 0
|
||||
for _, speaker := range backendStore.Speakers {
|
||||
subscribers += speaker.Subscribers
|
||||
}
|
||||
if path == "/admin/dashboard" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": len(backendStore.Users), "content": len(backendStore.Content), "moderationQueue": moderationQueue, "roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser}})
|
||||
return true
|
||||
}
|
||||
popular := append([]ContentItem(nil), backendStore.Content...)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.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
|
||||
}
|
||||
backendStore.mu.RLock()
|
||||
items := append([]AuditItem(nil), backendStore.Audit...)
|
||||
backendStore.mu.RUnlock()
|
||||
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 makeToken(user UserProfile) string {
|
||||
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles}
|
||||
bytes, _ := json.Marshal(payload)
|
||||
// Demo token only. Production must use signed JWT or another verified token format.
|
||||
return "demo-token-" + base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
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 ")
|
||||
if token == "demo-token-local-fallback" {
|
||||
user, ok := backendStore.userByLogin("demo_admin")
|
||||
return user, ok
|
||||
}
|
||||
if !strings.HasPrefix(token, "demo-token-") {
|
||||
return UserProfile{}, false
|
||||
}
|
||||
encoded := strings.TrimPrefix(token, "demo-token-")
|
||||
bytes, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return UserProfile{}, false
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(bytes, &payload); err != nil {
|
||||
return UserProfile{}, false
|
||||
}
|
||||
user := UserProfile{ID: payload.ID, Name: payload.Name, Login: payload.Login, Roles: payload.Roles}
|
||||
backendStore.mu.RLock()
|
||||
defer backendStore.mu.RUnlock()
|
||||
for _, stored := range backendStore.Users {
|
||||
if stored.ID == user.ID {
|
||||
user.Subscriptions = append([]string(nil), stored.Subscriptions...)
|
||||
break
|
||||
}
|
||||
}
|
||||
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 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
|
||||
}
|
||||
135
services/internal/service/service.go
Normal file
135
services/internal/service/service.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config describes one internal Go service behind the Elysia gateway.
|
||||
type Config struct {
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
DefaultPort string `json:"defaultPort"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Service string `json:"service"`
|
||||
Domain string `json:"domain"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
// EnvPort reads PORT with a safe fallback for local service runs.
|
||||
func EnvPort(defaultPort string) string {
|
||||
if port := strings.TrimSpace(os.Getenv("PORT")); port != "" {
|
||||
return port
|
||||
}
|
||||
if strings.TrimSpace(defaultPort) != "" {
|
||||
return defaultPort
|
||||
}
|
||||
return "8080"
|
||||
}
|
||||
|
||||
// MustRun starts a service and terminates the process if startup fails.
|
||||
func MustRun(cfg Config) {
|
||||
if err := Run(cfg); err != nil {
|
||||
log.Fatalf("%s stopped: %v", cfg.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts an HTTP server for an internal service.
|
||||
func Run(cfg Config) error {
|
||||
port := EnvPort(cfg.DefaultPort)
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: requestIDMiddleware(loggingMiddleware(NewHandler(cfg))),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("%s listening on :%s", cfg.Name, port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
// NewHandler returns the standard internal service HTTP API.
|
||||
func NewHandler(cfg Config) http.Handler {
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = "Fable Service"
|
||||
}
|
||||
if cfg.Domain == "" {
|
||||
cfg.Domain = "unknown"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response{
|
||||
Status: "ok",
|
||||
Service: cfg.Name,
|
||||
Domain: cfg.Domain,
|
||||
Capabilities: cfg.Capabilities,
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response{
|
||||
Status: "ready",
|
||||
Service: cfg.Name,
|
||||
Domain: cfg.Domain,
|
||||
Capabilities: cfg.Capabilities,
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, cfg)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if routeAPI(w, r, cfg) {
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "route not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response{
|
||||
Status: "ok",
|
||||
Service: cfg.Name,
|
||||
Domain: cfg.Domain,
|
||||
Capabilities: cfg.Capabilities,
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
log.Printf("write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
requestID = time.Now().UTC().Format("20060102150405.000000000")
|
||||
}
|
||||
w.Header().Set("X-Request-Id", requestID)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(started))
|
||||
})
|
||||
}
|
||||
28
services/internal/service/service_test.go
Normal file
28
services/internal/service/service_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
var body response
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Status != "ok" || body.Domain != "test" {
|
||||
t.Fatalf("unexpected body: %+v", body)
|
||||
}
|
||||
}
|
||||
229
services/internal/service/store.go
Normal file
229
services/internal/service/store.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var backendStore = newDemoStore()
|
||||
|
||||
type demoStore struct {
|
||||
mu sync.RWMutex
|
||||
Users []UserProfile
|
||||
Content []ContentItem
|
||||
Speakers []Speaker
|
||||
Categories []string
|
||||
Tags []string
|
||||
Notifications []NotificationItem
|
||||
Comments []CommentItem
|
||||
Audit []AuditItem
|
||||
Files map[string]StoredFile
|
||||
}
|
||||
|
||||
func newDemoStore() *demoStore {
|
||||
return &demoStore{
|
||||
Users: []UserProfile{
|
||||
{ID: "demo-user-1", Name: "Демо-администратор", Login: "demo_admin", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
|
||||
{ID: "demo-user-2", Name: "Демо-редактор", Login: "demo_editor", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
|
||||
{ID: "demo-user-3", Name: "Демо-пользователь", Login: "demo_user", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
|
||||
},
|
||||
Content: []ContentItem{
|
||||
{ID: "demo-news-1", Title: "Демо-новость о запуске медиаплатформы", Lead: "Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.", Body: "Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.", Type: ContentTypeNews, Category: "Новости", Tags: []string{"медиапроизводство", "анонс"}, Author: "Демо-редакция", PublishedAt: "2026-06-04", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 1240, ImageTone: "from-university-700 via-university-500 to-sky-300"},
|
||||
{ID: "demo-video-1", Title: "Демо-видео: открытая лекция", Lead: "Видеоматериал с метаданными, статусом проверки, категорией и тегами.", Body: "В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.", Type: ContentTypeVideo, Category: "Видео", Tags: []string{"образование", "архив"}, Author: "Демо-медиагруппа", PublishedAt: "2026-06-09", Duration: "18:40", Visibility: VisibilityAuthenticated, Status: ContentStatusReview, Views: 382, ImageTone: "from-indigo-700 via-university-800 to-cyan-500"},
|
||||
{ID: "demo-audio-1", Title: "Демо-аудио: выпуск университетского радио", Lead: "Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.", Body: "Этот пример показывает карточку аудио без использования реального названия передачи или записи.", Type: ContentTypeAudio, Category: "Аудио", Tags: []string{"интервью", "редакция"}, Author: "Демо-редактор", PublishedAt: "2026-06-11", Duration: "32:10", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 715, ImageTone: "from-blue-950 via-blue-700 to-emerald-300"},
|
||||
{ID: "demo-graphic-1", Title: "Демо-графика: афиша редакционного события", Lead: "Графические материалы можно фильтровать по типу, дате, категории и тегам.", Body: "Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.", Type: ContentTypeGraphic, Category: "Графика", Tags: []string{"анонс", "редакция"}, Author: "Демо-дизайнер", PublishedAt: "2026-06-13", Visibility: VisibilityRole, Status: ContentStatusModeration, Views: 96, ImageTone: "from-sky-400 via-blue-600 to-slate-900"},
|
||||
{ID: "demo-event-1", Title: "Демо-анонс медиавстречи со спикером", Lead: "Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.", Body: "События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.", Type: ContentTypeEvent, Category: "Мероприятия", Tags: []string{"анонс", "интервью"}, Author: "Демо-менеджер", PublishedAt: "2026-06-17", Visibility: VisibilityPublic, Status: ContentStatusDraft, Views: 45, ImageTone: "from-university-900 via-violet-700 to-orange-300"},
|
||||
},
|
||||
Speakers: []Speaker{
|
||||
{ID: "demo-speaker-1", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
|
||||
{ID: "demo-speaker-2", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
|
||||
{ID: "demo-speaker-3", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
|
||||
},
|
||||
Categories: []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"},
|
||||
Tags: []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"},
|
||||
Notifications: []NotificationItem{
|
||||
{ID: "demo-notification-1", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"},
|
||||
{ID: "demo-notification-2", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"},
|
||||
},
|
||||
Comments: []CommentItem{
|
||||
{ID: "demo-comment-1", ContentID: "demo-news-1", Author: "Демо-пользователь", Text: "Комментарий доступен авторизованным пользователям и может проходить модерацию.", CreatedAt: "2026-06-13 12:00"},
|
||||
},
|
||||
Audit: []AuditItem{
|
||||
{ID: "demo-audit-1", Actor: "Демо-администратор", Action: "изменил статус", Target: "Демо-видео: открытая лекция", CreatedAt: "2026-06-13 11:10"},
|
||||
{ID: "demo-audit-2", Actor: "Демо-редактор", Action: "создал черновик", Target: "Демо-анонс медиавстречи со спикером", CreatedAt: "2026-06-12 15:35"},
|
||||
},
|
||||
Files: map[string]StoredFile{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *demoStore) userByLogin(login string) (UserProfile, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, user := range s.Users {
|
||||
if user.Login == login {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
return UserProfile{}, false
|
||||
}
|
||||
|
||||
func (s *demoStore) upsertSubscription(userID, target string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for index := range s.Users {
|
||||
if s.Users[index].ID == userID {
|
||||
for _, subscription := range s.Users[index].Subscriptions {
|
||||
if subscription == target {
|
||||
return append([]string(nil), s.Users[index].Subscriptions...)
|
||||
}
|
||||
}
|
||||
s.Users[index].Subscriptions = append(s.Users[index].Subscriptions, target)
|
||||
return append([]string(nil), s.Users[index].Subscriptions...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *demoStore) addUser(login, name string) UserProfile {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user := UserProfile{ID: fmt.Sprintf("demo-user-%d", time.Now().UnixNano()), Name: name, Login: login, Roles: []RoleCode{RoleUser}, Subscriptions: []string{}}
|
||||
s.Users = append(s.Users, user)
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *demoStore) addContent(item ContentItem) ContentItem {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Content = append([]ContentItem{item}, s.Content...)
|
||||
s.Audit = append([]AuditItem{{ID: fmt.Sprintf("demo-audit-%d", time.Now().UnixNano()), Actor: item.Author, Action: "создал черновик", Target: item.Title, CreatedAt: time.Now().Format("2006-01-02 15:04")}}, s.Audit...)
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *demoStore) addFile(file StoredFile) StoredFile {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Files[file.ID] = file
|
||||
return file
|
||||
}
|
||||
|
||||
func (s *demoStore) fileByID(id string) (StoredFile, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
file, ok := s.Files[id]
|
||||
return file, ok
|
||||
}
|
||||
|
||||
func (s *demoStore) patchContent(id string, patch ContentItem) (ContentItem, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for index, item := range s.Content {
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if patch.Title != "" {
|
||||
item.Title = patch.Title
|
||||
}
|
||||
if patch.Lead != "" {
|
||||
item.Lead = patch.Lead
|
||||
}
|
||||
if patch.Body != "" {
|
||||
item.Body = patch.Body
|
||||
}
|
||||
if patch.Type != "" {
|
||||
item.Type = patch.Type
|
||||
}
|
||||
if patch.Category != "" {
|
||||
item.Category = patch.Category
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
item.Tags = patch.Tags
|
||||
}
|
||||
if patch.Visibility != "" {
|
||||
item.Visibility = patch.Visibility
|
||||
}
|
||||
if patch.Status != "" {
|
||||
item.Status = patch.Status
|
||||
}
|
||||
if patch.MediaURL != "" {
|
||||
item.MediaURL = patch.MediaURL
|
||||
}
|
||||
if patch.MediaKind != "" {
|
||||
item.MediaKind = patch.MediaKind
|
||||
}
|
||||
if patch.MimeType != "" {
|
||||
item.MimeType = patch.MimeType
|
||||
}
|
||||
if patch.FileName != "" {
|
||||
item.FileName = patch.FileName
|
||||
}
|
||||
if patch.FileSize != 0 {
|
||||
item.FileSize = patch.FileSize
|
||||
}
|
||||
s.Content[index] = item
|
||||
return item, true
|
||||
}
|
||||
return ContentItem{}, false
|
||||
}
|
||||
|
||||
func (s *demoStore) deleteContent(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for index, item := range s.Content {
|
||||
if item.ID == id {
|
||||
s.Content = append(s.Content[:index], s.Content[index+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *demoStore) contentByID(id string) (ContentItem, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for index, item := range s.Content {
|
||||
if item.ID == id {
|
||||
s.Content[index].Views++
|
||||
return s.Content[index], true
|
||||
}
|
||||
}
|
||||
return ContentItem{}, false
|
||||
}
|
||||
|
||||
func (s *demoStore) searchContent(term, category, contentType, sortMode string) []ContentItem {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
term = strings.ToLower(strings.TrimSpace(term))
|
||||
items := make([]ContentItem, 0, len(s.Content))
|
||||
for _, item := range s.Content {
|
||||
joined := strings.ToLower(strings.Join(append([]string{item.Title, item.Lead, item.Body, item.Author, item.Category, string(item.Status), string(item.Type)}, item.Tags...), " "))
|
||||
if term != "" && !strings.Contains(joined, term) {
|
||||
continue
|
||||
}
|
||||
if category != "" && category != "Все" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
if contentType != "" && string(item.Type) != contentType {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if sortMode == "newest" {
|
||||
return items[i].PublishedAt > items[j].PublishedAt
|
||||
}
|
||||
return items[i].Views > items[j].Views
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *demoStore) addComment(contentID string, user UserProfile, text string) CommentItem {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
comment := CommentItem{ID: fmt.Sprintf("demo-comment-%d", time.Now().UnixNano()), ContentID: contentID, Author: user.Name, Text: text, CreatedAt: time.Now().Format("2006-01-02 15:04")}
|
||||
s.Comments = append([]CommentItem{comment}, s.Comments...)
|
||||
return comment
|
||||
}
|
||||
110
services/internal/service/types.go
Normal file
110
services/internal/service/types.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
ContentTypeNews ContentType = "news"
|
||||
ContentTypeArticle ContentType = "article"
|
||||
ContentTypeVideo ContentType = "video"
|
||||
ContentTypeAudio ContentType = "audio"
|
||||
ContentTypeGraphic ContentType = "graphic"
|
||||
ContentTypeEvent ContentType = "event"
|
||||
)
|
||||
|
||||
type ContentStatus string
|
||||
|
||||
const (
|
||||
ContentStatusDraft ContentStatus = "Черновик"
|
||||
ContentStatusModeration ContentStatus = "На модерации"
|
||||
ContentStatusReview ContentStatus = "На проверке"
|
||||
ContentStatusPublished ContentStatus = "Опубликовано"
|
||||
ContentStatusArchived ContentStatus = "Архив"
|
||||
)
|
||||
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
VisibilityPublic Visibility = "Публично"
|
||||
VisibilityAuthenticated Visibility = "После входа"
|
||||
VisibilityRole Visibility = "По роли"
|
||||
)
|
||||
|
||||
type RoleCode string
|
||||
|
||||
const (
|
||||
RoleAdministrator RoleCode = "администратор"
|
||||
RoleEditor RoleCode = "редактор"
|
||||
RoleManager RoleCode = "менеджер"
|
||||
RoleUser RoleCode = "пользователь"
|
||||
)
|
||||
|
||||
type ContentItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Lead string `json:"lead"`
|
||||
Body string `json:"body"`
|
||||
Type ContentType `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Author string `json:"author"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Status ContentStatus `json:"status"`
|
||||
Views int `json:"views"`
|
||||
ImageTone string `json:"imageTone"`
|
||||
MediaURL string `json:"mediaUrl,omitempty"`
|
||||
MediaKind string `json:"mediaKind,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
FileSize int64 `json:"fileSize,omitempty"`
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
ID string
|
||||
Name string
|
||||
MimeType string
|
||||
Size int64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type Speaker struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Topics []string `json:"topics"`
|
||||
Materials int `json:"materials"`
|
||||
Subscribers int `json:"subscribers"`
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
Roles []RoleCode `json:"roles"`
|
||||
Subscriptions []string `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type NotificationItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Read bool `json:"read"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CommentItem struct {
|
||||
ID string `json:"id"`
|
||||
ContentID string `json:"contentId"`
|
||||
Author string `json:"author"`
|
||||
Text string `json:"text"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AuditItem struct {
|
||||
ID string `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user