1
0
forked from mixa/67

first commit 2

This commit is contained in:
mixa
2026-06-15 00:20:48 +03:00
parent 17bfa26689
commit e885e3e6fd
52 changed files with 9107 additions and 0 deletions

View 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",
},
})
}

View 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
View 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",
},
})
}

View 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",
},
})
}

View 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",
},
})
}

View 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",
},
})
}

View 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",
},
})
}

View 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",
},
})
}

View 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",
},
})
}

View 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",
},
})
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
module fable/services
go 1.26

View 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
}

View 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))
})
}

View 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)
}
}

View 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
}

View 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"`
}