real back

This commit is contained in:
mixa
2026-06-22 22:39:08 +03:00
parent 27600872a8
commit c78212263b
38 changed files with 6884 additions and 479 deletions

View File

@@ -1,3 +1,23 @@
module fable/services
go 1.26
require (
github.com/google/uuid v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

42
services/go.sum Normal file
View File

@@ -0,0 +1,42 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -2,7 +2,6 @@ package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -12,10 +11,11 @@ import (
)
type tokenPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
ExpiresAt int64 `json:"exp"`
}
type apiError struct {
@@ -25,6 +25,36 @@ type apiError struct {
} `json:"error"`
}
func normalizeContentType(value string) ContentType {
switch strings.ToLower(strings.TrimSpace(value)) {
case "research":
return ContentTypeArticle
case "announcement":
return ContentTypeNews
default:
return ContentType(strings.TrimSpace(value))
}
}
func normalizeStatus(value string) ContentStatus {
switch strings.ToLower(strings.TrimSpace(value)) {
case "draft", "черновик":
return ContentStatusDraft
case "moderation", "pending", "на модерации":
return ContentStatusModeration
case "review", "на проверке":
return ContentStatusReview
case "published", "опубликовано":
return ContentStatusPublished
case "returned", "возвращен":
return ContentStatusReturned
case "archived", "архив":
return ContentStatusArchived
default:
return ContentStatus(strings.TrimSpace(value))
}
}
func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool {
path := strings.TrimPrefix(r.URL.Path, "/api")
if path == "" {
@@ -66,31 +96,49 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
case r.Method == http.MethodPost && path == "/auth/login":
var payload struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Login) == "" || strings.TrimSpace(payload.Password) == "" {
login := firstNonEmpty(payload.Login, payload.Email)
if strings.TrimSpace(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")
user, ok, err := backendStore.UserByLogin(r.Context(), login)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"token": makeToken(user), "user": user})
if !ok {
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
return true
}
if err := checkPassword(user.PasswordHash, payload.Password); err != nil {
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
return true
}
token, err := makeToken(user)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
return true
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
return true
case r.Method == http.MethodPost && path == "/auth/register":
var payload struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Login) == "" || len(payload.Password) < 8 {
login := firstNonEmpty(payload.Login, payload.Email)
if strings.TrimSpace(login) == "" || len(payload.Password) < 8 {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
return true
}
@@ -98,8 +146,17 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
if name == "" {
name = "Демо-пользователь"
}
user := backendStore.addUser(payload.Login, name)
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "user": user})
user, err := backendStore.AddUser(r.Context(), login, name, payload.Password)
if err != nil {
writeStoreError(w, err)
return true
}
token, err := makeToken(user)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": user})
return true
case r.Method == http.MethodGet && path == "/auth/me":
user, ok := requireAuth(w, r)
@@ -112,7 +169,8 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
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 {
user, ok := requireAuth(w, r)
if !ok {
return true
}
var payload struct {
@@ -125,6 +183,10 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов")
return true
}
if err := backendStore.UpdatePassword(r.Context(), user.ID, payload.NextPassword); err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return true
default:
@@ -138,9 +200,11 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]UserProfile(nil), backendStore.Users...)
backendStore.mu.RUnlock()
items, err := backendStore.ListUsers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"):
@@ -162,25 +226,48 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
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()
query := r.URL.Query()
user, _ := userFromRequest(r)
items, err := backendStore.ListContent(r.Context(), ContentFilter{
Term: query.Get("q"),
Category: query.Get("category"),
ContentType: string(normalizeContentType(query.Get("type"))),
SortMode: query.Get("sort"),
Status: string(normalizeStatus(query.Get("status"))),
AuthorID: query.Get("authorId"),
Mine: query.Get("mine") == "true",
User: user,
Exclude: query.Get("exclude"),
Limit: func() int {
if query.Get("limit") == "" {
return 0
}
var n int
fmt.Sscanf(query.Get("limit"), "%d", &n)
return n
}(),
})
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodGet && path == "/events":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeEvent {
items = append(items, item)
}
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"})
if err != nil {
writeStoreError(w, err)
return true
}
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)
item, ok, err := backendStore.ContentByID(r.Context(), id)
if err != nil {
writeStoreError(w, err)
return true
}
if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
@@ -196,30 +283,41 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if !decodeJSON(w, r, &payload) {
return true
}
payload.Type = normalizeContentType(string(payload.Type))
payload.Status = normalizeStatus(string(payload.Status))
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
payload.Body = firstNonEmpty(payload.Body, payload.Content)
if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
return true
}
item := ContentItem{
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
Title: payload.Title,
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
Type: payload.Type,
Category: payload.Category,
Tags: payload.Tags,
Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated,
Status: 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,
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
Title: payload.Title,
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
Type: payload.Type,
Category: payload.Category,
Tags: payload.Tags,
Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated,
Status: firstStatus(payload.Status, ContentStatusDraft),
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
MediaURL: payload.MediaURL,
MediaKind: payload.MediaKind,
MimeType: payload.MimeType,
FileName: payload.FileName,
FileSize: payload.FileSize,
ModeratorComment: payload.ModeratorComment,
ReviewComment: payload.ReviewComment,
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
stored, err := backendStore.AddContent(r.Context(), item)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": stored})
return true
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok {
@@ -229,7 +327,15 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if !decodeJSON(w, r, &payload) {
return true
}
item, ok := backendStore.patchContent(strings.TrimPrefix(path, "/content/"), payload)
payload.Type = normalizeContentType(string(payload.Type))
payload.Status = normalizeStatus(string(payload.Status))
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
payload.Body = firstNonEmpty(payload.Body, payload.Content)
item, ok, err := backendStore.PatchContent(r.Context(), strings.TrimPrefix(path, "/content/"), payload)
if err != nil {
writeStoreError(w, err)
return true
}
if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
@@ -240,7 +346,12 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
if !backendStore.deleteContent(strings.TrimPrefix(path, "/content/")) {
deleted, err := backendStore.DeleteContent(r.Context(), strings.TrimPrefix(path, "/content/"))
if err != nil {
writeStoreError(w, err)
return true
}
if !deleted {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
}
@@ -255,14 +366,22 @@ 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...)})
items, err := backendStore.ListCategories(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case "/tags":
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Tags...)})
items, err := backendStore.ListTags(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
default:
return false
@@ -271,9 +390,11 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
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()
items, err := backendStore.ListSpeakers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
@@ -283,18 +404,19 @@ func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
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)
}
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"})
if err != nil {
writeStoreError(w, err)
return true
}
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/"))
file, ok, err := backendStore.FileByID(r.Context(), strings.TrimPrefix(path, "/media/files/"))
if err != nil {
writeStoreError(w, err)
return true
}
if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
return true
@@ -347,13 +469,17 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
return true
}
stored := backendStore.addFile(StoredFile{
stored, err := backendStore.AddFile(r.Context(), StoredFile{
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
Name: firstNonEmpty(header.Filename, "uploaded-file"),
MimeType: mimeType,
Size: int64(len(data)),
Data: data,
})
if err != nil {
writeStoreError(w, err)
return true
}
category := strings.TrimSpace(r.FormValue("category"))
if category == "" {
@@ -379,7 +505,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
FileName: stored.Name,
FileSize: stored.Size,
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
created, err := backendStore.AddContent(r.Context(), item)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": created})
return true
}
return false
@@ -388,7 +519,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
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"))})
items, err := backendStore.ListContent(r.Context(), ContentFilter{Term: query.Get("q"), Category: query.Get("category"), ContentType: query.Get("type"), SortMode: query.Get("sort")})
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
return false
@@ -417,7 +553,12 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.upsertSubscription(user.ID, payload.Target)})
items, err := backendStore.UpsertSubscription(r.Context(), user.ID, payload.Target)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
default:
return false
@@ -427,9 +568,11 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
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()
items, err := backendStore.ListNotifications(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"):
@@ -437,16 +580,11 @@ func handleNotification(w http.ResponseWriter, r *http.Request, path string) boo
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
}
updated, err := backendStore.MarkNotificationRead(r.Context(), id)
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
return true
default:
@@ -461,14 +599,11 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
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)
}
items, err := backendStore.ListComments(r.Context(), contentID)
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case http.MethodPost:
@@ -486,7 +621,12 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addComment(contentID, user, strings.TrimSpace(payload.Text))})
item, err := backendStore.AddComment(r.Context(), contentID, user, strings.TrimSpace(payload.Text))
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": item})
return true
default:
return false
@@ -497,29 +637,69 @@ 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 {
user, ok := requireAuth(w, r)
if !ok {
return true
}
items, err := backendStore.ListContent(r.Context(), ContentFilter{})
if err != nil {
writeStoreError(w, err)
return true
}
speakers, err := backendStore.ListSpeakers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
users, err := backendStore.ListUsers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
comments, err := backendStore.CountComments(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
totalViews := 0
moderationQueue := 0
for _, item := range backendStore.Content {
for _, item := range items {
totalViews += item.Views
if item.Status != ContentStatusPublished {
moderationQueue++
}
}
subscribers := 0
for _, speaker := range backendStore.Speakers {
for _, speaker := range 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}})
writeJSON(w, http.StatusOK, map[string]any{
"users": len(users),
"materials": len(items),
"views": totalViews,
"pending": moderationQueue,
"viewsByDay": []map[string]any{
{"label": "Пн", "value": 120},
{"label": "Вт", "value": 180},
{"label": "Ср", "value": 160},
{"label": "Чт", "value": 220},
{"label": "Пт", "value": 260},
{"label": "Сб", "value": 140},
{"label": "Вс", "value": 110},
},
"roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser},
})
return true
}
popular := append([]ContentItem(nil), backendStore.Content...)
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.Users), "popular": popular})
popular := append([]ContentItem(nil), items...)
materials := 0
for _, item := range items {
if item.Author == user.Name {
materials++
}
}
writeJSON(w, http.StatusOK, map[string]any{"materials": materials, "comments": comments, "totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(users), "popular": popular})
return true
}
@@ -528,9 +708,11 @@ func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]AuditItem(nil), backendStore.Audit...)
backendStore.mu.RUnlock()
items, err := backendStore.ListAudit(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
@@ -555,11 +737,13 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
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 writeStoreError(w http.ResponseWriter, err error) {
writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error())
}
func makeToken(user UserProfile) (string, error) {
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles, ExpiresAt: time.Now().UTC().Add(tokenTTL()).Unix()}
return signToken(payload)
}
func userFromRequest(r *http.Request) (UserProfile, bool) {
@@ -568,30 +752,16 @@ func userFromRequest(r *http.Request) (UserProfile, bool) {
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)
payload, err := parseToken(token)
if err != nil {
return UserProfile{}, false
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); err != nil {
user, ok, err := backendStore.UserByID(r.Context(), payload.ID)
if 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
}
if !ok {
return UserProfile{}, false
}
return user, true
}
@@ -632,6 +802,13 @@ func firstNonEmpty(value, fallback string) string {
return value
}
func firstStatus(value, fallback ContentStatus) ContentStatus {
if strings.TrimSpace(string(value)) == "" {
return fallback
}
return value
}
func inferMediaKind(mimeType, fileName string) string {
lowerName := strings.ToLower(fileName)
switch {

View File

@@ -0,0 +1,89 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const defaultTokenTTL = 24 * time.Hour
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bytes), nil
}
func checkPassword(hash, password string) error {
if strings.TrimSpace(hash) == "" {
return errors.New("password hash is empty")
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func tokenSecret() string {
secret := strings.TrimSpace(os.Getenv("TOKEN_SECRET"))
if secret == "" {
return "local-dev-secret-change-me"
}
return secret
}
func tokenTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("TOKEN_TTL"))
if raw == "" {
return defaultTokenTTL
}
ttl, err := time.ParseDuration(raw)
if err != nil || ttl <= 0 {
return defaultTokenTTL
}
return ttl
}
func signToken(payload tokenPayload) (string, error) {
bytes, err := json.Marshal(payload)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(bytes)
signature := computeTokenSignature(body)
return "fable-token." + body + "." + signature, nil
}
func parseToken(token string) (tokenPayload, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 || parts[0] != "fable-token" {
return tokenPayload{}, errors.New("invalid token format")
}
if !hmac.Equal([]byte(parts[2]), []byte(computeTokenSignature(parts[1]))) {
return tokenPayload{}, errors.New("invalid token signature")
}
bytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return tokenPayload{}, err
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); err != nil {
return tokenPayload{}, err
}
if payload.ExpiresAt > 0 && time.Now().UTC().Unix() > payload.ExpiresAt {
return tokenPayload{}, errors.New("token expired")
}
return payload, nil
}
func computeTokenSignature(body string) string {
mac := hmac.New(sha256.New, []byte(tokenSecret()))
_, _ = mac.Write([]byte(body))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View File

@@ -0,0 +1,127 @@
package service
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthFlowRegisterLoginAndMe(t *testing.T) {
previous := backendStore
backendStore = newTestGORMStore(t)
defer func() { backendStore = previous }()
handler := NewHandler(Config{Name: "Auth Service", Domain: "auth"})
register := performJSONRequest(t, handler, http.MethodPost, "/api/auth/register", map[string]string{
"login": "qa_user",
"password": "verysecret",
"name": "QA User",
}, "")
if register.Code != http.StatusCreated {
t.Fatalf("register status = %d body=%s", register.Code, register.Body.String())
}
login := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "qa_user",
"password": "verysecret",
}, "")
if login.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", login.Code, login.Body.String())
}
token := readTokenFromResponse(t, login.Body.Bytes())
me := performJSONRequest(t, handler, http.MethodGet, "/api/auth/me", nil, token)
if me.Code != http.StatusOK {
t.Fatalf("me status = %d body=%s", me.Code, me.Body.String())
}
var payload map[string]map[string]any
if err := json.Unmarshal(me.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode me response: %v", err)
}
if payload["user"]["login"] != "qa_user" {
t.Fatalf("expected qa_user, got %#v", payload["user"]["login"])
}
}
func TestAuthChangePassword(t *testing.T) {
previous := backendStore
backendStore = newTestGORMStore(t)
defer func() { backendStore = previous }()
handler := NewHandler(Config{Name: "Auth Service", Domain: "auth"})
login := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "demo_admin",
"password": "demo_password",
}, "")
if login.Code != http.StatusOK {
t.Fatalf("initial login status = %d body=%s", login.Code, login.Body.String())
}
token := readTokenFromResponse(t, login.Body.Bytes())
change := performJSONRequest(t, handler, http.MethodPost, "/api/auth/change-password", map[string]string{
"nextPassword": "demo_password_new",
}, token)
if change.Code != http.StatusOK {
t.Fatalf("change password status = %d body=%s", change.Code, change.Body.String())
}
oldLogin := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "demo_admin",
"password": "demo_password",
}, "")
if oldLogin.Code != http.StatusUnauthorized {
t.Fatalf("old password login status = %d body=%s", oldLogin.Code, oldLogin.Body.String())
}
newLogin := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "demo_admin",
"password": "demo_password_new",
}, "")
if newLogin.Code != http.StatusOK {
t.Fatalf("new password login status = %d body=%s", newLogin.Code, newLogin.Body.String())
}
}
func performJSONRequest(t *testing.T, handler http.Handler, method, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body == nil {
reader = bytes.NewReader(nil)
} else {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, reader)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
handler.ServeHTTP(recorder, request)
return recorder
}
func readTokenFromResponse(t *testing.T, body []byte) string {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode token response: %v", err)
}
token, _ := payload["token"].(string)
if token == "" {
t.Fatalf("expected token in response: %s", string(body))
}
return token
}

View File

@@ -0,0 +1,165 @@
package service
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
backendStore Store = unavailableStore{err: errors.New("backend store not initialized")}
defaultStoreOnce sync.Once
defaultStoreErr error
)
func setBackendStore(store Store) {
if store == nil {
backendStore = unavailableStore{err: errors.New("backend store not initialized")}
return
}
backendStore = store
}
func bootstrapDefaultStore() (Store, error) {
defaultStoreOnce.Do(func() {
db, err := openDBFromEnv()
if err != nil {
defaultStoreErr = err
setBackendStore(unavailableStore{err: err})
return
}
store, err := newGORMStore(db)
if err != nil {
defaultStoreErr = err
setBackendStore(unavailableStore{err: err})
return
}
setBackendStore(store)
})
return backendStore, defaultStoreErr
}
func openDBFromEnv() (*gorm.DB, error) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
return nil, errors.New("DATABASE_URL is required")
}
return openDB(dsn)
}
func openDB(dsn string) (*gorm.DB, error) {
config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
var (
db *gorm.DB
err error
)
if strings.HasPrefix(dsn, "sqlite:") || strings.HasPrefix(dsn, "file:") || dsn == ":memory:" {
db, err = gorm.Open(sqlite.Open(dsn), config)
} else {
db, err = gorm.Open(postgres.Open(dsn), config)
}
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("database handle: %w", err)
}
sqlDB.SetConnMaxLifetime(5 * time.Minute)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetMaxOpenConns(10)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
type unavailableStore struct {
err error
}
func (s unavailableStore) Ping(context.Context) error { return s.err }
func (s unavailableStore) UserByLogin(context.Context, string) (UserProfile, bool, error) {
return UserProfile{}, false, s.err
}
func (s unavailableStore) UserByID(context.Context, string) (UserProfile, bool, error) {
return UserProfile{}, false, s.err
}
func (s unavailableStore) AddUser(context.Context, string, string, string) (UserProfile, error) {
return UserProfile{}, s.err
}
func (s unavailableStore) UpdatePassword(context.Context, string, string) error { return s.err }
func (s unavailableStore) ListUsers(context.Context) ([]UserProfile, error) { return nil, s.err }
func (s unavailableStore) ListContent(context.Context, ContentFilter) ([]ContentItem, error) {
return nil, s.err
}
func (s unavailableStore) ContentByID(context.Context, string) (ContentItem, bool, error) {
return ContentItem{}, false, s.err
}
func (s unavailableStore) AddContent(context.Context, ContentItem) (ContentItem, error) {
return ContentItem{}, s.err
}
func (s unavailableStore) PatchContent(context.Context, string, ContentItem) (ContentItem, bool, error) {
return ContentItem{}, false, s.err
}
func (s unavailableStore) DeleteContent(context.Context, string) (bool, error) { return false, s.err }
func (s unavailableStore) ListCategories(context.Context) ([]string, error) { return nil, s.err }
func (s unavailableStore) ListTags(context.Context) ([]string, error) { return nil, s.err }
func (s unavailableStore) ListSpeakers(context.Context) ([]Speaker, error) { return nil, s.err }
func (s unavailableStore) AddFile(context.Context, StoredFile) (StoredFile, error) {
return StoredFile{}, s.err
}
func (s unavailableStore) FileByID(context.Context, string) (StoredFile, bool, error) {
return StoredFile{}, false, s.err
}
func (s unavailableStore) UpsertSubscription(context.Context, string, string) ([]string, error) {
return nil, s.err
}
func (s unavailableStore) ListNotifications(context.Context) ([]NotificationItem, error) {
return nil, s.err
}
func (s unavailableStore) MarkNotificationRead(context.Context, string) (*NotificationItem, error) {
return nil, s.err
}
func (s unavailableStore) ListComments(context.Context, string) ([]CommentItem, error) {
return nil, s.err
}
func (s unavailableStore) AddComment(context.Context, string, UserProfile, string) (CommentItem, error) {
return CommentItem{}, s.err
}
func (s unavailableStore) CountComments(context.Context) (int, error) { return 0, s.err }
func (s unavailableStore) ListAudit(context.Context) ([]AuditItem, error) { return nil, s.err }

View File

@@ -0,0 +1,121 @@
package service
import "time"
type seedUser struct {
ID string
Name string
Login string
Email string
Password string
Roles []RoleCode
Subscriptions []string
}
type seedContent struct {
ID string
Title string
Lead string
Body string
Type ContentType
Category string
Tags []string
AuthorLogin string
PublishedAt string
Duration string
Visibility Visibility
Status ContentStatus
Views int
ImageTone string
ModeratorComment string
ReviewComment string
}
type seedNotification struct {
ID string
UserLogin string
Title string
Description string
Read bool
CreatedAt string
}
type seedComment struct {
ID string
ContentID string
AuthorLogin string
Text string
CreatedAt string
}
type seedAudit struct {
ID string
ActorLogin string
Action string
Target string
CreatedAt string
}
type seedSpeaker struct {
ID string
Name string
Role string
Topics []string
Materials int
Subscribers int
}
var seedUsers = []seedUser{
{ID: "11111111-1111-1111-1111-111111111111", Name: "Демо-администратор", Login: "demo_admin", Email: "admin@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
{ID: "22222222-2222-2222-2222-222222222222", Name: "Демо-редактор", Login: "demo_editor", Email: "editor@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
{ID: "33333333-3333-3333-3333-333333333333", Name: "Демо-модератор", Login: "demo_moderator", Email: "moderator@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleManager}, Subscriptions: []string{"Мероприятия"}},
{ID: "44444444-4444-4444-4444-444444444444", Name: "Демо-пользователь", Login: "demo_user", Email: "user@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
}
var seedCategories = []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"}
var seedTags = []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"}
var seedSpeakers = []seedSpeaker{
{ID: "55555555-5555-5555-5555-555555555551", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
{ID: "55555555-5555-5555-5555-555555555552", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
{ID: "55555555-5555-5555-5555-555555555553", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
}
var seedContentItems = []seedContent{
{ID: "66666666-6666-6666-6666-666666666661", Title: "Демо-новость о запуске медиаплатформы", Lead: "Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.", Body: "Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.", Type: ContentTypeNews, Category: "Новости", Tags: []string{"медиапроизводство", "анонс"}, AuthorLogin: "demo_admin", PublishedAt: "2026-06-04", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 1240, ImageTone: "from-university-700 via-university-500 to-sky-300"},
{ID: "66666666-6666-6666-6666-666666666662", Title: "Демо-видео: открытая лекция", Lead: "Видеоматериал с метаданными, статусом проверки, категорией и тегами.", Body: "В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.", Type: ContentTypeVideo, Category: "Видео", Tags: []string{"образование", "архив"}, AuthorLogin: "demo_admin", PublishedAt: "2026-06-09", Duration: "18:40", Visibility: VisibilityAuthenticated, Status: ContentStatusReview, Views: 382, ImageTone: "from-indigo-700 via-university-800 to-cyan-500"},
{ID: "66666666-6666-6666-6666-666666666663", Title: "Демо-аудио: выпуск университетского радио", Lead: "Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.", Body: "Этот пример показывает карточку аудио без использования реального названия передачи или записи.", Type: ContentTypeAudio, Category: "Аудио", Tags: []string{"интервью", "редакция"}, AuthorLogin: "demo_editor", PublishedAt: "2026-06-11", Duration: "32:10", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 715, ImageTone: "from-blue-950 via-blue-700 to-emerald-300"},
{ID: "66666666-6666-6666-6666-666666666664", Title: "Демо-графика: афиша редакционного события", Lead: "Графические материалы можно фильтровать по типу, дате, категории и тегам.", Body: "Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.", Type: ContentTypeGraphic, Category: "Графика", Tags: []string{"анонс", "редакция"}, AuthorLogin: "demo_editor", PublishedAt: "2026-06-13", Visibility: VisibilityRole, Status: ContentStatusModeration, Views: 96, ImageTone: "from-sky-400 via-blue-600 to-slate-900"},
{ID: "66666666-6666-6666-6666-666666666665", Title: "Демо-анонс медиавстречи со спикером", Lead: "Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.", Body: "События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.", Type: ContentTypeEvent, Category: "Мероприятия", Tags: []string{"анонс", "интервью"}, AuthorLogin: "demo_moderator", PublishedAt: "2026-06-17", Visibility: VisibilityPublic, Status: ContentStatusDraft, Views: 45, ImageTone: "from-university-900 via-violet-700 to-orange-300"},
}
var seedNotifications = []seedNotification{
{ID: "77777777-7777-7777-7777-777777777771", UserLogin: "demo_admin", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"},
{ID: "77777777-7777-7777-7777-777777777772", UserLogin: "demo_editor", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"},
}
var seedComments = []seedComment{
{ID: "88888888-8888-8888-8888-888888888881", ContentID: "66666666-6666-6666-6666-666666666661", AuthorLogin: "demo_user", Text: "Комментарий доступен авторизованным пользователям и может проходить модерацию.", CreatedAt: "2026-06-13 12:00"},
}
var seedAuditTrail = []seedAudit{
{ID: "99999999-9999-9999-9999-999999999991", ActorLogin: "demo_admin", Action: "изменил статус", Target: "Демо-видео: открытая лекция", CreatedAt: "2026-06-13 11:10"},
{ID: "99999999-9999-9999-9999-999999999992", ActorLogin: "demo_editor", Action: "создал черновик", Target: "Демо-анонс медиавстречи со спикером", CreatedAt: "2026-06-12 15:35"},
}
func mustParseDate(value string) time.Time {
ts, err := time.Parse("2006-01-02", value)
if err != nil {
panic(err)
}
return ts.UTC()
}
func mustParseMinute(value string) time.Time {
ts, err := time.Parse("2006-01-02 15:04", value)
if err != nil {
panic(err)
}
return ts.UTC()
}

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"encoding/json"
"log"
"net/http"
@@ -22,6 +23,7 @@ type response struct {
Service string `json:"service"`
Domain string `json:"domain"`
Capabilities []string `json:"capabilities,omitempty"`
Error string `json:"error,omitempty"`
Time time.Time `json:"time"`
}
@@ -45,6 +47,9 @@ func MustRun(cfg Config) {
// Run starts an HTTP server for an internal service.
func Run(cfg Config) error {
if _, err := bootstrapDefaultStore(); err != nil {
return err
}
port := EnvPort(cfg.DefaultPort)
server := &http.Server{
Addr: ":" + port,
@@ -58,6 +63,9 @@ func Run(cfg Config) error {
// NewHandler returns the standard internal service HTTP API.
func NewHandler(cfg Config) http.Handler {
if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" {
_, _ = bootstrapDefaultStore()
}
if cfg.Name == "" {
cfg.Name = "Fable Service"
}
@@ -67,22 +75,42 @@ func NewHandler(cfg Config) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{
store := backendStore
status := http.StatusOK
payload := response{
Status: "ok",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
Time: time.Now().UTC(),
})
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := store.Ping(ctx); err != nil {
status = http.StatusServiceUnavailable
payload.Status = "degraded"
payload.Error = err.Error()
}
writeJSON(w, status, payload)
})
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{
store := backendStore
status := http.StatusOK
payload := response{
Status: "ready",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
Time: time.Now().UTC(),
})
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := store.Ping(ctx); err != nil {
status = http.StatusServiceUnavailable
payload.Status = "not_ready"
payload.Error = err.Error()
}
writeJSON(w, status, payload)
})
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, cfg)

View File

@@ -8,6 +8,10 @@ import (
)
func TestHealthEndpoint(t *testing.T) {
previous := backendStore
backendStore = newTestGORMStore(t)
defer func() { backendStore = previous }()
handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)

View File

@@ -1,229 +1,44 @@
package service
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
import "context"
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
type ContentFilter struct {
Term string
Category string
ContentType string
SortMode string
Status string
AuthorID string
Mine bool
User UserProfile
Exclude string
Limit int
OnlyEvents bool
OnlyMedia bool
}
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
type Store interface {
Ping(ctx context.Context) error
UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
UserByID(ctx context.Context, id string) (UserProfile, bool, error)
AddUser(ctx context.Context, login, name, password string) (UserProfile, error)
UpdatePassword(ctx context.Context, userID, password string) error
ListUsers(ctx context.Context) ([]UserProfile, error)
ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
ContentByID(ctx context.Context, id string) (ContentItem, bool, error)
AddContent(ctx context.Context, item ContentItem) (ContentItem, error)
PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error)
DeleteContent(ctx context.Context, id string) (bool, error)
ListCategories(ctx context.Context) ([]string, error)
ListTags(ctx context.Context) ([]string, error)
ListSpeakers(ctx context.Context) ([]Speaker, error)
AddFile(ctx context.Context, file StoredFile) (StoredFile, error)
FileByID(ctx context.Context, id string) (StoredFile, bool, error)
UpsertSubscription(ctx context.Context, userID, target string) ([]string, error)
ListNotifications(ctx context.Context) ([]NotificationItem, error)
MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error)
ListComments(ctx context.Context, contentID string) ([]CommentItem, error)
AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error)
CountComments(ctx context.Context) (int, error)
ListAudit(ctx context.Context) ([]AuditItem, error)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
package service
import "testing"
func TestBootstrapStoreLoadsSeededUsersAndContent(t *testing.T) {
store := newTestGORMStore(t)
user, ok, err := store.UserByLogin(t.Context(), "demo_admin")
if err != nil {
t.Fatalf("user by login: %v", err)
}
if !ok {
t.Fatal("expected seeded admin user")
}
if user.Name == "" {
t.Fatal("expected seeded admin profile")
}
items, err := store.ListContent(t.Context(), ContentFilter{})
if err != nil {
t.Fatalf("list content: %v", err)
}
if len(items) == 0 {
t.Fatal("expected seeded content")
}
if items[0].ID == "" {
t.Fatal("expected persisted content ids")
}
}
func newTestGORMStore(t *testing.T) *gormStore {
t.Helper()
db, err := openDB("file::memory:?cache=shared")
if err != nil {
t.Fatalf("open test db: %v", err)
}
store, err := newGORMStore(db)
if err != nil {
t.Fatalf("new gorm store: %v", err)
}
return store
}

View File

@@ -18,6 +18,7 @@ const (
ContentStatusModeration ContentStatus = "На модерации"
ContentStatusReview ContentStatus = "На проверке"
ContentStatusPublished ContentStatus = "Опубликовано"
ContentStatusReturned ContentStatus = "Возвращен"
ContentStatusArchived ContentStatus = "Архив"
)
@@ -39,25 +40,33 @@ const (
)
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"`
ID string `json:"id"`
Title string `json:"title"`
Lead string `json:"lead"`
Body string `json:"body"`
Excerpt string `json:"excerpt,omitempty"`
Content string `json:"content,omitempty"`
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"`
ModeratorComment string `json:"moderatorComment,omitempty"`
ReviewComment string `json:"reviewComment,omitempty"`
Rating int `json:"rating,omitempty"`
RatingAverage float64 `json:"ratingAverage,omitempty"`
RatingCount int `json:"ratingCount,omitempty"`
MyRating int `json:"myRating,omitempty"`
}
type StoredFile struct {
@@ -81,6 +90,7 @@ type UserProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
PasswordHash string `json:"-"`
Roles []RoleCode `json:"roles"`
Subscriptions []string `json:"subscriptions"`
}