This commit is contained in:
madipo2611 2025-04-28 15:14:02 +03:00
commit a5a90bed7e
44 changed files with 12096 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/tailly_back_v2.iml" filepath="$PROJECT_DIR$/.idea/tailly_back_v2.iml" />
</modules>
</component>
</project>

9
.idea/tailly_back_v2.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

90
cmd/server/main.go Normal file
View File

@ -0,0 +1,90 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"tailly_back_v2/internal/config"
"tailly_back_v2/internal/http"
"tailly_back_v2/internal/repository"
"tailly_back_v2/internal/service"
"tailly_back_v2/internal/ws"
"tailly_back_v2/pkg/auth"
"tailly_back_v2/pkg/database"
"time"
)
func main() {
// Загрузка конфигурации
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Инициализация БД
db, err := database.NewPostgres(cfg.Database.DSN)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
defer db.Close()
// Инициализация зависимостей
tokenAuth := auth.NewTokenAuth(
cfg.Auth.AccessTokenSecret,
cfg.Auth.RefreshTokenSecret,
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
// Репозитории
userRepo := repository.NewUserRepository(db)
postRepo := repository.NewPostRepository(db)
commentRepo := repository.NewCommentRepository(db)
likeRepo := repository.NewLikeRepository(db)
chatRepo := repository.NewChatRepository(db)
// Сервисы
services := service.NewServices(
service.NewAuthService(userRepo, *tokenAuth),
service.NewUserService(userRepo),
service.NewPostService(postRepo),
service.NewCommentService(commentRepo),
service.NewLikeService(likeRepo),
service.NewChatService(chatRepo),
)
// Инициализация чата
chatHub := ws.NewChatHub()
go chatHub.Run()
chatService := service.NewChatService(
chatRepo,
userRepo,
chatHub,
encryptionService,
)
// HTTP сервер
server := http.NewServer(cfg, services, tokenAuth)
// Запуск сервера в отдельной горутине
go func() {
if err := server.Run(); err != nil {
log.Printf("server error: %v", err)
}
}()
// Ожидание сигнала завершения
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("server shutdown error: %v", err)
}
log.Println("server stopped")
}

30
go.mod Normal file
View File

@ -0,0 +1,30 @@
module tailly_back_v2
go 1.24
require (
github.com/99designs/gqlgen v0.17.72
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.22.0
github.com/vektah/gqlparser/v2 v2.5.25
golang.org/x/crypto v0.37.0
)
require (
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sosodev/duration v1.3.1 // indirect
golang.org/x/sys v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

70
go.sum Normal file
View File

@ -0,0 +1,70 @@
github.com/99designs/gqlgen v0.17.72 h1:2JDAuutIYtAN26BAtigfLZFnTN53fpYbIENL8bVgAKY=
github.com/99designs/gqlgen v0.17.72/go.mod h1:BoL4C3j9W2f95JeWMrSArdDNGWmZB9MOS2EMHJDZmUc=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vektah/gqlparser/v2 v2.5.25 h1:FmWtFEa+invTIzWlWK6Vk7BVEZU/97QBzeI8Z1JjGt8=
github.com/vektah/gqlparser/v2 v2.5.25/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

41
gqlgen.yml Normal file
View File

@ -0,0 +1,41 @@
schema:
- internal/http/graph/schema.graphql
exec:
filename: internal/http/graph/generated.go
package: graph
model:
filename: internal/http/graph/models_gen.go
package: graph
resolver:
filename: internal/http/graph
layout: follow-schema
package: graph
skip_generation:
- "*_resolvers.go"
- "resolvers.go"
models:
User:
model: tailly_back_v2/internal/domain.User
Post:
model: tailly_back_v2/internal/domain.Post
RegisterInput:
model: tailly_back_v2/internal/domain.RegisterInput
LoginInput:
model: tailly_back_v2/internal/domain.LoginInput
Comment:
model: tailly_back_v2/internal/domain.Comment
Like:
model: tailly_back_v2/internal/domain.like
Tokens:
model: tailly_back_v2/internal/domain.Tokens
Chat:
model: tailly_back_v2/internal/domain.Chat
Message:
model: tailly_back_v2/internal/domain.Message
WSMessage:
model: tailly_back_v2/internal/domain.WSMessage
autobind:
- "tailly_back_v2/internal/domain"

21
internal/config/config.go Normal file
View File

@ -0,0 +1,21 @@
package config
import (
"time"
)
type Config struct {
Server struct {
Host string `env:"SERVER_HOST" env-default:"localhost"`
Port string `env:"SERVER_PORT" env-default:"3006"`
}
Database struct {
DSN string `env:"DB_DSN,required"`
}
Auth struct {
AccessTokenSecret string `env:"ACCESS_TOKEN_SECRET,required"`
RefreshTokenSecret string `env:"REFRESH_TOKEN_SECRET,required"`
AccessTokenExpiry time.Duration `env:"ACCESS_TOKEN_EXPIRY" env-default:"15m"`
RefreshTokenExpiry time.Duration `env:"REFRESH_TOKEN_EXPIRY" env-default:"168h"` // 7 дней
}
}

View File

@ -0,0 +1,5 @@
scrape_configs:
- job_name: 'blog_api'
scrape_interval: 15s
static_configs:
- targets: ['host.docker.internal:9100']

28
internal/domain/chat.go Normal file
View File

@ -0,0 +1,28 @@
package domain
import "time"
type Chat struct {
ID int `json:"id"`
User1ID int `json:"user1Id"`
User2ID int `json:"user2Id"`
CreatedAt time.Time `json:"createdAt"`
}
type Message struct {
ID int `json:"id"`
ChatID int `json:"chatId"`
SenderID int `json:"senderId"`
Content string `json:"content"`
Status string `json:"status"` // "sent", "delivered", "read"
CreatedAt time.Time `json:"createdAt"`
}
type WSMessage struct {
Type string `json:"type"`
MessageID int `json:"messageId"`
ChatID int `json:"chatId"`
SenderID int `json:"senderId"`
Content string `json:"content"`
Recipient int `json:"recipient"`
}

72
internal/domain/models.go Normal file
View File

@ -0,0 +1,72 @@
package domain
import (
"context"
"time"
)
// Пользователь
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"` // Пароль не должен возвращаться в ответах
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Пост в блоге
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
AuthorID int `json:"authorId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Комментарий к посту
type Comment struct {
ID int `json:"id"`
Content string `json:"content"`
PostID int `json:"postId"`
AuthorID int `json:"authorId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Лайк к посту
type Like struct {
ID int `json:"id"`
PostID int `json:"postId"`
UserID int `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
}
// Токены для аутентификации
type Tokens struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
// Типы для аутентификации
type RegisterInput struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
type LoginInput struct {
Email string `json:"email"`
Password string `json:"password"`
}
type MutationResolver interface {
CreatePost(ctx context.Context, title string, content string) (*Post, error)
AddComment(ctx context.Context, postID int, content string) (*Comment, error)
}
type QueryResolver interface {
Posts(ctx context.Context) ([]*Post, error)
Post(ctx context.Context, id int) (*Post, error)
}

View File

@ -0,0 +1,38 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
type chatSessionResolver struct{ *Resolver }
// User is the resolver for the user field.
func (r *chatSessionResolver) User(ctx context.Context, obj *domain.ChatSession) (*domain.User, error) {
panic("not implemented")
}
// LastMessage is the resolver for the lastMessage field.
func (r *chatSessionResolver) LastMessage(ctx context.Context, obj *domain.ChatSession) (*domain.Message, error) {
panic("not implemented")
}
// UnreadCount is the resolver for the unreadCount field.
func (r *chatSessionResolver) UnreadCount(ctx context.Context, obj *domain.ChatSession) (int, error) {
panic("not implemented")
}
// Sender is the resolver for the sender field.
func (r *messageResolver) Sender(ctx context.Context, obj *domain.Message) (*domain.User, error) {
panic("not implemented")
}
// Receiver is the resolver for the receiver field.
func (r *messageResolver) Receiver(ctx context.Context, obj *domain.Message) (*domain.User, error) {
panic("not implemented")
}
// CreatedAt is the resolver for the createdAt field.
func (r *messageResolver) CreatedAt(ctx context.Context, obj *domain.Message) (string, error) {
panic("not implemented")
}

View File

@ -0,0 +1,29 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
// Comment is the resolver for the comment field.
type commentResolver struct{ *Resolver }
// Author is the resolver for the author field.
func (r *commentResolver) Author(ctx context.Context, obj *domain.Comment) (*domain.User, error) {
return r.services.User.GetByID(ctx, obj.AuthorID)
}
// Post is the resolver for the post field.
func (r *commentResolver) Post(ctx context.Context, obj *domain.Comment) (*domain.Post, error) {
return r.services.Post.GetByID(ctx, obj.PostID)
}
// CreatedAt is the resolver for the createdAt field.
func (r *commentResolver) CreatedAt(ctx context.Context, obj *domain.Comment) (string, error) {
panic("not implemented")
}
// UpdatedAt is the resolver for the updatedAt field.
func (r *commentResolver) UpdatedAt(ctx context.Context, obj *domain.Comment) (string, error) {
panic("not implemented")
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
// LikeResolver реализует методы для работы с лайками в GraphQL
type likeResolver struct{ *Resolver }
// User возвращает пользователя, который поставил лайк
func (r *likeResolver) User(ctx context.Context, obj *domain.Like) (*domain.User, error) {
return r.services.User.GetByID(ctx, obj.UserID)
}
// Post возвращает пост, который был лайкнут
func (r *likeResolver) Post(ctx context.Context, obj *domain.Like) (*domain.Post, error) {
return r.services.Post.GetByID(ctx, obj.PostID)
}
// CreatedAt is the resolver for the createdAt field.
func (r *likeResolver) CreatedAt(ctx context.Context, obj *domain.Like) (string, error) {
panic("not implemented")
}

View File

@ -0,0 +1,12 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package graph
type Mutation struct {
}
type Query struct {
}
type Subscription struct {
}

View File

@ -0,0 +1,48 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
type postResolver struct{ *Resolver }
// Author is the resolver for the author field.
func (r *postResolver) Author(ctx context.Context, obj *domain.Post) (*domain.User, error) {
return r.services.User.GetByID(ctx, obj.AuthorID)
}
// Comments is the resolver for the comments field.
func (r *postResolver) Comments(ctx context.Context, obj *domain.Post) ([]*domain.Comment, error) {
return r.services.Comment.GetByPostID(ctx, obj.ID)
}
// Likes is the resolver for the likes field.
func (r *postResolver) Likes(ctx context.Context, obj *domain.Post) ([]*domain.Like, error) {
return r.services.Like.GetByPostID(ctx, obj.ID)
}
// Post.likesCount - получение количества лайков для поста
func (r *postResolver) LikesCount(ctx context.Context, obj *domain.Post) (int, error) {
return r.services.Like.GetCountForPost(ctx, obj.ID)
}
// Post.isLiked - проверка, поставил ли текущий пользователь лайк
func (r *postResolver) IsLiked(ctx context.Context, obj *domain.Post) (bool, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return false, nil // Не авторизован - считаем что не лайкал
}
return r.services.Like.CheckIfLiked(ctx, userID, obj.ID)
}
// CreatedAt is the resolver for the createdAt field.
func (r *postResolver) CreatedAt(ctx context.Context, obj *domain.Post) (string, error) {
panic("not implemented")
}
// UpdatedAt is the resolver for the updatedAt field.
func (r *postResolver) UpdatedAt(ctx context.Context, obj *domain.Post) (string, error) {
panic("not implemented")
}

View File

@ -0,0 +1,221 @@
package graph
import (
"context"
"errors"
"github.com/vektah/gqlparser/v2/gqlerror"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/service"
)
// Реализуем корневые резолверы
type Resolver struct {
services *service.Services
}
// Ensure все интерфейсы реализованы
var _ domain.QueryResolver = (*Resolver)(nil)
var _ domain.MutationResolver = (*Resolver)(nil)
func NewResolver(services *service.Services) *Resolver {
return &Resolver{services: services}
}
// ChatSession returns ChatSessionResolver implementation.
func (r *Resolver) ChatSession() ChatSessionResolver { return &chatSessionResolver{r} }
// Comment returns CommentResolver implementation.
func (r *Resolver) Comment() CommentResolver { return &commentResolver{r} }
// Like returns LikeResolver implementation.
func (r *Resolver) Like() LikeResolver { return &likeResolver{r} }
// Message returns MessageResolver implementation.
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Post returns PostResolver implementation.
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
// Subscription returns SubscriptionResolver implementation.
func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} }
// User returns UserResolver implementation.
func (r *Resolver) User() UserResolver { return &userResolver{r} }
// Query resolvers
func (r *Resolver) Me(ctx context.Context) (*domain.User, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.User.GetByID(ctx, userID)
}
func (r *Resolver) Posts(ctx context.Context) ([]*domain.Post, error) {
return r.services.Post.GetAll(ctx)
}
// Mutation resolvers
func (r *Resolver) Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error) {
return r.services.Auth.Register(ctx, service.RegisterInput{
Username: input.Username,
Email: input.Email,
Password: input.Password,
})
}
func (r *Resolver) Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error) {
return r.services.Auth.Login(ctx, input.Email, input.Password)
}
// mutationResolver реализует MutationResolver интерфейс
type mutationResolver struct{ *Resolver }
// Register is the resolver for the register field.
func (r *mutationResolver) Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error) {
return r.services.Auth.Register(ctx, service.RegisterInput(input))
}
// Login is the resolver for the login field.
func (r *mutationResolver) Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error) {
return r.services.Auth.Login(ctx, input.Email, input.Password)
}
// RefreshTokens is the resolver for the refreshTokens field.
func (r *mutationResolver) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) {
return r.services.Auth.RefreshTokens(ctx, refreshToken)
}
// CreatePost is the resolver for the createPost field.
func (r *mutationResolver) CreatePost(ctx context.Context, title string, content string) (*domain.Post, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.Post.Create(ctx, userID, title, content)
}
// CreateComment is the resolver for the createComment field.
func (r *mutationResolver) CreateComment(ctx context.Context, postID string, content string) (*domain.Comment, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.Comment.Create(ctx, postID, userID, content)
}
// Mutation: likePost - добавление лайка к посту
func (r *mutationResolver) LikePost(ctx context.Context, postID string) (*domain.Like, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
// Проверяем, не поставил ли пользователь уже лайк
liked, err := r.services.Like.CheckIfLiked(ctx, userID, postID)
if err != nil {
return nil, err
}
if liked {
return nil, gqlerror.Errorf("you have already liked this post")
}
return r.services.Like.LikePost(ctx, userID, postID)
}
// Mutation: unlikePost - удаление лайка с поста
func (r *mutationResolver) UnlikePost(ctx context.Context, postID string) (bool, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return false, err
}
// Проверяем, ставил ли пользователь лайк
liked, err := r.services.Like.CheckIfLiked(ctx, userID, postID)
if err != nil {
return false, err
}
if !liked {
return false, gqlerror.Errorf("you haven't liked this post yet")
}
if err := r.services.Like.UnlikePost(ctx, userID, postID); err != nil {
return false, err
}
return true, nil
}
// Mutation.changePassword - смена пароля
func (r *mutationResolver) ChangePassword(ctx context.Context, oldPassword string, newPassword string) (bool, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return false, err
}
if err := r.services.User.ChangePassword(ctx, userID, oldPassword, newPassword); err != nil {
return false, err
}
return true, nil
}
// SendMessage is the resolver for the sendMessage field.
func (r *mutationResolver) SendMessage(ctx context.Context, receiverID string, content string) (*domain.Message, error) {
panic("not implemented")
}
// MarkAsRead is the resolver for the markAsRead field.
func (r *mutationResolver) MarkAsRead(ctx context.Context, messageID string) (bool, error) {
panic("not implemented")
}
// queryResolver реализует QueryResolver интерфейс
type queryResolver struct{ *Resolver }
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*domain.User, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.User.GetByID(ctx, userID)
}
// Post is the resolver for the post field.
func (r *queryResolver) Post(ctx context.Context, id string) (*domain.Post, error) {
return r.services.Post.GetByID(ctx, id)
}
// Posts is the resolver for the posts field.
func (r *queryResolver) Posts(ctx context.Context) ([]*domain.Post, error) {
return r.services.Post.GetAll(ctx)
}
// GetChatHistory is the resolver for the getChatHistory field.
func (r *queryResolver) GetChatHistory(ctx context.Context, userID string) ([]*domain.Message, error) {
panic("not implemented")
}
// GetUserChats is the resolver for the getUserChats field.
func (r *queryResolver) GetUserChats(ctx context.Context) ([]*domain.ChatSession, error) {
panic("not implemented")
}
// getUserIDFromContext извлекает userID из контекста
func getUserIDFromContext(ctx context.Context) (int, error) {
userID, ok := ctx.Value("userID").(int)
if !ok {
return 0, errors.New("unauthorized")
}
return userID, nil
}

View File

@ -0,0 +1,117 @@
# Тип пользователя
type User {
id: ID! # Уникальный идентификатор
username: String! # Имя пользователя
email: String! # Email (уникальный)
createdAt: String! # Дата создания
updatedAt: String! # Дата обновления
}
# Пост в блоге
type Post {
id: ID! # Уникальный идентификатор
title: String! # Заголовок поста
content: String! # Содержание поста
author: User! # Автор поста
comments: [Comment!]! # Комментарии к посту
likes: [Like!]! # Лайки к посту
likesCount: Int!
isLiked: Boolean!
createdAt: String! # Дата создания
updatedAt: String! # Дата обновления
}
# Комментарий к посту
type Comment {
id: ID! # Уникальный идентификатор
content: String! # Текст комментария
post: Post! # Пост, к которому относится
author: User! # Автор комментария
createdAt: String! # Дата создания
updatedAt: String! # Дата обновления
}
# Лайк к посту
type Like {
id: ID! # Уникальный идентификатор
post: Post! # Пост, который лайкнули
user: User! # Пользователь, который поставил лайк
createdAt: String! # Дата создания
}
# Токены для аутентификации
type Tokens {
accessToken: String! # Access токен (короткоживущий)
refreshToken: String! # Refresh токен (долгоживущий)
}
type Message {
id: ID!
sender: User!
receiver: User!
content: String!
createdAt: String!
status: String!
}
type ChatSession {
user: User!
lastMessage: Message!
unreadCount: Int!
}
type Subscription {
messageReceived: Message!
}
input RegisterInput {
username: String!
email: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
# Запросы (получение данных)
type Query {
me: User! # Получить текущего пользователя
post(id: ID!): Post! # Получить пост по ID
posts: [Post!]! # Получить все посты
user(id: ID!): User! # Получить пользователя по ID
getChatHistory(userId: ID!): [Message!]!
getUserChats: [ChatSession!]!
}
# Мутации (изменение данных)
type Mutation {
# Регистрация нового пользователя
register(input: RegisterInput!): User!
# Вход в систему
login(input: LoginInput!): Tokens!
# Обновление токенов
refreshTokens(refreshToken: String!): Tokens!
# Создание поста
createPost(title: String!, content: String!): Post!
# Создание комментария
createComment(postId: ID!, content: String!): Comment!
# Лайк поста
likePost(postId: ID!): Like!
# Удаление лайка
unlikePost(postId: ID!): Boolean!
updateProfile(username: String!, email: String!): User!
changePassword(oldPassword: String!, newPassword: String!): Boolean!
sendMessage(receiverId: ID!, content: String!): Message!
markAsRead(messageId: ID!): Boolean!
}

View File

@ -0,0 +1,3 @@
package graph
// THIS CODE WILL BE UPDATED WITH SCHEMA CHANGES. PREVIOUS IMPLEMENTATION FOR SCHEMA CHANGES WILL BE KEPT IN THE COMMENT SECTION. IMPLEMENTATION FOR UNCHANGED SCHEMA WILL BE KEPT.

View File

@ -0,0 +1,13 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
type subscriptionResolver struct{ *Resolver }
// MessageReceived is the resolver for the messageReceived field.
func (r *subscriptionResolver) MessageReceived(ctx context.Context) (<-chan *domain.Message, error) {
panic("not implemented")
}

View File

@ -0,0 +1,34 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
// userResolver реализует методы для работы с пользователями
type userResolver struct{ *Resolver }
// Query.user - получение пользователя по ID
func (r *queryResolver) User(ctx context.Context, id string) (*domain.User, error) {
return r.services.User.GetByID(ctx, id)
}
// CreatedAt is the resolver for the createdAt field.
func (r *userResolver) CreatedAt(ctx context.Context, obj *domain.User) (string, error) {
panic("not implemented")
}
// UpdatedAt is the resolver for the updatedAt field.
func (r *userResolver) UpdatedAt(ctx context.Context, obj *domain.User) (string, error) {
panic("not implemented")
}
// Mutation.updateProfile - обновление профиля
func (r *mutationResolver) UpdateProfile(ctx context.Context, username string, email string) (*domain.User, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.User.UpdateProfile(ctx, userID, username, email)
}

View File

@ -0,0 +1,79 @@
package handlers
import (
"net/http"
"strconv"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/service"
"tailly_back_v2/internal/ws"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
type ChatHandler struct {
chatService service.ChatService
hub *ws.ChatHub
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // В production заменить на проверку origin
},
}
func (h *ChatHandler) WebSocketConnection(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.Atoi(chi.URLParam(r, "userID"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "Could not open websocket connection", http.StatusBadRequest)
return
}
client := &ws.Client{
UserID: userID,
Send: make(chan *domain.Message, 256),
}
h.hub.Register(client)
// Горутина для чтения сообщений
go h.readPump(conn, client)
// Горутина для записи сообщений
go h.writePump(conn, client)
}
func (h *ChatHandler) readPump(conn *websocket.Conn, client *ws.Client) {
defer func() {
h.hub.Unregister(client)
conn.Close()
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
// Обработка входящих сообщений (если нужно)
}
}
func (h *ChatHandler) writePump(conn *websocket.Conn, client *ws.Client) {
defer conn.Close()
for {
message, ok := <-client.Send
if !ok {
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteJSON(message); err != nil {
return
}
}
}

View File

@ -0,0 +1,64 @@
package middleware
import (
"context"
"net/http"
"strings"
"tailly_back_v2/pkg/auth"
)
const (
authorizationHeader = "Authorization"
bearerPrefix = "Bearer "
userIDKey = "userID" // Ключ для хранения userID в контексте
)
// AuthMiddleware проверяет JWT токен и добавляет userID в контекст
func AuthMiddleware(tokenAuth *auth.TokenAuth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Пропускаем OPTIONS запросы (для CORS)
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Извлекаем заголовок Authorization
header := r.Header.Get(authorizationHeader)
if header == "" {
next.ServeHTTP(w, r)
return
}
// Проверяем формат заголовка
if !strings.HasPrefix(header, bearerPrefix) {
next.ServeHTTP(w, r)
return
}
// Извлекаем токен
token := strings.TrimPrefix(header, bearerPrefix)
if token == "" {
next.ServeHTTP(w, r)
return
}
// Валидируем токен
userID, err := tokenAuth.ValidateAccessToken(token)
if err != nil {
next.ServeHTTP(w, r)
return
}
// Добавляем userID в контекст
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUserIDFromContext извлекает userID из контекста
func GetUserIDFromContext(ctx context.Context) (int, bool) {
userID, ok := ctx.Value(userIDKey).(int)
return userID, ok
}

View File

@ -0,0 +1,54 @@
package middleware
import (
"net/http"
"strings"
)
// CORS middleware настраивает политику кросс-доменных запросов
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Проверяем, разрешен ли источник
if isOriginAllowed(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers",
"Accept, Content-Type, Content-Length, Accept-Encoding, Authorization")
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS")
}
// Обрабатываем предварительные OPTIONS-запросы
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// isOriginAllowed проверяет разрешен ли домен для CORS
func isOriginAllowed(origin string, allowedOrigins []string) bool {
if origin == "" {
return false
}
// Разрешаем все источники в development
if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
return true
}
// Точноe сравнение с разрешенными доменами
for _, allowed := range allowedOrigins {
if strings.EqualFold(origin, allowed) {
return true
}
}
return false
}

View File

@ -0,0 +1,83 @@
package middleware
import (
"bytes"
"io"
"log"
"net/http"
"time"
)
// LoggingMiddleware логирует входящие HTTP-запросы
func LoggingMiddleware(logger *log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Логируем основные параметры запроса
logData := map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"ip": r.RemoteAddr,
}
// Чтение тела запроса (для логирования)
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if len(bodyBytes) > 0 {
logData["body_size"] = len(bodyBytes)
// Для JSON-запросов логируем тело
if r.Header.Get("Content-Type") == "application/json" {
logData["body"] = string(bodyBytes)
}
}
}
// Перехват ответа
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
// Обработка запроса
next.ServeHTTP(rw, r)
// Дополняем данные для логирования
duration := time.Since(start)
logData["status"] = rw.status
logData["duration"] = duration.String()
logData["response_size"] = rw.size
// Форматированный вывод лога
logger.Printf(
"%s %s %d %s | IP: %s | Duration: %s | Body: %d bytes",
r.Method,
r.URL.Path,
rw.status,
http.StatusText(rw.status),
r.RemoteAddr,
duration,
len(bodyBytes),
)
})
}
}
// Кастомный responseWriter для перехвата статуса и размера ответа
type responseWriter struct {
http.ResponseWriter
status int
size int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
size, err := rw.ResponseWriter.Write(b)
rw.size += size
return size, err
}

View File

@ -0,0 +1,67 @@
package middleware
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Duration of HTTP requests",
Buckets: []float64{0.1, 0.5, 1, 2.5, 5, 10},
},
[]string{"method", "path"},
)
httpResponseSize = promauto.NewSummaryVec(
prometheus.SummaryOpts{
Name: "http_response_size_bytes",
Help: "Size of HTTP responses",
},
[]string{"method", "path"},
)
)
// MetricsMiddleware собирает метрики для Prometheus
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{w, http.StatusOK, 0}
next.ServeHTTP(rw, r)
duration := time.Since(start).Seconds()
status := strconv.Itoa(rw.status)
// Регистрируем метрики
httpRequestsTotal.WithLabelValues(
r.Method,
r.URL.Path,
status,
).Inc()
httpRequestDuration.WithLabelValues(
r.Method,
r.URL.Path,
).Observe(duration)
httpResponseSize.WithLabelValues(
r.Method,
r.URL.Path,
).Observe(float64(rw.size))
})
}

91
internal/http/server.go Normal file
View File

@ -0,0 +1,91 @@
package http
import (
"context"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"log"
"net/http"
"os"
"tailly_back_v2/internal/config"
"tailly_back_v2/internal/http/graph"
"tailly_back_v2/internal/http/middleware"
"tailly_back_v2/internal/service"
"tailly_back_v2/pkg/auth"
)
type Server struct {
router *chi.Mux
cfg *config.Config
services *service.Services
tokenAuth *auth.TokenAuth
}
func NewServer(cfg *config.Config, services *service.Services, tokenAuth *auth.TokenAuth) *Server {
s := &Server{
router: chi.NewRouter(),
cfg: cfg,
services: services,
tokenAuth: tokenAuth,
}
s.configureRouter()
return s
}
func (s *Server) configureRouter() {
allowedOrigins := []string{
"http://localhost:3000", // React dev server
"https://your-production.app", // Продакшен домен
}
// Логирование
logger := log.New(os.Stdout, "HTTP: ", log.LstdFlags)
s.router.Use(middleware.LoggingMiddleware(logger))
// Метрики
s.router.Use(middleware.MetricsMiddleware)
// Middleware
s.router.Use(middleware.CORS(allowedOrigins))
s.router.Use(middleware.AuthMiddleware(s.tokenAuth))
// GraphQL handler
resolver := graph.NewResolver(s.services)
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
Resolvers: resolver,
}))
// Routes
s.router.Handle("/", playground.Handler("GraphQL playground", "/query"))
s.router.Handle("/query", srv)
}
func (s *Server) Run() error {
return http.ListenAndServe(s.cfg.Server.Host+":"+s.cfg.Server.Port, s.router)
}
func (s *Server) Shutdown(ctx context.Context) error {
// Здесь можно добавить логику graceful shutdown
return nil
}
// Отдельный endpoint для метрик Prometheus
func (s *Server) configureMetrics() {
prometheus.MustRegister(
middleware.httpRequestsTotal,
middleware.httpRequestDuration,
middleware.httpResponseSize,
)
metricsRouter := chi.NewRouter()
metricsRouter.Get("/metrics", promhttp.Handler().ServeHTTP)
go func() {
if err := http.ListenAndServe(":9100", metricsRouter); err != nil {
log.Printf("Metrics server error: %v", err)
}
}()
}

View File

@ -0,0 +1,206 @@
package repository
import (
"context"
"database/sql"
"errors"
"tailly_back_v2/internal/domain"
"time"
)
var (
ErrMessageNotFound = errors.New("message not found")
ErrChatNotFound = errors.New("chat not found")
)
type ChatRepository interface {
// Основные методы сообщений
SaveMessage(ctx context.Context, message *domain.Message) error
GetMessageByID(ctx context.Context, id int) (*domain.Message, error)
GetMessagesByChat(ctx context.Context, chatID int, limit, offset int) ([]*domain.Message, error)
UpdateMessageStatus(ctx context.Context, id int, status string) error
DeleteMessage(ctx context.Context, id int) error
// Методы чатов
CreateChat(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error)
GetChatByID(ctx context.Context, id int) (*domain.Chat, error)
GetUserChats(ctx context.Context, userID int) ([]*domain.Chat, error)
GetChatByParticipants(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error)
}
type chatRepository struct {
db *sql.DB
}
func NewChatRepository(db *sql.DB) ChatRepository {
return &chatRepository{db: db}
}
func (r *chatRepository) SaveMessage(ctx context.Context, message *domain.Message) error {
query := `
INSERT INTO messages (chat_id, sender_id, content, status, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
message.ChatID,
message.SenderID,
message.Content,
message.Status,
message.CreatedAt,
).Scan(&message.ID)
return err
}
func (r *chatRepository) GetMessageByID(ctx context.Context, id int) (*domain.Message, error) {
query := `
SELECT id, chat_id, sender_id, content, status, created_at
FROM messages
WHERE id = $1
`
message := &domain.Message{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&message.ID,
&message.ChatID,
&message.SenderID,
&message.Content,
&message.Status,
&message.CreatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrMessageNotFound
}
return message, err
}
func (r *chatRepository) GetMessagesByChat(ctx context.Context, chatID int, limit, offset int) ([]*domain.Message, error) {
query := `
SELECT id, chat_id, sender_id, content, status, created_at
FROM messages
WHERE chat_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, chatID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []*domain.Message
for rows.Next() {
var message domain.Message
if err := rows.Scan(
&message.ID,
&message.ChatID,
&message.SenderID,
&message.Content,
&message.Status,
&message.CreatedAt,
); err != nil {
return nil, err
}
messages = append(messages, &message)
}
return messages, nil
}
func (r *chatRepository) UpdateMessageStatus(ctx context.Context, id int, status string) error {
query := `
UPDATE messages
SET status = $1
WHERE id = $2
`
_, err := r.db.ExecContext(ctx, query, status, id)
return err
}
func (r *chatRepository) DeleteMessage(ctx context.Context, id int) error {
query := `DELETE FROM messages WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
func (r *chatRepository) CreateChat(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error) {
query := `
INSERT INTO chats (user1_id, user2_id, created_at)
VALUES ($1, $2, $3)
RETURNING id
`
chat := &domain.Chat{
User1ID: user1ID,
User2ID: user2ID,
CreatedAt: time.Now(),
}
err := r.db.QueryRowContext(ctx, query, user1ID, user2ID, chat.CreatedAt).Scan(&chat.ID)
return chat, err
}
func (r *chatRepository) GetChatByID(ctx context.Context, id int) (*domain.Chat, error) {
query := `
SELECT id, user1_id, user2_id, created_at
FROM chats
WHERE id = $1
`
chat := &domain.Chat{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&chat.ID,
&chat.User1ID,
&chat.User2ID,
&chat.CreatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrChatNotFound
}
return chat, err
}
func (r *chatRepository) GetUserChats(ctx context.Context, userID int) ([]*domain.Chat, error) {
query := `
SELECT id, user1_id, user2_id, created_at
FROM chats
WHERE user1_id = $1 OR user2_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var chats []*domain.Chat
for rows.Next() {
var chat domain.Chat
if err := rows.Scan(
&chat.ID,
&chat.User1ID,
&chat.User2ID,
&chat.CreatedAt,
); err != nil {
return nil, err
}
chats = append(chats, &chat)
}
return chats, nil
}
func (r *chatRepository) GetChatByParticipants(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error) {
query := `
SELECT id, user1_id, user2_id, created_at
FROM chats
WHERE (user1_id = $1 AND user2_id = $2)
OR (user1_id = $2 AND user2_id = $1)
LIMIT 1
`
chat := &domain.Chat{}
err := r.db.QueryRowContext(ctx, query, user1ID, user2ID).Scan(
&chat.ID,
&chat.User1ID,
&chat.User2ID,
&chat.CreatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrChatNotFound
}
return chat, err
}

View File

@ -0,0 +1,80 @@
package repository
import (
"context"
"database/sql"
"errors"
"tailly_back_v2/internal/domain"
)
var (
ErrCommentNotFound = errors.New("comment not found")
)
type CommentRepository interface {
Create(ctx context.Context, comment *domain.Comment) error
GetByID(ctx context.Context, id int) (*domain.Comment, error)
GetByPostID(ctx context.Context, postID int) ([]*domain.Comment, error)
Update(ctx context.Context, comment *domain.Comment) error
Delete(ctx context.Context, id int) error
}
type commentRepository struct {
db *sql.DB
}
func NewCommentRepository(db *sql.DB) CommentRepository {
return &commentRepository{db: db}
}
func (r *commentRepository) Create(ctx context.Context, comment *domain.Comment) error {
query := `
INSERT INTO comments (content, post_id, author_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
comment.Content,
comment.PostID,
comment.AuthorID,
comment.CreatedAt,
comment.UpdatedAt,
).Scan(&comment.ID)
return err
}
func (r *commentRepository) GetByPostID(ctx context.Context, postID int) ([]*domain.Comment, error) {
query := `
SELECT id, content, post_id, author_id, created_at, updated_at
FROM comments
WHERE post_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var comments []*domain.Comment
for rows.Next() {
comment := &domain.Comment{}
err := rows.Scan(
&comment.ID,
&comment.Content,
&comment.PostID,
&comment.AuthorID,
&comment.CreatedAt,
&comment.UpdatedAt,
)
if err != nil {
return nil, err
}
comments = append(comments, comment)
}
return comments, nil
}

View File

@ -0,0 +1,96 @@
package repository
import (
"context"
"database/sql"
"errors"
"tailly_back_v2/internal/domain"
)
var (
ErrLikeNotFound = errors.New("like not found")
ErrLikeAlreadyExists = errors.New("like already exists")
)
type LikeRepository interface {
Create(ctx context.Context, like *domain.Like) error
GetByID(ctx context.Context, id int) (*domain.Like, error)
GetByPostID(ctx context.Context, postID int) ([]*domain.Like, error)
GetByUserAndPost(ctx context.Context, userID, postID int) (*domain.Like, error)
Delete(ctx context.Context, id int) error
DeleteByUserAndPost(ctx context.Context, userID, postID int) error
}
type likeRepository struct {
db *sql.DB
}
func NewLikeRepository(db *sql.DB) LikeRepository {
return &likeRepository{db: db}
}
func (r *likeRepository) Create(ctx context.Context, like *domain.Like) error {
// Проверяем, существует ли уже лайк
_, err := r.GetByUserAndPost(ctx, like.UserID, like.PostID)
if err == nil {
return ErrLikeAlreadyExists
} else if err != ErrLikeNotFound {
return err
}
query := `
INSERT INTO likes (post_id, user_id, created_at)
VALUES ($1, $2, $3)
RETURNING id
`
err = r.db.QueryRowContext(ctx, query,
like.PostID,
like.UserID,
like.CreatedAt,
).Scan(&like.ID)
return err
}
func (r *likeRepository) GetByPostID(ctx context.Context, postID int) ([]*domain.Like, error) {
query := `
SELECT id, post_id, user_id, created_at
FROM likes
WHERE post_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var likes []*domain.Like
for rows.Next() {
like := &domain.Like{}
err := rows.Scan(
&like.ID,
&like.PostID,
&like.UserID,
&like.CreatedAt,
)
if err != nil {
return nil, err
}
likes = append(likes, like)
}
return likes, nil
}
func (r *likeRepository) DeleteByUserAndPost(ctx context.Context, userID, postID int) error {
query := `
DELETE FROM likes
WHERE user_id = $1 AND post_id = $2
`
_, err := r.db.ExecContext(ctx, query, userID, postID)
return err
}

View File

@ -0,0 +1,104 @@
package repository
import (
"context"
"database/sql"
"errors"
"tailly_back_v2/internal/domain"
)
var (
ErrPostNotFound = errors.New("post not found")
)
type PostRepository interface {
Create(ctx context.Context, post *domain.Post) error
GetByID(ctx context.Context, id int) (*domain.Post, error)
GetAll(ctx context.Context) ([]*domain.Post, error)
GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error)
Update(ctx context.Context, post *domain.Post) error
Delete(ctx context.Context, id int) error
}
type postRepository struct {
db *sql.DB
}
func NewPostRepository(db *sql.DB) PostRepository {
return &postRepository{db: db}
}
func (r *postRepository) Create(ctx context.Context, post *domain.Post) error {
query := `
INSERT INTO posts (title, content, author_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
post.Title,
post.Content,
post.AuthorID,
post.CreatedAt,
post.UpdatedAt,
).Scan(&post.ID)
return err
}
func (r *postRepository) GetByID(ctx context.Context, id int) (*domain.Post, error) {
query := `
SELECT id, title, content, author_id, created_at, updated_at
FROM posts
WHERE id = $1
`
post := &domain.Post{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&post.ID,
&post.Title,
&post.Content,
&post.AuthorID,
&post.CreatedAt,
&post.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrPostNotFound
}
return post, err
}
func (r *postRepository) GetAll(ctx context.Context) ([]*domain.Post, error) {
query := `
SELECT id, title, content, author_id, created_at, updated_at
FROM posts
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []*domain.Post
for rows.Next() {
post := &domain.Post{}
err := rows.Scan(
&post.ID,
&post.Title,
&post.Content,
&post.AuthorID,
&post.CreatedAt,
&post.UpdatedAt,
)
if err != nil {
return nil, err
}
posts = append(posts, post)
}
return posts, nil
}

View File

@ -0,0 +1,72 @@
package repository
import (
"context"
"database/sql"
"errors"
"tailly_back_v2/internal/domain"
)
var (
ErrUserNotFound = errors.New("user not found")
)
type UserRepository interface {
Create(ctx context.Context, user *domain.User) error
GetByID(ctx context.Context, id int) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
Update(ctx context.Context, user *domain.User) error
Delete(ctx context.Context, id int) error
}
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (username, email, password, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
user.Username,
user.Email,
user.Password,
user.CreatedAt,
user.UpdatedAt,
).Scan(&user.ID)
return err
}
func (r *userRepository) GetByID(ctx context.Context, id int) (*domain.User, error) {
query := `
SELECT id, username, email, password, created_at, updated_at
FROM users
WHERE id = $1
`
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Username,
&user.Email,
&user.Password,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return user, err
}
// Остальные методы аналогично...

View File

@ -0,0 +1,111 @@
package service
import (
"context"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"tailly_back_v2/pkg/auth"
"time"
)
// Интерфейс сервиса аутентификации
type AuthService interface {
Register(ctx context.Context, input RegisterInput) (*domain.User, error)
Login(ctx context.Context, email, password string) (*domain.Tokens, error)
RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error)
}
// Реализация сервиса аутентификации
type authService struct {
userRepo repository.UserRepository
tokenAuth auth.TokenAuth
}
// Конструктор сервиса
func NewAuthService(userRepo repository.UserRepository, tokenAuth auth.TokenAuth) AuthService {
return &authService{
userRepo: userRepo,
tokenAuth: tokenAuth,
}
}
// Входные данные для регистрации
type RegisterInput struct {
Username string
Email string
Password string
}
// Регистрация нового пользователя
func (s *authService) Register(ctx context.Context, input RegisterInput) (*domain.User, error) {
// Проверка существования пользователя
_, err := s.userRepo.GetByEmail(ctx, input.Email)
if err == nil {
return nil, errors.New("user with this email already exists")
} else if err != repository.ErrUserNotFound {
return nil, err
}
// Хеширование пароля
hashedPassword, err := auth.HashPassword(input.Password)
if err != nil {
return nil, err
}
// Создание пользователя
user := &domain.User{
Username: input.Username,
Email: input.Email,
Password: hashedPassword,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// Вход в систему
func (s *authService) Login(ctx context.Context, email, password string) (*domain.Tokens, error) {
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
if err == repository.ErrUserNotFound {
return nil, errors.New("invalid credentials")
}
return nil, err
}
// Проверка пароля
if !auth.CheckPasswordHash(password, user.Password) {
return nil, errors.New("invalid credentials")
}
// Генерация токенов
tokens, err := s.tokenAuth.GenerateTokens(user.ID)
if err != nil {
return nil, err
}
return tokens, nil
}
// Обновление токенов
func (s *authService) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) {
userID, err := s.tokenAuth.ValidateRefreshToken(refreshToken)
if err != nil {
return nil, errors.New("invalid refresh token")
}
// Проверка существования пользователя
_, err = s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, errors.New("user not found")
}
// Генерация новых токенов
return s.tokenAuth.GenerateTokens(userID)
}

View File

@ -0,0 +1,152 @@
package service
import (
"context"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"tailly_back_v2/internal/ws"
"time"
)
type ChatService interface {
SendMessage(ctx context.Context, senderID, chatID int, content string) (*domain.Message, error)
GetChatMessages(ctx context.Context, chatID, userID int, limit, offset int) ([]*domain.Message, error)
MarkAsRead(ctx context.Context, messageID int) error
DeleteMessage(ctx context.Context, messageID, userID int) error
GetUserChats(ctx context.Context, userID int) ([]*domain.Chat, error)
GetOrCreateChat(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error)
}
type chatService struct {
chatRepo repository.ChatRepository
userRepo repository.UserRepository
hub *ws.ChatHub
encryptor EncryptionService
}
func NewChatService(
chatRepo repository.ChatRepository,
userRepo repository.UserRepository,
hub *ws.ChatHub,
encryptor EncryptionService,
) ChatService {
return &chatService{
chatRepo: chatRepo,
userRepo: userRepo,
hub: hub,
encryptor: encryptor,
}
}
func (s *chatService) SendMessage(ctx context.Context, senderID, chatID int, content string) (*domain.Message, error) {
// Проверяем существование чата
chat, err := s.chatRepo.GetChatByID(ctx, chatID)
if err != nil {
return nil, err
}
// Проверяем, что отправитель является участником чата
if senderID != chat.User1ID && senderID != chat.User2ID {
return nil, errors.New("user is not a participant of this chat")
}
// Шифруем сообщение (если используется E2EE)
encryptedContent, err := s.encryptor.EncryptMessage(content)
if err != nil {
return nil, err
}
message := &domain.Message{
ChatID: chatID,
SenderID: senderID,
Content: encryptedContent,
Status: "sent",
CreatedAt: time.Now(),
}
if err := s.chatRepo.SaveMessage(ctx, message); err != nil {
return nil, err
}
// Отправляем через WebSocket
recipientID := chat.User1ID
if senderID == chat.User1ID {
recipientID = chat.User2ID
}
s.hub.Broadcast(&domain.WSMessage{
Type: "new_message",
MessageID: message.ID,
ChatID: chatID,
SenderID: senderID,
Content: encryptedContent,
Recipient: recipientID,
})
return message, nil
}
func (s *chatService) GetChatMessages(ctx context.Context, chatID, userID int, limit, offset int) ([]*domain.Message, error) {
// Проверяем доступ пользователя к чату
chat, err := s.chatRepo.GetChatByID(ctx, chatID)
if err != nil {
return nil, err
}
if userID != chat.User1ID && userID != chat.User2ID {
return nil, errors.New("access denied")
}
messages, err := s.chatRepo.GetMessagesByChat(ctx, chatID, limit, offset)
if err != nil {
return nil, err
}
// Расшифровываем сообщения (если используется E2EE)
for i := range messages {
decrypted, err := s.encryptor.DecryptMessage(messages[i].Content)
if err != nil {
return nil, err
}
messages[i].Content = decrypted
}
return messages, nil
}
func (s *chatService) MarkAsRead(ctx context.Context, messageID int) error {
return s.chatRepo.UpdateMessageStatus(ctx, messageID, "read")
}
func (s *chatService) DeleteMessage(ctx context.Context, messageID, userID int) error {
message, err := s.chatRepo.GetMessageByID(ctx, messageID)
if err != nil {
return err
}
if message.SenderID != userID {
return errors.New("only sender can delete the message")
}
return s.chatRepo.DeleteMessage(ctx, messageID)
}
func (s *chatService) GetUserChats(ctx context.Context, userID int) ([]*domain.Chat, error) {
return s.chatRepo.GetUserChats(ctx, userID)
}
func (s *chatService) GetOrCreateChat(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error) {
// Проверяем существование чата
chat, err := s.chatRepo.GetChatByParticipants(ctx, user1ID, user2ID)
if err == nil {
return chat, nil
}
if !errors.Is(err, repository.ErrChatNotFound) {
return nil, err
}
// Создаем новый чат
return s.chatRepo.CreateChat(ctx, user1ID, user2ID)
}

View File

@ -0,0 +1,132 @@
package service
import (
"context"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"time"
)
// Интерфейс сервиса комментариев
type CommentService interface {
Create(ctx context.Context, postID, authorID int, content string) (*domain.Comment, error)
GetByID(ctx context.Context, id int) (*domain.Comment, error)
GetByPostID(ctx context.Context, postID int) ([]*domain.Comment, error)
Update(ctx context.Context, id int, content string) (*domain.Comment, error)
Delete(ctx context.Context, id int) error
}
// Реализация сервиса комментариев
type commentService struct {
commentRepo repository.CommentRepository
postRepo repository.PostRepository // Добавляем для проверки существования поста
}
// Конструктор сервиса
func NewCommentService(
commentRepo repository.CommentRepository,
postRepo repository.PostRepository,
) CommentService {
return &commentService{
commentRepo: commentRepo,
postRepo: postRepo,
}
}
// Создание комментария
func (s *commentService) Create(ctx context.Context, postID, authorID int, content string) (*domain.Comment, error) {
// Проверяем существование поста
if _, err := s.postRepo.GetByID(ctx, postID); err != nil {
if errors.Is(err, repository.ErrPostNotFound) {
return nil, errors.New("post not found")
}
return nil, err
}
// Валидация контента
if content == "" {
return nil, errors.New("comment content cannot be empty")
}
if len(content) > 1000 {
return nil, errors.New("comment is too long")
}
comment := &domain.Comment{
Content: content,
PostID: postID,
AuthorID: authorID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.commentRepo.Create(ctx, comment); err != nil {
return nil, err
}
return comment, nil
}
// Получение комментария по ID
func (s *commentService) GetByID(ctx context.Context, id int) (*domain.Comment, error) {
comment, err := s.commentRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrCommentNotFound) {
return nil, errors.New("comment not found")
}
return nil, err
}
return comment, nil
}
// Получение комментариев для поста
func (s *commentService) GetByPostID(ctx context.Context, postID int) ([]*domain.Comment, error) {
// Проверяем существование поста
if _, err := s.postRepo.GetByID(ctx, postID); err != nil {
if errors.Is(err, repository.ErrPostNotFound) {
return nil, errors.New("post not found")
}
return nil, err
}
comments, err := s.commentRepo.GetByPostID(ctx, postID)
if err != nil {
return nil, err
}
// Возвращаем пустой слайс вместо nil
if comments == nil {
return []*domain.Comment{}, nil
}
return comments, nil
}
// Обновление комментария
func (s *commentService) Update(ctx context.Context, id int, content string) (*domain.Comment, error) {
// Валидация контента
if content == "" {
return nil, errors.New("comment content cannot be empty")
}
// Получаем существующий комментарий
comment, err := s.commentRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Обновляем поля
comment.Content = content
comment.UpdatedAt = time.Now()
if err := s.commentRepo.Update(ctx, comment); err != nil {
return nil, err
}
return comment, nil
}
// Удаление комментария
func (s *commentService) Delete(ctx context.Context, id int) error {
return s.commentRepo.Delete(ctx, id)
}

View File

@ -0,0 +1,129 @@
package service
import (
"context"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"time"
)
// Интерфейс сервиса лайков
type LikeService interface {
LikePost(ctx context.Context, userID, postID int) (*domain.Like, error)
UnlikePost(ctx context.Context, userID, postID int) error
GetByPostID(ctx context.Context, postID int) ([]*domain.Like, error)
GetCountForPost(ctx context.Context, postID int) (int, error)
CheckIfLiked(ctx context.Context, userID, postID int) (bool, error)
}
// Реализация сервиса лайков
type likeService struct {
likeRepo repository.LikeRepository
postRepo repository.PostRepository // Для проверки существования поста
}
// Конструктор сервиса
func NewLikeService(
likeRepo repository.LikeRepository,
postRepo repository.PostRepository,
) LikeService {
return &likeService{
likeRepo: likeRepo,
postRepo: postRepo,
}
}
// Поставить лайк посту
func (s *likeService) LikePost(ctx context.Context, userID, postID int) (*domain.Like, error) {
// Проверяем существование поста
if _, err := s.postRepo.GetByID(ctx, postID); err != nil {
if errors.Is(err, repository.ErrPostNotFound) {
return nil, errors.New("post not found")
}
return nil, err
}
// Проверяем, не поставил ли пользователь уже лайк
if liked, err := s.CheckIfLiked(ctx, userID, postID); err != nil {
return nil, err
} else if liked {
return nil, errors.New("you have already liked this post")
}
like := &domain.Like{
PostID: postID,
UserID: userID,
CreatedAt: time.Now(),
}
if err := s.likeRepo.Create(ctx, like); err != nil {
return nil, err
}
return like, nil
}
// Убрать лайк с поста
func (s *likeService) UnlikePost(ctx context.Context, userID, postID int) error {
// Проверяем существование поста
if _, err := s.postRepo.GetByID(ctx, postID); err != nil {
if errors.Is(err, repository.ErrPostNotFound) {
return errors.New("post not found")
}
return err
}
// Проверяем, ставил ли пользователь лайк
if liked, err := s.CheckIfLiked(ctx, userID, postID); err != nil {
return err
} else if !liked {
return errors.New("you haven't liked this post yet")
}
return s.likeRepo.DeleteByUserAndPost(ctx, userID, postID)
}
// Получить все лайки для поста
func (s *likeService) GetByPostID(ctx context.Context, postID int) ([]*domain.Like, error) {
// Проверяем существование поста
if _, err := s.postRepo.GetByID(ctx, postID); err != nil {
if errors.Is(err, repository.ErrPostNotFound) {
return nil, errors.New("post not found")
}
return nil, err
}
likes, err := s.likeRepo.GetByPostID(ctx, postID)
if err != nil {
return nil, err
}
// Возвращаем пустой слайс вместо nil
if likes == nil {
return []*domain.Like{}, nil
}
return likes, nil
}
// Получить количество лайков для поста
func (s *likeService) GetCountForPost(ctx context.Context, postID int) (int, error) {
likes, err := s.GetByPostID(ctx, postID)
if err != nil {
return 0, err
}
return len(likes), nil
}
// Проверить, поставил ли пользователь лайк
func (s *likeService) CheckIfLiked(ctx context.Context, userID, postID int) (bool, error) {
_, err := s.likeRepo.GetByUserAndPost(ctx, userID, postID)
if err != nil {
if errors.Is(err, repository.ErrLikeNotFound) {
return false, nil
}
return false, err
}
return true, nil
}

View File

@ -0,0 +1,120 @@
package service
import (
"context"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"time"
)
// Интерфейс сервиса постов
type PostService interface {
Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error)
GetByID(ctx context.Context, id int) (*domain.Post, error)
GetAll(ctx context.Context) ([]*domain.Post, error)
GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error)
Update(ctx context.Context, id int, title, content string) (*domain.Post, error)
Delete(ctx context.Context, id int) error
}
// Реализация сервиса постов
type postService struct {
postRepo repository.PostRepository
}
// Конструктор сервиса
func NewPostService(postRepo repository.PostRepository) PostService {
return &postService{postRepo: postRepo}
}
// Создание нового поста
func (s *postService) Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error) {
// Валидация данных
if title == "" {
return nil, errors.New("post title cannot be empty")
}
if content == "" {
return nil, errors.New("post content cannot be empty")
}
post := &domain.Post{
Title: title,
Content: content,
AuthorID: authorID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.postRepo.Create(ctx, post); err != nil {
return nil, err
}
return post, nil
}
// Получение поста по ID
func (s *postService) GetByID(ctx context.Context, id int) (*domain.Post, error) {
post, err := s.postRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrPostNotFound) {
return nil, errors.New("post not found")
}
return nil, err
}
return post, nil
}
// Получение всех постов
func (s *postService) GetAll(ctx context.Context) ([]*domain.Post, error) {
posts, err := s.postRepo.GetAll(ctx)
if err != nil {
return nil, err
}
// Возвращаем пустой слайс вместо nil
if posts == nil {
return []*domain.Post{}, nil
}
return posts, nil
}
// Получение постов автора
func (s *postService) GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error) {
posts, err := s.postRepo.GetByAuthorID(ctx, authorID)
if err != nil {
return nil, err
}
if posts == nil {
return []*domain.Post{}, nil
}
return posts, nil
}
// Обновление поста
func (s *postService) Update(ctx context.Context, id int, title, content string) (*domain.Post, error) {
// Получаем существующий пост
post, err := s.postRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Обновляем поля
post.Title = title
post.Content = content
post.UpdatedAt = time.Now()
if err := s.postRepo.Update(ctx, post); err != nil {
return nil, err
}
return post, nil
}
// Удаление поста
func (s *postService) Delete(ctx context.Context, id int) error {
return s.postRepo.Delete(ctx, id)
}

View File

@ -0,0 +1,29 @@
package service
// Services объединяет все сервисы приложения
type Services struct {
Auth AuthService
User UserService
Post PostService
Comment CommentService
Like LikeService
Chat ChatService
}
func NewServices(
authService AuthService,
userService UserService,
postService PostService,
commentService CommentService,
likeService LikeService,
chatService ChatService,
) *Services {
return &Services{
Auth: authService,
User: userService,
Post: postService,
Comment: commentService,
Like: likeService,
Chat: chatService,
}
}

View File

@ -0,0 +1,88 @@
package service
import (
"context"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"tailly_back_v2/pkg/auth"
"time"
)
type UserService interface {
GetByID(ctx context.Context, id int) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
UpdateProfile(ctx context.Context, id int, username, email string) (*domain.User, error)
ChangePassword(ctx context.Context, id int, oldPassword, newPassword string) error
}
type userService struct {
userRepo repository.UserRepository
tokenAuth *auth.TokenAuth
}
func NewUserService(userRepo repository.UserRepository, tokenAuth *auth.TokenAuth) UserService {
return &userService{
userRepo: userRepo,
tokenAuth: tokenAuth,
}
}
func (s *userService) GetByID(ctx context.Context, id int) (*domain.User, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, errors.New("user not found")
}
return nil, err
}
// Очищаем пароль перед возвратом
user.Password = ""
return user, nil
}
func (s *userService) UpdateProfile(ctx context.Context, id int, username, email string) (*domain.User, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Проверяем email на уникальность
if email != user.Email {
if _, err := s.userRepo.GetByEmail(ctx, email); err == nil {
return nil, errors.New("email already in use")
}
}
user.Username = username
user.Email = email
user.UpdatedAt = time.Now()
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err
}
// Очищаем пароль перед возвратом
user.Password = ""
return user, nil
}
func (s *userService) ChangePassword(ctx context.Context, id int, oldPassword, newPassword string) error {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return err
}
if !auth.CheckPasswordHash(oldPassword, user.Password) {
return errors.New("invalid current password")
}
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
user.Password = hashedPassword
return s.userRepo.Update(ctx, user)
}

62
internal/ws/hub.go Normal file
View File

@ -0,0 +1,62 @@
package ws
import (
"sync"
"tailly_back_v2/internal/domain"
)
type Client struct {
UserID int
Send chan *domain.Message
}
type ChatHub struct {
clients map[int]*Client
register chan *Client
unregister chan *Client
broadcast chan *domain.Message
mutex sync.RWMutex
}
func NewChatHub() *ChatHub {
return &ChatHub{
clients: make(map[int]*Client),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan *domain.Message),
}
}
func (h *ChatHub) Run() {
for {
select {
case client := <-h.register:
h.mutex.Lock()
h.clients[client.UserID] = client
h.mutex.Unlock()
case client := <-h.unregister:
h.mutex.Lock()
if _, ok := h.clients[client.UserID]; ok {
close(client.Send)
delete(h.clients, client.UserID)
}
h.mutex.Unlock()
case message := <-h.broadcast:
h.mutex.RLock()
// Отправляем отправителю и получателю
if sender, ok := h.clients[message.SenderID]; ok {
sender.Send <- message
}
if receiver, ok := h.clients[message.ReceiverID]; ok {
receiver.Send <- message
}
h.mutex.RUnlock()
}
}
}
func (h *ChatHub) Broadcast(message *domain.Message) {
h.broadcast <- message
}

133
pkg/auth/auth.go Normal file
View File

@ -0,0 +1,133 @@
package auth
import (
"errors"
"fmt"
"tailly_back_v2/internal/domain"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidToken = errors.New("invalid token")
)
type TokenAuth struct {
accessTokenSecret string
refreshTokenSecret string
accessTokenExpiry time.Duration
refreshTokenExpiry time.Duration
}
func NewTokenAuth(accessSecret, refreshSecret string, accessExpiry, refreshExpiry time.Duration) *TokenAuth {
return &TokenAuth{
accessTokenSecret: accessSecret,
refreshTokenSecret: refreshSecret,
accessTokenExpiry: accessExpiry,
refreshTokenExpiry: refreshExpiry,
}
}
// GenerateTokens создает пару access и refresh токенов
func (a *TokenAuth) GenerateTokens(userID int) (*domain.Tokens, error) {
accessToken, err := a.generateAccessToken(userID)
if err != nil {
return nil, err
}
refreshToken, err := a.generateRefreshToken(userID)
if err != nil {
return nil, err
}
return &domain.Tokens{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
// generateAccessToken создает access токен (короткоживущий)
func (a *TokenAuth) generateAccessToken(userID int) (string, error) {
claims := jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.accessTokenExpiry)),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(a.accessTokenSecret))
}
// generateRefreshToken создает refresh токен (долгоживущий)
func (a *TokenAuth) generateRefreshToken(userID int) (string, error) {
claims := jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.refreshTokenExpiry)),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(a.refreshTokenSecret))
}
// ValidateAccessToken проверяет access токен и возвращает userID
func (a *TokenAuth) ValidateAccessToken(tokenString string) (int, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, 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 []byte(a.accessTokenSecret), nil
})
if err != nil {
return 0, err
}
if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok && token.Valid {
var userID int
_, err := fmt.Sscanf(claims.Subject, "%d", &userID)
if err != nil {
return 0, ErrInvalidToken
}
return userID, nil
}
return 0, ErrInvalidToken
}
// ValidateRefreshToken проверяет refresh токен и возвращает userID
func (a *TokenAuth) ValidateRefreshToken(tokenString string) (int, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, 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 []byte(a.refreshTokenSecret), nil
})
if err != nil {
return 0, err
}
if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok && token.Valid {
var userID int
_, err := fmt.Sscanf(claims.Subject, "%d", &userID)
if err != nil {
return 0, ErrInvalidToken
}
return userID, nil
}
return 0, ErrInvalidToken
}
// HashPassword хеширует пароль
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash проверяет пароль с хешем
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

36
pkg/database/postgres.go Normal file
View File

@ -0,0 +1,36 @@
package database
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
type Postgres struct {
db *sql.DB
}
func NewPostgres(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
// Проверяем соединение
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("failed to ping db: %w", err)
}
// Настройка пула соединений
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}