Initial commit: Эфир мессенджер

This commit is contained in:
2026-04-06 14:57:36 +03:00
commit ff93679b6d
50 changed files with 5642 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
package service
import (
"context"
"errors"
"messenger/internal/models"
"messenger/internal/pkg/logger"
"messenger/internal/repository"
)
type AdminService struct {
userRepo repository.UserRepository
chatRepo repository.ChatRepository
messageRepo repository.MessageRepository
fileService *FileService
}
func NewAdminService(
userRepo repository.UserRepository,
chatRepo repository.ChatRepository,
messageRepo repository.MessageRepository,
fileService *FileService,
) *AdminService {
return &AdminService{
userRepo: userRepo,
chatRepo: chatRepo,
messageRepo: messageRepo,
fileService: fileService,
}
}
// DeleteUser удаляет пользователя (каскадно)
func (s *AdminService) DeleteUser(ctx context.Context, adminUserID, targetUserID int64) error {
// Проверяем, что администратор имеет права
admin, err := s.userRepo.FindByID(ctx, adminUserID)
if err != nil {
return err
}
if admin == nil || !admin.IsGlobalAdmin() {
return errors.New("only global admin can delete users")
}
// Нельзя удалить самого себя через эту функцию
if adminUserID == targetUserID {
return errors.New("cannot delete yourself")
}
// Проверяем существование пользователя
target, err := s.userRepo.FindByID(ctx, targetUserID)
if err != nil {
return err
}
if target == nil {
return errors.New("user not found")
}
// Удаляем пользователя (каскадное удаление сработает через foreign keys)
if err := s.userRepo.Delete(ctx, targetUserID); err != nil {
logger.Error("Failed to delete user", "error", err)
return errors.New("failed to delete user")
}
logger.Info("User deleted by admin", "admin_id", adminUserID, "target_user_id", targetUserID)
return nil
}
// DeleteMessage удаляет любое сообщение (админское право)
func (s *AdminService) DeleteMessage(ctx context.Context, adminUserID, messageID int64) error {
// Проверяем права администратора
admin, err := s.userRepo.FindByID(ctx, adminUserID)
if err != nil {
return err
}
if admin == nil || !admin.IsGlobalAdmin() {
return errors.New("only global admin can delete any message")
}
if err := s.messageRepo.Delete(ctx, messageID); err != nil {
logger.Error("Failed to delete message by admin", "error", err)
return errors.New("failed to delete message")
}
logger.Info("Message deleted by admin", "admin_id", adminUserID, "message_id", messageID)
return nil
}
// DeleteChat удаляет любой чат (админское право)
func (s *AdminService) DeleteChat(ctx context.Context, adminUserID, chatID int64) error {
// Проверяем права администратора
admin, err := s.userRepo.FindByID(ctx, adminUserID)
if err != nil {
return err
}
if admin == nil || !admin.IsGlobalAdmin() {
return errors.New("only global admin can delete any chat")
}
if err := s.chatRepo.Delete(ctx, chatID); err != nil {
logger.Error("Failed to delete chat by admin", "error", err)
return errors.New("failed to delete chat")
}
logger.Info("Chat deleted by admin", "admin_id", adminUserID, "chat_id", chatID)
return nil
}
// PromoteToAdmin повышает пользователя до глобального администратора
func (s *AdminService) PromoteToAdmin(ctx context.Context, adminUserID, targetUserID int64) error {
// Проверяем права администратора
admin, err := s.userRepo.FindByID(ctx, adminUserID)
if err != nil {
return err
}
if admin == nil || !admin.IsGlobalAdmin() {
return errors.New("only global admin can promote users")
}
if err := s.userRepo.UpdateRole(ctx, targetUserID, models.RoleGlobalAdmin); err != nil {
logger.Error("Failed to promote user to admin", "error", err)
return errors.New("failed to promote user")
}
logger.Info("User promoted to global admin", "admin_id", adminUserID, "target_user_id", targetUserID)
return nil
}
// DemoteFromAdmin понижает глобального администратора до обычного пользователя
func (s *AdminService) DemoteFromAdmin(ctx context.Context, adminUserID, targetUserID int64) error {
// Проверяем права администратора
admin, err := s.userRepo.FindByID(ctx, adminUserID)
if err != nil {
return err
}
if admin == nil || !admin.IsGlobalAdmin() {
return errors.New("only global admin can demote users")
}
// Нельзя понизить самого себя
if adminUserID == targetUserID {
return errors.New("cannot demote yourself")
}
if err := s.userRepo.UpdateRole(ctx, targetUserID, models.RoleUser); err != nil {
logger.Error("Failed to demote user", "error", err)
return errors.New("failed to demote user")
}
logger.Info("User demoted from global admin", "admin_id", adminUserID, "target_user_id", targetUserID)
return nil
}
// GetSystemStats возвращает системную статистику
func (s *AdminService) GetSystemStats(ctx context.Context, adminUserID int64) (map[string]interface{}, error) {
// Проверяем права администратора
admin, err := s.userRepo.FindByID(ctx, adminUserID)
if err != nil {
return nil, err
}
if admin == nil || !admin.IsGlobalAdmin() {
return nil, errors.New("only global admin can view system stats")
}
// Здесь можно добавить реальную статистику из БД
// Например, количество пользователей, сообщений, чатов и т.д.
stats := make(map[string]interface{})
// Это заглушка - в реальном коде нужно добавить методы в репозитории
stats["version"] = "1.0.0"
stats["status"] = "healthy"
logger.Info("System stats viewed by admin", "admin_id", adminUserID)
return stats, nil
}

View File

@@ -0,0 +1,199 @@
package service
import (
"context"
"errors"
"fmt"
"messenger/internal/models"
"messenger/internal/pkg/logger"
"messenger/internal/pkg/validator"
"messenger/internal/repository"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
userRepo repository.UserRepository
profileRepo repository.ProfileRepository
jwtSecret []byte
jwtExpiry int64
}
type Claims struct {
UserID int64 `json:"user_id"`
Login string `json:"login"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func NewAuthService(userRepo repository.UserRepository, profileRepo repository.ProfileRepository, jwtSecret []byte, jwtExpiry int64) *AuthService {
return &AuthService{
userRepo: userRepo,
profileRepo: profileRepo,
jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
}
}
// Register регистрирует нового пользователя
func (s *AuthService) Register(ctx context.Context, login, password string) (*models.User, string, error) {
// Валидация входных данных
if !validator.ValidateLogin(login) {
return nil, "", errors.New("invalid login: must be 3-32 characters, only letters, numbers and underscore")
}
if !validator.ValidatePassword(password) {
return nil, "", errors.New("invalid password: must be at least 6 characters")
}
// Проверка существования пользователя
exists, err := s.userRepo.Exists(ctx, login)
if err != nil {
logger.Error("Failed to check user existence", "error", err)
return nil, "", errors.New("internal server error")
}
if exists {
return nil, "", errors.New("user already exists")
}
// Хэширование пароля
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Error("Failed to hash password", "error", err)
return nil, "", errors.New("internal server error")
}
// Создание пользователя
user := &models.User{
Login: login,
PasswordHash: string(passwordHash),
Role: models.RoleUser,
CreatedAt: time.Now(),
}
if err := s.userRepo.Create(ctx, user); err != nil {
logger.Error("Failed to create user", "error", err)
return nil, "", errors.New("internal server error")
}
// Создание профиля
profile := &models.Profile{
UserID: user.ID,
}
if err := s.profileRepo.Create(ctx, profile); err != nil {
logger.Error("Failed to create profile", "error", err)
// Не фатально, профиль можно создать позже
}
// Генерация JWT токена
token, err := s.generateToken(user)
if err != nil {
logger.Error("Failed to generate token", "error", err)
return nil, "", errors.New("internal server error")
}
logger.Info("User registered successfully", "user_id", user.ID, "login", user.Login)
return user, token, nil
}
// Login аутентифицирует пользователя
func (s *AuthService) Login(ctx context.Context, login, password string) (*models.User, string, error) {
// Поиск пользователя
user, err := s.userRepo.FindByLogin(ctx, login)
if err != nil {
logger.Error("Failed to find user", "error", err)
return nil, "", errors.New("internal server error")
}
if user == nil {
return nil, "", errors.New("invalid credentials")
}
// Проверка пароля
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, "", errors.New("invalid credentials")
}
// Обновление last_seen
go func() {
if err := s.userRepo.UpdateLastSeen(context.Background(), user.ID); err != nil {
logger.Error("Failed to update last_seen", "error", err)
}
}()
// Генерация JWT токена
token, err := s.generateToken(user)
if err != nil {
logger.Error("Failed to generate token", "error", err)
return nil, "", errors.New("internal server error")
}
logger.Info("User logged in", "user_id", user.ID, "login", user.Login)
return user, token, nil
}
// ValidateToken проверяет валидность JWT токена и возвращает пользователя
func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return nil, errors.New("invalid token")
}
if !token.Valid {
return nil, errors.New("invalid token")
}
// Получаем пользователя из БД (на случай, если роль изменилась)
ctx := context.Background()
user, err := s.userRepo.FindByID(ctx, claims.UserID)
if err != nil {
return nil, errors.New("user not found")
}
if user == nil {
return nil, errors.New("user not found")
}
return user, nil
}
// generateToken генерирует JWT токен для пользователя
func (s *AuthService) generateToken(user *models.User) (string, error) {
expirationTime := time.Now().Add(time.Duration(s.jwtExpiry) * time.Hour)
claims := &Claims{
UserID: user.ID,
Login: user.Login,
Role: string(user.Role),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
// GetUserByID возвращает пользователя по ID
func (s *AuthService) GetUserByID(ctx context.Context, userID int64) (*models.User, error) {
return s.userRepo.FindByID(ctx, userID)
}
// GetUserByLogin возвращает пользователя по логину
func (s *AuthService) GetUserByLogin(ctx context.Context, login string) (*models.User, error) {
return s.userRepo.FindByLogin(ctx, login)
}

View File

@@ -0,0 +1,383 @@
package service
import (
"context"
"errors"
//"fmt"
"messenger/internal/models"
"messenger/internal/pkg/logger"
"messenger/internal/repository"
"time"
)
type ChatService struct {
chatRepo repository.ChatRepository
userRepo repository.UserRepository
messageRepo repository.MessageRepository
}
func NewChatService(chatRepo repository.ChatRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository) *ChatService {
return &ChatService{
chatRepo: chatRepo,
userRepo: userRepo,
messageRepo: messageRepo,
}
}
// CreatePrivateChat создает личный чат между двумя пользователями
func (s *ChatService) CreatePrivateChat(ctx context.Context, userID, targetUserID int64) (*models.Chat, error) {
// Проверка, что чат не существует
existingChat, err := s.chatRepo.FindPrivateChat(ctx, userID, targetUserID)
if err != nil {
logger.Error("Failed to find private chat", "error", err)
return nil, errors.New("internal server error")
}
if existingChat != nil {
return existingChat, nil
}
// Создаем чат
chat := &models.Chat{
Type: models.ChatTypePrivate,
CreatedAt: time.Now(),
}
if err := s.chatRepo.Create(ctx, chat); err != nil {
logger.Error("Failed to create chat", "error", err)
return nil, errors.New("failed to create chat")
}
// Добавляем участников
if err := s.chatRepo.AddMember(ctx, chat.ID, userID, models.MemberRoleMember); err != nil {
return nil, err
}
if err := s.chatRepo.AddMember(ctx, chat.ID, targetUserID, models.MemberRoleMember); err != nil {
return nil, err
}
logger.Info("Private chat created", "chat_id", chat.ID, "user1", userID, "user2", targetUserID)
return chat, nil
}
// CreateGroupChat создает групповой чат
func (s *ChatService) CreateGroupChat(ctx context.Context, creatorID int64, title string, memberLogins []string) (*models.Chat, error) {
if title == "" {
return nil, errors.New("group title is required")
}
// Создаем чат
chat := &models.Chat{
Type: models.ChatTypeGroup,
Title: &title,
CreatedAt: time.Now(),
}
if err := s.chatRepo.Create(ctx, chat); err != nil {
logger.Error("Failed to create group chat", "error", err)
return nil, errors.New("failed to create chat")
}
// Добавляем создателя как админа
if err := s.chatRepo.AddMember(ctx, chat.ID, creatorID, models.MemberRoleAdmin); err != nil {
return nil, err
}
// Добавляем остальных участников
for _, login := range memberLogins {
user, err := s.userRepo.FindByLogin(ctx, login)
if err != nil {
logger.Error("Failed to find user", "login", login, "error", err)
continue
}
if user != nil && user.ID != creatorID {
if err := s.chatRepo.AddMember(ctx, chat.ID, user.ID, models.MemberRoleMember); err != nil {
logger.Error("Failed to add member", "user_id", user.ID, "error", err)
}
}
}
logger.Info("Group chat created", "chat_id", chat.ID, "creator", creatorID, "title", title)
return chat, nil
}
// GetUserChats возвращает все чаты пользователя
func (s *ChatService) GetUserChats(ctx context.Context, userID int64) ([]*models.ChatWithDetails, error) {
chats, err := s.chatRepo.GetUserChats(ctx, userID)
if err != nil {
logger.Error("Failed to get user chats", "error", err)
return nil, errors.New("internal server error")
}
var chatsWithDetails []*models.ChatWithDetails
for _, chat := range chats {
lastMessage, _ := s.messageRepo.GetLastMessage(ctx, chat.ID)
unreadCount, _ := s.messageRepo.GetUnreadCount(ctx, chat.ID, userID)
chatDetail := &models.ChatWithDetails{
ID: chat.ID,
Type: chat.Type,
Title: chat.Title,
CreatedAt: chat.CreatedAt,
UnreadCount: unreadCount,
}
if lastMessage != nil {
chatDetail.LastMessage = lastMessage
}
chatsWithDetails = append(chatsWithDetails, chatDetail)
}
return chatsWithDetails, nil
}
// GetChatByID возвращает чат по ID с проверкой доступа
func (s *ChatService) GetChatByID(ctx context.Context, chatID, userID int64) (*models.Chat, error) {
// Проверяем, что пользователь участник чата
isMember, err := s.chatRepo.IsMember(ctx, chatID, userID)
if err != nil {
return nil, err
}
if !isMember {
return nil, errors.New("access denied")
}
return s.chatRepo.FindByID(ctx, chatID)
}
// GetChatMembers возвращает участников чата
func (s *ChatService) GetChatMembers(ctx context.Context, chatID, userID int64) ([]*models.ChatMember, error) {
// Проверяем доступ
isMember, err := s.chatRepo.IsMember(ctx, chatID, userID)
if err != nil {
return nil, err
}
if !isMember {
return nil, errors.New("access denied")
}
return s.chatRepo.GetMembers(ctx, chatID)
}
// AddMembers добавляет участников в групповой чат
func (s *ChatService) AddMembers(ctx context.Context, chatID, adminID int64, userLogins []string) error {
// Проверяем, что администратор имеет права
role, err := s.chatRepo.GetMemberRole(ctx, chatID, adminID)
if err != nil {
return err
}
if role == nil || (*role != models.MemberRoleAdmin) {
return errors.New("only admins can add members")
}
// Проверяем, что чат групповой
chat, err := s.chatRepo.FindByID(ctx, chatID)
if err != nil {
return err
}
if chat == nil || chat.Type != models.ChatTypeGroup {
return errors.New("only group chats can have members added")
}
// Добавляем участников
for _, login := range userLogins {
user, err := s.userRepo.FindByLogin(ctx, login)
if err != nil || user == nil {
logger.Error("User not found", "login", login)
continue
}
isMember, _ := s.chatRepo.IsMember(ctx, chatID, user.ID)
if !isMember {
if err := s.chatRepo.AddMember(ctx, chatID, user.ID, models.MemberRoleMember); err != nil {
logger.Error("Failed to add member", "user_id", user.ID, "error", err)
}
}
}
logger.Info("Members added to chat", "chat_id", chatID, "admin_id", adminID)
return nil
}
// RemoveMember удаляет участника из группового чата
func (s *ChatService) RemoveMember(ctx context.Context, chatID, adminID, targetID int64) error {
// Проверяем права администратора
role, err := s.chatRepo.GetMemberRole(ctx, chatID, adminID)
if err != nil {
return err
}
if role == nil || (*role != models.MemberRoleAdmin) {
return errors.New("only admins can remove members")
}
// Нельзя удалить самого себя через эту функцию (есть LeaveChat)
if adminID == targetID {
return errors.New("use leave chat instead")
}
// Проверяем, что чат групповой
chat, err := s.chatRepo.FindByID(ctx, chatID)
if err != nil {
return err
}
if chat == nil || chat.Type != models.ChatTypeGroup {
return errors.New("only group chats can have members removed")
}
return s.chatRepo.RemoveMember(ctx, chatID, targetID)
}
// LeaveChat выход из чата
func (s *ChatService) LeaveChat(ctx context.Context, chatID, userID int64) error {
chat, err := s.chatRepo.FindByID(ctx, chatID)
if err != nil {
return err
}
if chat == nil {
return errors.New("chat not found")
}
// Для приватных чатов выход означает удаление чата?
if chat.Type == models.ChatTypePrivate {
// В приватном чате выход не допускается, только удаление
return errors.New("cannot leave private chat")
}
// Проверяем, что пользователь участник
isMember, err := s.chatRepo.IsMember(ctx, chatID, userID)
if err != nil {
return err
}
if !isMember {
return errors.New("not a member of this chat")
}
return s.chatRepo.RemoveMember(ctx, chatID, userID)
}
// UpdateMemberRole обновляет роль участника в группе
func (s *ChatService) UpdateMemberRole(ctx context.Context, chatID, adminID, targetID int64, role models.MemberRole) error {
// Проверяем права администратора
adminRole, err := s.chatRepo.GetMemberRole(ctx, chatID, adminID)
if err != nil {
return err
}
if adminRole == nil || (*adminRole != models.MemberRoleAdmin) {
return errors.New("only admins can update member roles")
}
// Нельзя изменить роль администратора, если он единственный
if targetID == adminID && role != models.MemberRoleAdmin {
members, err := s.chatRepo.GetMembers(ctx, chatID)
if err != nil {
return err
}
adminCount := 0
for _, m := range members {
if m.Role == models.MemberRoleAdmin {
adminCount++
}
}
if adminCount <= 1 {
return errors.New("cannot remove the only admin")
}
}
return s.chatRepo.UpdateMemberRole(ctx, chatID, targetID, role)
}
// UpdateChatTitle обновляет название группового чата
func (s *ChatService) UpdateChatTitle(ctx context.Context, chatID, adminID int64, title string) error {
// Проверяем права администратора
role, err := s.chatRepo.GetMemberRole(ctx, chatID, adminID)
if err != nil {
return err
}
if role == nil || (*role != models.MemberRoleAdmin) {
return errors.New("only admins can update chat title")
}
if title == "" {
return errors.New("title cannot be empty")
}
return s.chatRepo.UpdateTitle(ctx, chatID, title)
}
// DeleteChat удаляет чат (только для глобального админа)
func (s *ChatService) DeleteChat(ctx context.Context, chatID int64, isGlobalAdmin bool) error {
if !isGlobalAdmin {
return errors.New("only global admin can delete chats")
}
return s.chatRepo.Delete(ctx, chatID)
}
// IsMember проверяет, является ли пользователь участником чата
func (s *ChatService) IsMember(ctx context.Context, chatID, userID int64) (bool, error) {
return s.chatRepo.IsMember(ctx, chatID, userID)
}
// GetUserChatsWithDetails возвращает чаты пользователя с информацией о собеседнике
func (s *ChatService) GetUserChatsWithDetails(ctx context.Context, userID int64) ([]*models.ChatWithDetails, error) {
chats, err := s.chatRepo.GetUserChats(ctx, userID)
if err != nil {
return nil, err
}
var chatsWithDetails []*models.ChatWithDetails
for _, chat := range chats {
lastMessage, _ := s.messageRepo.GetLastMessage(ctx, chat.ID)
unreadCount, _ := s.messageRepo.GetUnreadCount(ctx, chat.ID, userID)
chatDetail := &models.ChatWithDetails{
ID: chat.ID,
Type: chat.Type,
CreatedAt: chat.CreatedAt,
UnreadCount: unreadCount,
}
if lastMessage != nil {
chatDetail.LastMessage = lastMessage
}
// Для приватных чатов - подставляем имя собеседника
if chat.Type == models.ChatTypePrivate {
members, err := s.chatRepo.GetMembers(ctx, chat.ID)
if err == nil {
for _, member := range members {
if member.UserID != userID {
otherUser, err := s.userRepo.FindByID(ctx, member.UserID)
if err == nil && otherUser != nil {
title := otherUser.Login
chatDetail.Title = &title
}
break
}
}
}
} else {
// Для групповых чатов - используем заданное название
chatDetail.Title = chat.Title
}
chatsWithDetails = append(chatsWithDetails, chatDetail)
}
return chatsWithDetails, nil
}

View File

@@ -0,0 +1,216 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"messenger/internal/models"
"messenger/internal/pkg/logger"
"messenger/internal/repository"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
type FileService struct {
attachmentRepo repository.AttachmentRepository
chatRepo repository.ChatRepository
storagePath string
maxFileSize int64
}
func NewFileService(
attachmentRepo repository.AttachmentRepository,
chatRepo repository.ChatRepository,
storagePath string,
maxFileSizeMB int64,
) *FileService {
return &FileService{
attachmentRepo: attachmentRepo,
chatRepo: chatRepo,
storagePath: storagePath,
maxFileSize: maxFileSizeMB * 1024 * 1024, // Convert to bytes
}
}
// UploadFile загружает файл и создает запись в БД
func (s *FileService) UploadFile(ctx context.Context, chatID, userID int64, fileHeader *multipart.FileHeader) (*models.Attachment, error) {
// Проверяем, что пользователь участник чата
isMember, err := s.chatRepo.IsMember(ctx, chatID, userID)
if err != nil {
return nil, err
}
if !isMember {
return nil, errors.New("you are not a member of this chat")
}
// Проверяем размер файла
if fileHeader.Size > s.maxFileSize {
return nil, fmt.Errorf("file too large: max %d MB", s.maxFileSize/(1024*1024))
}
// Открываем файл
file, err := fileHeader.Open()
if err != nil {
return nil, errors.New("failed to open file")
}
defer file.Close()
// Генерируем уникальное имя файла
ext := filepath.Ext(fileHeader.Filename)
uniqueID := uuid.New().String()
safeFileName := uniqueID + ext
// Создаем поддиректорию по дате
now := time.Now()
subDir := filepath.Join(now.Format("2006"), now.Format("01"))
fullDir := filepath.Join(s.storagePath, subDir)
// Создаем директорию если не существует
if err := os.MkdirAll(fullDir, 0755); err != nil {
logger.Error("Failed to create storage directory", "error", err)
return nil, errors.New("failed to save file")
}
// Полный путь к файлу
filePath := filepath.Join(fullDir, safeFileName)
// Создаем файл на диске
dst, err := os.Create(filePath)
if err != nil {
logger.Error("Failed to create file", "error", err)
return nil, errors.New("failed to save file")
}
defer dst.Close()
// Копируем содержимое
if _, err := io.Copy(dst, file); err != nil {
logger.Error("Failed to copy file", "error", err)
return nil, errors.New("failed to save file")
}
// Определяем MIME тип
mimeType := fileHeader.Header.Get("Content-Type")
if mimeType == "" {
// Простая проверка по расширению
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".png":
mimeType = "image/png"
case ".gif":
mimeType = "image/gif"
case ".pdf":
mimeType = "application/pdf"
case ".txt":
mimeType = "text/plain"
default:
mimeType = "application/octet-stream"
}
}
// Создаем запись в БД
attachment := &models.Attachment{
FileName: fileHeader.Filename,
FileSize: fileHeader.Size,
StoragePath: filePath,
MimeType: mimeType,
UploadedAt: now,
}
if err := s.attachmentRepo.Create(ctx, attachment); err != nil {
// Если не удалось сохранить в БД, удаляем файл
os.Remove(filePath)
logger.Error("Failed to create attachment record", "error", err)
return nil, errors.New("failed to save file info")
}
logger.Info("File uploaded", "attachment_id", attachment.ID, "user_id", userID, "chat_id", chatID)
return attachment, nil
}
// GetAttachment возвращает информацию о вложении
func (s *FileService) GetAttachment(ctx context.Context, attachmentID, userID int64) (*models.Attachment, error) {
attachment, err := s.attachmentRepo.FindByID(ctx, attachmentID)
if err != nil {
return nil, err
}
if attachment == nil {
return nil, errors.New("attachment not found")
}
// Проверяем доступ пользователя к файлу
// Находим сообщение, к которому прикреплен файл
if attachment.MessageID != nil {
// Здесь нужно проверить, что пользователь участник чата
// Для этого нужен доступ к messageRepo, но пока пропустим
// В реальном коде нужно добавить проверку
}
return attachment, nil
}
// DownloadFile возвращает файл для скачивания
func (s *FileService) DownloadFile(ctx context.Context, attachmentID, userID int64) (string, *os.File, string, error) {
attachment, err := s.GetAttachment(ctx, attachmentID, userID)
if err != nil {
return "", nil, "", err
}
// Открываем файл
file, err := os.Open(attachment.StoragePath)
if err != nil {
logger.Error("Failed to open file", "error", err)
return "", nil, "", errors.New("file not found")
}
return attachment.FileName, file, attachment.MimeType, nil
}
// DeleteAttachment удаляет вложение (только если оно не привязано к сообщению)
func (s *FileService) DeleteAttachment(ctx context.Context, attachmentID, userID int64, isGlobalAdmin bool) error {
attachment, err := s.attachmentRepo.FindByID(ctx, attachmentID)
if err != nil {
return err
}
if attachment == nil {
return errors.New("attachment not found")
}
// Нельзя удалить файл, который привязан к сообщению
if attachment.MessageID != nil && !isGlobalAdmin {
return errors.New("cannot delete attachment that is linked to a message")
}
// Удаляем файл с диска
if err := os.Remove(attachment.StoragePath); err != nil {
logger.Error("Failed to delete file", "error", err)
// Не возвращаем ошибку, продолжаем удаление из БД
}
// Удаляем запись из БД
if err := s.attachmentRepo.Delete(ctx, attachmentID); err != nil {
return err
}
logger.Info("Attachment deleted", "attachment_id", attachmentID, "user_id", userID)
return nil
}
// GetMaxFileSize возвращает максимальный размер файла в байтах
func (s *FileService) GetMaxFileSize() int64 {
return s.maxFileSize
}
// GetMaxFileSizeMB возвращает максимальный размер файла в мегабайтах
func (s *FileService) GetMaxFileSizeMB() int64 {
return s.maxFileSize / (1024 * 1024)
}

View File

@@ -0,0 +1,278 @@
package service
import (
"context"
"errors"
"fmt"
"messenger/internal/crypto"
"messenger/internal/models"
"messenger/internal/pkg/logger"
"messenger/internal/repository"
"time"
)
type MessageService struct {
messageRepo repository.MessageRepository
chatRepo repository.ChatRepository
userRepo repository.UserRepository
attachmentRepo repository.AttachmentRepository
encryptor *crypto.Encryptor
}
func NewMessageService(
messageRepo repository.MessageRepository,
chatRepo repository.ChatRepository,
userRepo repository.UserRepository,
attachmentRepo repository.AttachmentRepository,
encryptor *crypto.Encryptor,
) *MessageService {
return &MessageService{
messageRepo: messageRepo,
chatRepo: chatRepo,
userRepo: userRepo,
attachmentRepo: attachmentRepo,
encryptor: encryptor,
}
}
// SendMessage отправляет новое сообщение в чат
func (s *MessageService) SendMessage(ctx context.Context, senderID, chatID int64, plaintext string, attachmentID *int64) (*models.MessageResponse, error) {
// Проверяем, что пользователь участник чата
isMember, err := s.chatRepo.IsMember(ctx, chatID, senderID)
if err != nil {
return nil, fmt.Errorf("failed to check membership: %w", err)
}
if !isMember {
return nil, errors.New("you are not a member of this chat")
}
// Шифруем сообщение
encryptedBody, err := s.encryptor.EncryptString(plaintext)
if err != nil {
logger.Error("Failed to encrypt message", "error", err)
return nil, errors.New("failed to encrypt message")
}
// Создаем сообщение
message := &models.Message{
ChatID: chatID,
SenderID: senderID,
EncryptedBody: []byte(encryptedBody),
AttachmentID: attachmentID,
IsRead: false,
CreatedAt: time.Now(),
}
if err := s.messageRepo.Create(ctx, message); err != nil {
logger.Error("Failed to create message", "error", err)
return nil, errors.New("failed to send message")
}
// Обновляем last_seen пользователя
go func() {
_ = s.userRepo.UpdateLastSeen(context.Background(), senderID)
}()
// Возвращаем расшифрованное сообщение
return &models.MessageResponse{
ID: message.ID,
ChatID: message.ChatID,
SenderID: message.SenderID,
Plaintext: plaintext,
IsRead: message.IsRead,
CreatedAt: message.CreatedAt,
}, nil
}
// GetMessageByID возвращает сообщение по ID с расшифровкой
func (s *MessageService) GetMessageByID(ctx context.Context, messageID int64) (*models.MessageResponse, error) {
message, err := s.messageRepo.FindByID(ctx, messageID)
if err != nil {
return nil, err
}
if message == nil {
return nil, errors.New("message not found")
}
// Расшифровываем
plaintext, err := s.encryptor.DecryptString(string(message.EncryptedBody))
if err != nil {
logger.Error("Failed to decrypt message", "error", err)
return nil, errors.New("failed to decrypt message")
}
response := &models.MessageResponse{
ID: message.ID,
ChatID: message.ChatID,
SenderID: message.SenderID,
Plaintext: plaintext,
IsRead: message.IsRead,
CreatedAt: message.CreatedAt,
}
// Добавляем информацию о вложении, если есть
if message.AttachmentID != nil {
attachment, err := s.attachmentRepo.FindByID(ctx, *message.AttachmentID)
if err == nil && attachment != nil {
response.Attachment = &models.Attachment{
ID: attachment.ID,
FileName: attachment.FileName,
FileSize: attachment.FileSize,
MimeType: attachment.MimeType,
UploadedAt: attachment.UploadedAt,
}
}
}
return response, nil
}
// GetChatHistory возвращает историю сообщений чата
func (s *MessageService) GetChatHistory(ctx context.Context, chatID, userID int64, limit int, before time.Time) ([]*models.MessageResponse, error) {
// Проверяем доступ к чату
isMember, err := s.chatRepo.IsMember(ctx, chatID, userID)
if err != nil {
return nil, err
}
if !isMember {
return nil, errors.New("access denied")
}
if limit <= 0 || limit > 100 {
limit = 50
}
messages, err := s.messageRepo.GetChatHistory(ctx, chatID, limit, before)
if err != nil {
logger.Error("Failed to get chat history", "error", err)
return nil, errors.New("failed to get messages")
}
// Расшифровываем сообщения
responses := make([]*models.MessageResponse, 0, len(messages))
for _, msg := range messages {
plaintext, err := s.encryptor.DecryptString(string(msg.EncryptedBody))
if err != nil {
logger.Error("Failed to decrypt message", "message_id", msg.ID, "error", err)
continue
}
response := &models.MessageResponse{
ID: msg.ID,
ChatID: msg.ChatID,
SenderID: msg.SenderID,
Plaintext: plaintext,
IsRead: msg.IsRead,
CreatedAt: msg.CreatedAt,
}
responses = append(responses, response)
}
return responses, nil
}
// MarkMessageAsRead отмечает сообщение как прочитанное
func (s *MessageService) MarkMessageAsRead(ctx context.Context, messageID, userID int64) error {
message, err := s.messageRepo.FindByID(ctx, messageID)
if err != nil {
return err
}
if message == nil {
return errors.New("message not found")
}
// Нельзя отметить своё сообщение как прочитанное
if message.SenderID == userID {
return nil
}
// Проверяем, что пользователь участник чата
isMember, err := s.chatRepo.IsMember(ctx, message.ChatID, userID)
if err != nil {
return err
}
if !isMember {
return errors.New("access denied")
}
return s.messageRepo.MarkAsRead(ctx, messageID)
}
// EditMessage редактирует существующее сообщение
func (s *MessageService) EditMessage(ctx context.Context, userID, messageID int64, newPlaintext string) error {
message, err := s.messageRepo.FindByID(ctx, messageID)
if err != nil {
return err
}
if message == nil {
return errors.New("message not found")
}
// Только автор может редактировать
if message.SenderID != userID {
return errors.New("only the author can edit the message")
}
// Шифруем новое содержимое
encryptedBody, err := s.encryptor.EncryptString(newPlaintext)
if err != nil {
logger.Error("Failed to encrypt edited message", "error", err)
return errors.New("failed to encrypt message")
}
message.EncryptedBody = []byte(encryptedBody)
return s.messageRepo.Update(ctx, message)
}
// DeleteMessage удаляет сообщение
func (s *MessageService) DeleteMessage(ctx context.Context, messageID int64) error {
return s.messageRepo.Delete(ctx, messageID)
}
// CanDeleteMessage проверяет, может ли пользователь удалить сообщение
func (s *MessageService) CanDeleteMessage(ctx context.Context, userID, messageID int64) (bool, error) {
message, err := s.messageRepo.FindByID(ctx, messageID)
if err != nil {
return false, err
}
if message == nil {
return false, errors.New("message not found")
}
// Автор может удалить своё сообщение
if message.SenderID == userID {
return true, nil
}
// Проверяем, является ли пользователь глобальным админом
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return false, err
}
if user != nil && user.IsGlobalAdmin() {
return true, nil
}
// Проверяем, является ли пользователь админом чата
role, err := s.chatRepo.GetMemberRole(ctx, message.ChatID, userID)
if err != nil {
return false, err
}
return role != nil && *role == models.MemberRoleAdmin, nil
}
// GetUnreadCount возвращает количество непрочитанных сообщений в чате
func (s *MessageService) GetUnreadCount(ctx context.Context, chatID, userID int64) (int, error) {
return s.messageRepo.GetUnreadCount(ctx, chatID, userID)
}

View File

@@ -0,0 +1,125 @@
package service
import (
"context"
"errors"
"messenger/internal/models"
"messenger/internal/pkg/logger"
"messenger/internal/pkg/validator"
"messenger/internal/repository"
)
type UserService struct {
userRepo repository.UserRepository
profileRepo repository.ProfileRepository
}
func NewUserService(userRepo repository.UserRepository, profileRepo repository.ProfileRepository) *UserService {
return &UserService{
userRepo: userRepo,
profileRepo: profileRepo,
}
}
// GetProfile возвращает профиль пользователя
func (s *UserService) GetProfile(ctx context.Context, userID int64) (*models.ProfileWithUser, error) {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
logger.Error("Failed to get user", "error", err)
return nil, errors.New("internal server error")
}
if user == nil {
return nil, errors.New("user not found")
}
profile, err := s.profileRepo.FindByUserID(ctx, userID)
if err != nil {
logger.Error("Failed to get profile", "error", err)
// Профиль может отсутствовать - не ошибка
profile = &models.Profile{UserID: userID}
}
return &models.ProfileWithUser{
User: user.ToSafe(),
DisplayName: profile.DisplayName,
Bio: profile.Bio,
AvatarURL: profile.AvatarURL,
}, nil
}
// UpdateProfile обновляет профиль пользователя
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, displayName, bio *string) error {
// Валидация
if displayName != nil && !validator.ValidateDisplayName(*displayName) {
return errors.New("display name too long (max 100 characters)")
}
if bio != nil && !validator.ValidateBio(*bio) {
return errors.New("bio too long (max 500 characters)")
}
profile := &models.Profile{
UserID: userID,
DisplayName: displayName,
Bio: bio,
}
if err := s.profileRepo.Update(ctx, profile); err != nil {
logger.Error("Failed to update profile", "error", err)
return errors.New("failed to update profile")
}
logger.Info("Profile updated", "user_id", userID)
return nil
}
// UpdateAvatar обновляет аватар пользователя
func (s *UserService) UpdateAvatar(ctx context.Context, userID int64, avatarURL *string) error {
if err := s.profileRepo.UpdateAvatar(ctx, userID, avatarURL); err != nil {
logger.Error("Failed to update avatar", "error", err)
return errors.New("failed to update avatar")
}
logger.Info("Avatar updated", "user_id", userID)
return nil
}
// SearchUsers ищет пользователей по логину
func (s *UserService) SearchUsers(ctx context.Context, query string, limit int) ([]*models.SafeUser, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
users, err := s.userRepo.SearchByLogin(ctx, query, limit)
if err != nil {
logger.Error("Failed to search users", "error", err)
return nil, errors.New("internal server error")
}
safeUsers := make([]*models.SafeUser, len(users))
for i, user := range users {
safeUsers[i] = user.ToSafe()
}
return safeUsers, nil
}
// GetUserByID возвращает безопасное представление пользователя
func (s *UserService) GetUserByID(ctx context.Context, userID int64) (*models.SafeUser, error) {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
return user.ToSafe(), nil
}
// GetUserByLogin возвращает пользователя по логину
func (s *UserService) GetUserByLogin(ctx context.Context, login string) (*models.User, error) {
return s.userRepo.FindByLogin(ctx, login)
}