real back
This commit is contained in:
@@ -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
42
services/go.sum
Normal 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=
|
||||
@@ -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 {
|
||||
|
||||
89
services/internal/service/auth.go
Normal file
89
services/internal/service/auth.go
Normal 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))
|
||||
}
|
||||
127
services/internal/service/auth_test.go
Normal file
127
services/internal/service/auth_test.go
Normal 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
|
||||
}
|
||||
165
services/internal/service/db.go
Normal file
165
services/internal/service/db.go
Normal 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 }
|
||||
121
services/internal/service/seed.go
Normal file
121
services/internal/service/seed.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
1238
services/internal/service/store_gorm.go
Normal file
1238
services/internal/service/store_gorm.go
Normal file
File diff suppressed because it is too large
Load Diff
42
services/internal/service/store_gorm_test.go
Normal file
42
services/internal/service/store_gorm_test.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user