package service import ( "context" "errors" "fmt" "sort" "strings" "time" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type roleRecord struct { ID string `gorm:"column:id;primaryKey"` Code string `gorm:"column:code;uniqueIndex"` Name string `gorm:"column:name"` Description string `gorm:"column:description"` CreatedAt time.Time } func (roleRecord) TableName() string { return "roles" } type userRecord struct { ID string `gorm:"column:id;primaryKey"` Login string `gorm:"column:login"` PasswordHash string `gorm:"column:password_hash"` DisplayName string `gorm:"column:display_name"` Email *string `gorm:"column:email"` IsActive bool `gorm:"column:is_active"` CreatedAt time.Time UpdatedAt time.Time Roles []roleRecord `gorm:"many2many:user_roles;joinForeignKey:UserID;joinReferences:RoleID"` Subscriptions []subscriptionRecord `gorm:"foreignKey:UserID"` } func (userRecord) TableName() string { return "users" } type categoryRecord struct { ID string `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` Description string `gorm:"column:description"` CreatedAt time.Time } func (categoryRecord) TableName() string { return "categories" } type tagRecord struct { ID string `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` CreatedAt time.Time } func (tagRecord) TableName() string { return "tags" } type speakerRecord struct { ID string `gorm:"column:id;primaryKey"` DisplayName string `gorm:"column:display_name"` RoleDescription string `gorm:"column:role_description"` Biography string `gorm:"column:biography"` TopicsCSV string `gorm:"column:topics_csv"` MaterialsCount int `gorm:"column:materials_count"` Subscribers int `gorm:"column:subscribers_count"` CreatedAt time.Time UpdatedAt time.Time } func (speakerRecord) TableName() string { return "speakers" } type contentRecord struct { ID string `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Lead string `gorm:"column:lead"` Body string `gorm:"column:body"` ContentType string `gorm:"column:content_type"` Status string `gorm:"column:status"` Visibility string `gorm:"column:visibility"` CategoryID *string `gorm:"column:category_id"` Category categoryRecord `gorm:"foreignKey:CategoryID"` AuthorUserID *string `gorm:"column:author_user_id"` AuthorUser userRecord `gorm:"foreignKey:AuthorUserID"` AuthorLabel string `gorm:"column:author_label"` SpeakerID *string `gorm:"column:speaker_id"` PublishedAt *time.Time `gorm:"column:published_at"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` ArchivedAt *time.Time `gorm:"column:archived_at"` Duration string `gorm:"column:duration"` ImageTone string `gorm:"column:image_tone"` ModeratorComment string `gorm:"column:moderator_comment"` ReviewComment string `gorm:"column:review_comment"` RatingAverage float64 `gorm:"column:rating_average"` RatingCount int `gorm:"column:rating_count"` Tags []tagRecord `gorm:"many2many:content_tags;joinForeignKey:ContentID;joinReferences:TagID"` MediaFile *mediaFileRecord `gorm:"foreignKey:ContentID"` Views int64 `gorm:"column:views_count;->"` } func (contentRecord) TableName() string { return "content_items" } type mediaFileRecord struct { ID string `gorm:"column:id;primaryKey"` ContentID *string `gorm:"column:content_id"` UploadedByUserID *string `gorm:"column:uploaded_by_user_id"` OriginalName string `gorm:"column:original_name"` MimeType string `gorm:"column:mime_type"` SizeBytes int64 `gorm:"column:size_bytes"` StorageKey string `gorm:"column:storage_key"` PublicURL string `gorm:"column:public_url"` Checksum string `gorm:"column:checksum"` CreatedAt time.Time `gorm:"column:created_at"` } func (mediaFileRecord) TableName() string { return "media_files" } type fileBlobRecord struct { ID string `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` MimeType string `gorm:"column:mime_type"` SizeBytes int64 `gorm:"column:size_bytes"` Data []byte `gorm:"column:data"` CreatedAt time.Time `gorm:"column:created_at"` } func (fileBlobRecord) TableName() string { return "stored_file_blobs" } type subscriptionRecord struct { ID string `gorm:"column:id;primaryKey"` UserID string `gorm:"column:user_id"` SubscriptionType string `gorm:"column:subscription_type"` CategoryID *string `gorm:"column:category_id"` Category categoryRecord `gorm:"foreignKey:CategoryID"` TagID *string `gorm:"column:tag_id"` Tag tagRecord `gorm:"foreignKey:TagID"` SpeakerID *string `gorm:"column:speaker_id"` Speaker speakerRecord `gorm:"foreignKey:SpeakerID"` CreatedAt time.Time `gorm:"column:created_at"` } func (subscriptionRecord) TableName() string { return "subscriptions" } type commentRecord struct { ID string `gorm:"column:id;primaryKey"` ContentID string `gorm:"column:content_id"` UserID string `gorm:"column:user_id"` User userRecord `gorm:"foreignKey:UserID"` Body string `gorm:"column:body"` Status string `gorm:"column:status"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (commentRecord) TableName() string { return "comments" } type notificationRecord struct { ID string `gorm:"column:id;primaryKey"` UserID string `gorm:"column:user_id"` Title string `gorm:"column:title"` Body string `gorm:"column:body"` IsRead bool `gorm:"column:is_read"` ContentID *string `gorm:"column:content_id"` CreatedAt time.Time `gorm:"column:created_at"` ReadAt *time.Time `gorm:"column:read_at"` } func (notificationRecord) TableName() string { return "notifications" } type actionLogRecord struct { ID string `gorm:"column:id;primaryKey"` ActorUserID *string `gorm:"column:actor_user_id"` ActorUser userRecord `gorm:"foreignKey:ActorUserID"` Action string `gorm:"column:action"` EntityType string `gorm:"column:entity_type"` EntityID *string `gorm:"column:entity_id"` RequestID string `gorm:"column:request_id"` CreatedAt time.Time `gorm:"column:created_at"` } func (actionLogRecord) TableName() string { return "action_logs" } type contentViewRecord struct { ID string `gorm:"column:id;primaryKey"` ContentID string `gorm:"column:content_id"` UserID *string `gorm:"column:user_id"` AnonymousKey *string `gorm:"column:anonymous_key"` CreatedAt time.Time `gorm:"column:created_at"` } func (contentViewRecord) TableName() string { return "content_views" } type gormStore struct { db *gorm.DB } func newGORMStore(db *gorm.DB) (*gormStore, error) { if db.Dialector.Name() == "sqlite" { if err := db.AutoMigrate( &roleRecord{}, &userRecord{}, &categoryRecord{}, &tagRecord{}, &speakerRecord{}, &contentRecord{}, &mediaFileRecord{}, &fileBlobRecord{}, &subscriptionRecord{}, &commentRecord{}, ¬ificationRecord{}, &actionLogRecord{}, &contentViewRecord{}, ); err != nil { return nil, fmt.Errorf("auto-migrate service tables: %w", err) } } else if err := ensurePostgresSchema(db); err != nil { return nil, err } store := &gormStore{db: db} if err := store.seed(context.Background()); err != nil { return nil, err } return store, nil } func ensurePostgresSchema(db *gorm.DB) error { statements := []string{ `ALTER TABLE speakers ADD COLUMN IF NOT EXISTS topics_csv TEXT NOT NULL DEFAULT ''`, `ALTER TABLE speakers ADD COLUMN IF NOT EXISTS materials_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE speakers ADD COLUMN IF NOT EXISTS subscribers_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE content_items ADD COLUMN IF NOT EXISTS duration TEXT`, `ALTER TABLE content_items ADD COLUMN IF NOT EXISTS image_tone TEXT NOT NULL DEFAULT ''`, `ALTER TABLE content_items ADD COLUMN IF NOT EXISTS moderator_comment TEXT`, `ALTER TABLE content_items ADD COLUMN IF NOT EXISTS review_comment TEXT`, `ALTER TABLE content_items ADD COLUMN IF NOT EXISTS rating_average DOUBLE PRECISION NOT NULL DEFAULT 0`, `ALTER TABLE content_items ADD COLUMN IF NOT EXISTS rating_count INTEGER NOT NULL DEFAULT 0`, `CREATE TABLE IF NOT EXISTS stored_file_blobs ( id UUID PRIMARY KEY, name TEXT NOT NULL, mime_type TEXT NOT NULL, size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), data BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, } for _, statement := range statements { if err := db.Exec(statement).Error; err != nil { return fmt.Errorf("ensure postgres schema: %w", err) } } return nil } func (s *gormStore) Ping(ctx context.Context) error { sqlDB, err := s.db.DB() if err != nil { return err } return sqlDB.PingContext(ctx) } func (s *gormStore) UserByLogin(ctx context.Context, login string) (UserProfile, bool, error) { login = resolveLoginAlias(login) var user userRecord err := s.db.WithContext(ctx). Preload("Roles"). Preload("Subscriptions.Category"). Preload("Subscriptions.Tag"). Preload("Subscriptions.Speaker"). Where("lower(login) = ?", strings.ToLower(strings.TrimSpace(login))). First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return UserProfile{}, false, nil } if err != nil { return UserProfile{}, false, err } return mapUserRecord(user), true, nil } func (s *gormStore) UserByID(ctx context.Context, id string) (UserProfile, bool, error) { var user userRecord err := s.db.WithContext(ctx). Preload("Roles"). Preload("Subscriptions.Category"). Preload("Subscriptions.Tag"). Preload("Subscriptions.Speaker"). First(&user, "id = ?", id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return UserProfile{}, false, nil } if err != nil { return UserProfile{}, false, err } return mapUserRecord(user), true, nil } func (s *gormStore) AddUser(ctx context.Context, login, name, password string) (UserProfile, error) { hash, err := hashPassword(password) if err != nil { return UserProfile{}, err } user := userRecord{ ID: uuid.NewString(), Login: strings.ToLower(strings.TrimSpace(login)), PasswordHash: hash, DisplayName: strings.TrimSpace(name), IsActive: true, } var role roleRecord if err := s.db.WithContext(ctx).Where("code = ?", roleCodeToDB(RoleUser)).First(&role).Error; err != nil { return UserProfile{}, err } if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { return UserProfile{}, err } if err := s.db.WithContext(ctx).Model(&user).Association("Roles").Append(&role); err != nil { return UserProfile{}, err } profile, _, err := s.UserByID(ctx, user.ID) if err != nil { return UserProfile{}, err } return profile, nil } func (s *gormStore) UpdatePassword(ctx context.Context, userID, password string) error { hash, err := hashPassword(password) if err != nil { return err } return s.db.WithContext(ctx).Model(&userRecord{}).Where("id = ?", userID).Updates(map[string]any{ "password_hash": hash, "updated_at": time.Now().UTC(), }).Error } func (s *gormStore) ListUsers(ctx context.Context) ([]UserProfile, error) { var users []userRecord if err := s.db.WithContext(ctx). Preload("Roles"). Preload("Subscriptions.Category"). Preload("Subscriptions.Tag"). Preload("Subscriptions.Speaker"). Order("created_at asc"). Find(&users).Error; err != nil { return nil, err } items := make([]UserProfile, 0, len(users)) for _, user := range users { items = append(items, mapUserRecord(user)) } return items, nil } func (s *gormStore) ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error) { query := s.db.WithContext(ctx). Model(&contentRecord{}). Select("content_items.*, COALESCE(view_counts.views_count, 0) AS views_count"). Joins("LEFT JOIN (SELECT content_id, COUNT(*) AS views_count FROM content_views GROUP BY content_id) AS view_counts ON view_counts.content_id = content_items.id"). Preload("Category"). Preload("AuthorUser"). Preload("Tags"). Preload("MediaFile") if term := strings.TrimSpace(filter.Term); term != "" { like := "%" + strings.ToLower(term) + "%" query = query.Joins("LEFT JOIN categories search_categories ON search_categories.id = content_items.category_id"). Where("lower(content_items.title) LIKE ? OR lower(coalesce(content_items.lead, '')) LIKE ? OR lower(coalesce(content_items.body, '')) LIKE ? OR lower(coalesce(content_items.author_label, '')) LIKE ? OR lower(coalesce(search_categories.title, '')) LIKE ?", like, like, like, like, like) } if filter.Category != "" && filter.Category != "Все" { query = query.Joins("LEFT JOIN categories filter_categories ON filter_categories.id = content_items.category_id").Where("filter_categories.title = ?", filter.Category) } if filter.ContentType != "" { query = query.Where("content_items.content_type = ?", contentTypeToDB(ContentType(filter.ContentType))) } if filter.Status != "" { query = query.Where("content_items.status = ?", contentStatusToDB(ContentStatus(filter.Status))) } if filter.AuthorID != "" { query = query.Where("content_items.author_user_id = ?", filter.AuthorID) } if filter.Mine { query = query.Where("content_items.author_user_id = ?", filter.User.ID) } if filter.Exclude != "" { query = query.Where("content_items.id <> ?", filter.Exclude) } if filter.OnlyEvents { query = query.Where("content_items.content_type = ?", contentTypeToDB(ContentTypeEvent)) } if filter.OnlyMedia { query = query.Where("content_items.content_type IN ?", []string{contentTypeToDB(ContentTypeVideo), contentTypeToDB(ContentTypeAudio), contentTypeToDB(ContentTypeGraphic)}) } if filter.SortMode == "newest" { query = query.Order("content_items.published_at desc NULLS LAST").Order("content_items.created_at desc") } else { query = query.Order("views_count desc").Order("content_items.published_at desc NULLS LAST") } if filter.Limit > 0 { query = query.Limit(filter.Limit) } var records []contentRecord if err := query.Find(&records).Error; err != nil { return nil, err } items := make([]ContentItem, 0, len(records)) for _, record := range records { items = append(items, mapContentRecord(record)) } return items, nil } func (s *gormStore) ContentByID(ctx context.Context, id string) (ContentItem, bool, error) { var exists int64 if err := s.db.WithContext(ctx).Model(&contentRecord{}).Where("id = ?", id).Count(&exists).Error; err != nil { return ContentItem{}, false, err } if exists == 0 { return ContentItem{}, false, nil } if err := s.db.WithContext(ctx).Create(&contentViewRecord{ID: uuid.NewString(), ContentID: id}).Error; err != nil { return ContentItem{}, false, err } items, err := s.ListContent(ctx, ContentFilter{}) if err != nil { return ContentItem{}, false, err } for _, item := range items { if item.ID == id { return item, true, nil } } return ContentItem{}, false, nil } func (s *gormStore) AddContent(ctx context.Context, item ContentItem) (ContentItem, error) { record, err := s.buildContentRecord(ctx, item, false) if err != nil { return ContentItem{}, err } if record.ID == "" { record.ID = uuid.NewString() } if err := s.db.WithContext(ctx).Create(&record).Error; err != nil { return ContentItem{}, err } if err := s.replaceContentTags(ctx, record.ID, item.Tags); err != nil { return ContentItem{}, err } if item.MediaURL != "" || item.FileName != "" || item.MimeType != "" || item.FileSize > 0 { if err := s.upsertMediaFile(ctx, record.ID, item); err != nil { return ContentItem{}, err } } _ = s.appendAuditLog(ctx, item.Author, "создал черновик", item.Title, record.ID) reloaded, _, err := s.reloadContent(ctx, record.ID) if err != nil { return ContentItem{}, err } return reloaded, nil } func (s *gormStore) PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error) { var record contentRecord if err := s.db.WithContext(ctx).First(&record, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) { return ContentItem{}, false, nil } else if err != nil { return ContentItem{}, false, err } updates := map[string]any{} if patch.Title != "" { updates["title"] = patch.Title } if patch.Lead != "" { updates["lead"] = patch.Lead } if patch.Body != "" { updates["body"] = patch.Body } if patch.Type != "" { updates["content_type"] = contentTypeToDB(patch.Type) } if patch.Category != "" { categoryID, err := s.lookupCategoryID(ctx, patch.Category) if err != nil { return ContentItem{}, false, err } updates["category_id"] = categoryID } if patch.Visibility != "" { updates["visibility"] = visibilityToDB(patch.Visibility) } if patch.Status != "" { updates["status"] = contentStatusToDB(patch.Status) } if patch.ModeratorComment != "" { updates["moderator_comment"] = patch.ModeratorComment updates["review_comment"] = patch.ModeratorComment } if patch.ReviewComment != "" { updates["review_comment"] = patch.ReviewComment } if patch.Rating > 0 { updates["rating_average"] = float64(patch.Rating) updates["rating_count"] = gorm.Expr("rating_count + 1") } if patch.MediaURL != "" || patch.MediaKind != "" || patch.MimeType != "" || patch.FileName != "" || patch.FileSize != 0 { if err := s.upsertMediaFile(ctx, id, patch); err != nil { return ContentItem{}, false, err } } if len(updates) > 0 { updates["updated_at"] = time.Now().UTC() if err := s.db.WithContext(ctx).Model(&contentRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return ContentItem{}, false, err } } if patch.Tags != nil { if err := s.replaceContentTags(ctx, id, patch.Tags); err != nil { return ContentItem{}, false, err } } item, ok, err := s.reloadContent(ctx, id) if err != nil || !ok { return item, ok, err } if patch.Status != "" || patch.ModeratorComment != "" || patch.ReviewComment != "" { _ = s.appendAuditLog(ctx, item.Author, "изменил статус", item.Title, id) } if patch.Rating > 0 { item.MyRating = patch.Rating } return item, true, nil } func (s *gormStore) DeleteContent(ctx context.Context, id string) (bool, error) { result := s.db.WithContext(ctx).Delete(&contentRecord{}, "id = ?", id) if result.Error != nil { return false, result.Error } return result.RowsAffected > 0, nil } func (s *gormStore) ListCategories(ctx context.Context) ([]string, error) { var categories []categoryRecord if err := s.db.WithContext(ctx).Order("title asc").Find(&categories).Error; err != nil { return nil, err } items := make([]string, 0, len(categories)) for _, category := range categories { items = append(items, category.Title) } return items, nil } func (s *gormStore) ListTags(ctx context.Context) ([]string, error) { var tags []tagRecord if err := s.db.WithContext(ctx).Order("title asc").Find(&tags).Error; err != nil { return nil, err } items := make([]string, 0, len(tags)) for _, tag := range tags { items = append(items, tag.Title) } return items, nil } func (s *gormStore) ListSpeakers(ctx context.Context) ([]Speaker, error) { var speakers []speakerRecord if err := s.db.WithContext(ctx).Order("display_name asc").Find(&speakers).Error; err != nil { return nil, err } items := make([]Speaker, 0, len(speakers)) for _, speaker := range speakers { items = append(items, Speaker{ID: speaker.ID, Name: speaker.DisplayName, Role: speaker.RoleDescription, Topics: splitCSV(speaker.TopicsCSV), Materials: speaker.MaterialsCount, Subscribers: speaker.Subscribers}) } return items, nil } func (s *gormStore) AddFile(ctx context.Context, file StoredFile) (StoredFile, error) { if file.ID == "" { file.ID = uuid.NewString() } record := fileBlobRecord{ID: file.ID, Name: file.Name, MimeType: file.MimeType, SizeBytes: file.Size, Data: file.Data} if err := s.db.WithContext(ctx).Create(&record).Error; err != nil { return StoredFile{}, err } return file, nil } func (s *gormStore) FileByID(ctx context.Context, id string) (StoredFile, bool, error) { var record fileBlobRecord if err := s.db.WithContext(ctx).First(&record, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) { return StoredFile{}, false, nil } else if err != nil { return StoredFile{}, false, err } return StoredFile{ID: record.ID, Name: record.Name, MimeType: record.MimeType, Size: record.SizeBytes, Data: record.Data}, true, nil } func (s *gormStore) UpsertSubscription(ctx context.Context, userID, target string) ([]string, error) { target = strings.TrimSpace(target) if target == "" { user, ok, err := s.UserByID(ctx, userID) return user.Subscriptions, okToErr(ok, err) } var record subscriptionRecord var existing subscriptionRecord lookup := strings.ToLower(target) if categoryID, err := s.lookupCategoryIDInsensitive(ctx, lookup); err != nil { return nil, err } else if categoryID != nil { record = subscriptionRecord{ID: uuid.NewString(), UserID: userID, SubscriptionType: "category", CategoryID: categoryID} existing = subscriptionRecord{UserID: userID, SubscriptionType: "category", CategoryID: categoryID} } else if tagID, err := s.lookupTagIDInsensitive(ctx, lookup); err != nil { return nil, err } else if tagID != nil { record = subscriptionRecord{ID: uuid.NewString(), UserID: userID, SubscriptionType: "tag", TagID: tagID} existing = subscriptionRecord{UserID: userID, SubscriptionType: "tag", TagID: tagID} } else if speakerID, err := s.lookupSpeakerIDInsensitive(ctx, lookup); err != nil { return nil, err } else if speakerID != nil { record = subscriptionRecord{ID: uuid.NewString(), UserID: userID, SubscriptionType: "speaker", SpeakerID: speakerID} existing = subscriptionRecord{UserID: userID, SubscriptionType: "speaker", SpeakerID: speakerID} } if record.ID != "" { var count int64 if err := s.db.WithContext(ctx).Model(&subscriptionRecord{}).Where(&existing).Count(&count).Error; err != nil { return nil, err } if count == 0 { if err := s.db.WithContext(ctx).Create(&record).Error; err != nil { return nil, err } } } user, ok, err := s.UserByID(ctx, userID) if err != nil { return nil, err } if !ok { return nil, nil } return user.Subscriptions, nil } func (s *gormStore) ListNotifications(ctx context.Context) ([]NotificationItem, error) { var records []notificationRecord if err := s.db.WithContext(ctx).Order("created_at desc").Find(&records).Error; err != nil { return nil, err } items := make([]NotificationItem, 0, len(records)) for _, record := range records { items = append(items, NotificationItem{ID: record.ID, Title: record.Title, Description: record.Body, Read: record.IsRead, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")}) } return items, nil } func (s *gormStore) MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error) { now := time.Now().UTC() if err := s.db.WithContext(ctx).Model(¬ificationRecord{}).Where("id = ?", id).Updates(map[string]any{"is_read": true, "read_at": &now}).Error; err != nil { return nil, err } var record notificationRecord if err := s.db.WithContext(ctx).First(&record, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } item := NotificationItem{ID: record.ID, Title: record.Title, Description: record.Body, Read: record.IsRead, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")} return &item, nil } func (s *gormStore) ListComments(ctx context.Context, contentID string) ([]CommentItem, error) { var records []commentRecord if err := s.db.WithContext(ctx).Preload("User").Where("content_id = ?", contentID).Order("created_at desc").Find(&records).Error; err != nil { return nil, err } items := make([]CommentItem, 0, len(records)) for _, record := range records { items = append(items, CommentItem{ID: record.ID, ContentID: record.ContentID, Author: record.User.DisplayName, Text: record.Body, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")}) } return items, nil } func (s *gormStore) AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error) { record := commentRecord{ID: uuid.NewString(), ContentID: contentID, UserID: user.ID, Body: text, Status: "visible"} if err := s.db.WithContext(ctx).Create(&record).Error; err != nil { return CommentItem{}, err } return CommentItem{ID: record.ID, ContentID: contentID, Author: user.Name, Text: text, CreatedAt: time.Now().UTC().Format("2006-01-02 15:04")}, nil } func (s *gormStore) CountComments(ctx context.Context) (int, error) { var count int64 if err := s.db.WithContext(ctx).Model(&commentRecord{}).Count(&count).Error; err != nil { return 0, err } return int(count), nil } func (s *gormStore) ListAudit(ctx context.Context) ([]AuditItem, error) { var records []actionLogRecord if err := s.db.WithContext(ctx).Preload("ActorUser").Order("created_at desc").Find(&records).Error; err != nil { return nil, err } items := make([]AuditItem, 0, len(records)) for _, record := range records { target := "" if record.EntityID != nil { if item, ok, err := s.reloadContent(ctx, *record.EntityID); err == nil && ok { target = item.Title } } if target == "" { target = record.EntityType } actor := record.ActorUser.DisplayName if actor == "" { actor = "Система" } items = append(items, AuditItem{ID: record.ID, Actor: actor, Action: record.Action, Target: target, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")}) } return items, nil } func (s *gormStore) reloadContent(ctx context.Context, id string) (ContentItem, bool, error) { items, err := s.ListContent(ctx, ContentFilter{}) if err != nil { return ContentItem{}, false, err } for _, item := range items { if item.ID == id { return item, true, nil } } return ContentItem{}, false, nil } func (s *gormStore) buildContentRecord(ctx context.Context, item ContentItem, preserveID bool) (contentRecord, error) { categoryID, err := s.lookupCategoryID(ctx, item.Category) if err != nil { return contentRecord{}, err } authorID, err := s.lookupUserIDByName(ctx, item.Author) if err != nil { return contentRecord{}, err } record := contentRecord{ Title: item.Title, Lead: item.Lead, Body: item.Body, ContentType: contentTypeToDB(item.Type), Status: contentStatusToDB(item.Status), Visibility: visibilityToDB(item.Visibility), CategoryID: categoryID, AuthorUserID: authorID, AuthorLabel: item.Author, Duration: item.Duration, ImageTone: item.ImageTone, ModeratorComment: item.ModeratorComment, ReviewComment: item.ReviewComment, } if preserveID { record.ID = item.ID } if item.PublishedAt != "" { ts := mustParseDate(item.PublishedAt) record.PublishedAt = &ts } if record.Status == "" { record.Status = contentStatusToDB(ContentStatusDraft) } if record.Visibility == "" { record.Visibility = visibilityToDB(VisibilityPublic) } return record, nil } func (s *gormStore) replaceContentTags(ctx context.Context, contentID string, tags []string) error { if err := s.db.WithContext(ctx).Where("content_id = ?", contentID).Delete(&struct { ContentID string `gorm:"column:content_id"` TagID string `gorm:"column:tag_id"` }{}).Error; err != nil { return err } for _, title := range tags { tagID, err := s.lookupTagID(ctx, title) if err != nil { return err } if tagID == nil { continue } if err := s.db.WithContext(ctx).Table("content_tags").Create(map[string]any{"content_id": contentID, "tag_id": *tagID}).Error; err != nil { return err } } return nil } func (s *gormStore) upsertMediaFile(ctx context.Context, contentID string, item ContentItem) error { if item.FileName == "" && item.MediaURL == "" && item.MimeType == "" && item.FileSize == 0 { return nil } var existing mediaFileRecord err := s.db.WithContext(ctx).Where("content_id = ?", contentID).First(&existing).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } record := mediaFileRecord{ ID: existing.ID, ContentID: &contentID, OriginalName: firstNonEmpty(item.FileName, existing.OriginalName), MimeType: firstNonEmpty(item.MimeType, existing.MimeType), SizeBytes: firstNonZeroInt64(item.FileSize, existing.SizeBytes), StorageKey: firstNonEmpty(item.MediaURL, existing.StorageKey), PublicURL: firstNonEmpty(item.MediaURL, existing.PublicURL), } if record.ID == "" { record.ID = uuid.NewString() return s.db.WithContext(ctx).Create(&record).Error } return s.db.WithContext(ctx).Model(&mediaFileRecord{}).Where("id = ?", record.ID).Updates(record).Error } func (s *gormStore) appendAuditLog(ctx context.Context, actorName, action, title, contentID string) error { actorID, err := s.lookupUserIDByName(ctx, actorName) if err != nil { return err } entityID := contentID return s.db.WithContext(ctx).Create(&actionLogRecord{ID: uuid.NewString(), ActorUserID: actorID, Action: action, EntityType: title, EntityID: &entityID}).Error } func (s *gormStore) lookupCategoryID(ctx context.Context, title string) (*string, error) { var record categoryRecord if err := s.db.WithContext(ctx).Where("title = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } return &record.ID, nil } func (s *gormStore) lookupCategoryIDInsensitive(ctx context.Context, title string) (*string, error) { var record categoryRecord if err := s.db.WithContext(ctx).Where("lower(title) = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } return &record.ID, nil } func (s *gormStore) lookupTagID(ctx context.Context, title string) (*string, error) { var record tagRecord if err := s.db.WithContext(ctx).Where("title = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } return &record.ID, nil } func (s *gormStore) lookupTagIDInsensitive(ctx context.Context, title string) (*string, error) { var record tagRecord if err := s.db.WithContext(ctx).Where("lower(title) = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } return &record.ID, nil } func (s *gormStore) lookupSpeakerIDInsensitive(ctx context.Context, title string) (*string, error) { var record speakerRecord if err := s.db.WithContext(ctx).Where("lower(display_name) = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } return &record.ID, nil } func (s *gormStore) lookupUserIDByName(ctx context.Context, name string) (*string, error) { var record userRecord if err := s.db.WithContext(ctx).Where("display_name = ?", name).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } else if err != nil { return nil, err } return &record.ID, nil } func (s *gormStore) seed(ctx context.Context) error { if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, role := range []roleRecord{ {ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", Code: roleCodeToDB(RoleAdministrator), Name: "Администратор", Description: "Расширенное управление системой, пользователями, ролями и настройками"}, {ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", Code: roleCodeToDB(RoleEditor), Name: "Редактор", Description: "Создание и редактирование материалов, участие в модерации"}, {ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3", Code: roleCodeToDB(RoleManager), Name: "Менеджер", Description: "Публикация, управление контентом и подписками"}, {ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4", Code: roleCodeToDB(RoleUser), Name: "Пользователь", Description: "Просмотр материалов, комментарии и подписки"}, } { if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "code"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "description"})}).Create(&role).Error; err != nil { return err } } for _, category := range seedCategories { record := categoryRecord{ID: uuid.NewSHA1(uuid.Nil, []byte("category:"+category)).String(), Title: category, Slug: slugify(category)} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "slug"})}).Create(&record).Error; err != nil { return err } } for _, tag := range seedTags { record := tagRecord{ID: uuid.NewSHA1(uuid.Nil, []byte("tag:"+tag)).String(), Title: tag, Slug: slugify(tag)} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "slug"})}).Create(&record).Error; err != nil { return err } } for _, speaker := range seedSpeakers { record := speakerRecord{ID: speaker.ID, DisplayName: speaker.Name, RoleDescription: speaker.Role, TopicsCSV: strings.Join(speaker.Topics, ","), MaterialsCount: speaker.Materials, Subscribers: speaker.Subscribers} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"display_name", "role_description", "topics_csv", "materials_count", "subscribers_count"})}).Create(&record).Error; err != nil { return err } } var storedRoles []roleRecord if err := tx.Where("code IN ?", []string{roleCodeToDB(RoleAdministrator), roleCodeToDB(RoleEditor), roleCodeToDB(RoleManager), roleCodeToDB(RoleUser)}).Find(&storedRoles).Error; err != nil { return err } roleIDs := map[RoleCode]string{} for _, role := range storedRoles { roleIDs[roleCodeFromDB(role.Code)] = role.ID } for _, user := range seedUsers { email := user.Email hash, err := hashPassword(user.Password) if err != nil { return err } record := userRecord{ID: user.ID, Login: user.Login, PasswordHash: hash, DisplayName: user.Name, Email: &email, IsActive: true} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"login", "password_hash", "display_name", "email", "is_active"})}).Create(&record).Error; err != nil { return err } for _, role := range user.Roles { join := map[string]any{"user_id": user.ID, "role_id": roleIDs[role]} if err := tx.Table("user_roles").Clauses(clause.OnConflict{DoNothing: true}).Create(join).Error; err != nil { return err } } } for _, item := range seedContentItems { categoryID := uuid.NewSHA1(uuid.Nil, []byte("category:"+item.Category)).String() authorID, _ := userIDByLogin(item.AuthorLogin) publishedAt := mustParseDate(item.PublishedAt) record := contentRecord{ID: item.ID, Title: item.Title, Lead: item.Lead, Body: item.Body, ContentType: contentTypeToDB(item.Type), Status: contentStatusToDB(item.Status), Visibility: visibilityToDB(item.Visibility), CategoryID: &categoryID, AuthorUserID: authorID, AuthorLabel: seedUserName(item.AuthorLogin), PublishedAt: &publishedAt, Duration: item.Duration, ImageTone: item.ImageTone, ModeratorComment: item.ModeratorComment, ReviewComment: item.ReviewComment} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "lead", "body", "content_type", "status", "visibility", "category_id", "author_user_id", "author_label", "published_at", "duration", "image_tone", "moderator_comment", "review_comment"})}).Create(&record).Error; err != nil { return err } for _, tag := range item.Tags { join := map[string]any{"content_id": item.ID, "tag_id": uuid.NewSHA1(uuid.Nil, []byte("tag:"+tag)).String()} if err := tx.Table("content_tags").Clauses(clause.OnConflict{DoNothing: true}).Create(join).Error; err != nil { return err } } var existingViews int64 if err := tx.Model(&contentViewRecord{}).Where("content_id = ?", item.ID).Count(&existingViews).Error; err != nil { return err } for idx := existingViews; idx < int64(item.Views); idx++ { if err := tx.Create(&contentViewRecord{ID: uuid.NewString(), ContentID: item.ID, CreatedAt: publishedAt.Add(time.Duration(idx) * time.Second)}).Error; err != nil { return err } } } for _, notification := range seedNotifications { userID, _ := userIDByLogin(notification.UserLogin) record := notificationRecord{ID: notification.ID, UserID: derefString(userID), Title: notification.Title, Body: notification.Description, IsRead: notification.Read, CreatedAt: mustParseMinute(notification.CreatedAt)} if notification.Read { readAt := record.CreatedAt record.ReadAt = &readAt } if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"user_id", "title", "body", "is_read", "created_at", "read_at"})}).Create(&record).Error; err != nil { return err } } for _, comment := range seedComments { userID, _ := userIDByLogin(comment.AuthorLogin) record := commentRecord{ID: comment.ID, ContentID: comment.ContentID, UserID: derefString(userID), Body: comment.Text, Status: "visible", CreatedAt: mustParseMinute(comment.CreatedAt), UpdatedAt: mustParseMinute(comment.CreatedAt)} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"content_id", "user_id", "body", "status", "created_at", "updated_at"})}).Create(&record).Error; err != nil { return err } } for _, audit := range seedAuditTrail { userID, _ := userIDByLogin(audit.ActorLogin) entityID := lookupSeedContentIDByTitle(audit.Target) record := actionLogRecord{ID: audit.ID, ActorUserID: userID, Action: audit.Action, EntityType: audit.Target, EntityID: entityID, CreatedAt: mustParseMinute(audit.CreatedAt)} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"actor_user_id", "action", "entity_type", "entity_id", "created_at"})}).Create(&record).Error; err != nil { return err } } return nil }); err != nil { return err } for _, user := range seedUsers { for _, target := range user.Subscriptions { if _, err := s.UpsertSubscription(ctx, user.ID, target); err != nil { return err } } } return nil } func mapUserRecord(user userRecord) UserProfile { roles := make([]RoleCode, 0, len(user.Roles)) for _, role := range user.Roles { roles = append(roles, roleCodeFromDB(role.Code)) } subscriptions := make([]string, 0, len(user.Subscriptions)) for _, subscription := range user.Subscriptions { switch subscription.SubscriptionType { case "category": subscriptions = append(subscriptions, subscription.Category.Title) case "tag": subscriptions = append(subscriptions, subscription.Tag.Title) case "speaker": subscriptions = append(subscriptions, subscription.Speaker.DisplayName) } } sort.Strings(subscriptions) return UserProfile{ID: user.ID, Name: user.DisplayName, Login: user.Login, PasswordHash: user.PasswordHash, Roles: roles, Subscriptions: subscriptions} } func mapContentRecord(record contentRecord) ContentItem { tags := make([]string, 0, len(record.Tags)) for _, tag := range record.Tags { tags = append(tags, tag.Title) } sort.Strings(tags) item := ContentItem{ ID: record.ID, Title: record.Title, Lead: record.Lead, Body: record.Body, Type: contentTypeFromDB(record.ContentType), Category: record.Category.Title, Tags: tags, Author: firstNonEmpty(record.AuthorUser.DisplayName, record.AuthorLabel), Duration: record.Duration, Visibility: visibilityFromDB(record.Visibility), Status: contentStatusFromDB(record.Status), Views: int(record.Views), ImageTone: record.ImageTone, ModeratorComment: record.ModeratorComment, ReviewComment: record.ReviewComment, RatingAverage: record.RatingAverage, RatingCount: record.RatingCount, } if record.PublishedAt != nil { item.PublishedAt = record.PublishedAt.UTC().Format("2006-01-02") } if record.MediaFile != nil { item.MediaURL = firstNonEmpty(record.MediaFile.PublicURL, record.MediaFile.StorageKey) item.MediaKind = inferMediaKind(record.MediaFile.MimeType, record.MediaFile.OriginalName) item.MimeType = record.MediaFile.MimeType item.FileName = record.MediaFile.OriginalName item.FileSize = record.MediaFile.SizeBytes } return item } func resolveLoginAlias(login string) string { login = strings.ToLower(strings.TrimSpace(login)) aliases := map[string]string{"admin@dstu.ru": "demo_admin", "editor@dstu.ru": "demo_editor", "moderator@dstu.ru": "demo_moderator", "manager@dstu.ru": "demo_moderator", "user@dstu.ru": "demo_user"} if alias, ok := aliases[login]; ok { return alias } return login } func roleCodeToDB(role RoleCode) string { switch role { case RoleAdministrator: return "administrator" case RoleEditor: return "editor" case RoleManager: return "manager" default: return "user" } } func roleCodeFromDB(role string) RoleCode { switch role { case "administrator": return RoleAdministrator case "editor": return RoleEditor case "manager": return RoleManager default: return RoleUser } } func contentTypeToDB(contentType ContentType) string { if contentType == ContentTypeEvent { return "event" } return string(contentType) } func contentTypeFromDB(contentType string) ContentType { return ContentType(contentType) } func contentStatusToDB(status ContentStatus) string { switch status { case ContentStatusDraft: return "draft" case ContentStatusModeration: return "moderation" case ContentStatusReview: return "review" case ContentStatusPublished: return "published" case ContentStatusReturned: return "returned" case ContentStatusArchived: return "archived" default: return strings.ToLower(strings.TrimSpace(string(status))) } } func contentStatusFromDB(status string) ContentStatus { return normalizeStatus(status) } func visibilityToDB(visibility Visibility) string { switch visibility { case VisibilityPublic: return "public" case VisibilityAuthenticated: return "authenticated" case VisibilityRole: return "role_restricted" default: return "public" } } func visibilityFromDB(value string) Visibility { switch value { case "public": return VisibilityPublic case "authenticated": return VisibilityAuthenticated case "role_restricted": return VisibilityRole default: return VisibilityPublic } } func splitCSV(value string) []string { if strings.TrimSpace(value) == "" { return nil } parts := strings.Split(value, ",") items := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := strings.TrimSpace(part); trimmed != "" { items = append(items, trimmed) } } return items } func slugify(value string) string { value = strings.ToLower(strings.TrimSpace(value)) replacer := strings.NewReplacer(" ", "-", ",", "", ".", "", "«", "", "»", "") return replacer.Replace(value) } func firstNonZeroInt64(value, fallback int64) int64 { if value == 0 { return fallback } return value } func userIDByLogin(login string) (*string, bool) { for _, user := range seedUsers { if user.Login == login { return &user.ID, true } } return nil, false } func seedUserName(login string) string { for _, user := range seedUsers { if user.Login == login { return user.Name } } return login } func lookupSeedContentIDByTitle(title string) *string { for _, item := range seedContentItems { if item.Title == title { return &item.ID } } return nil } func derefString(value *string) string { if value == nil { return "" } return *value } func okToErr(ok bool, err error) error { if err != nil { return err } if !ok { return nil } return nil }