[hw-8] add: comment service

This commit is contained in:
3ybacTuK
2025-07-26 23:47:18 +03:00
parent 6420eaf3d7
commit 6e0d90a6d5
29 changed files with 1249 additions and 725 deletions

View File

@@ -0,0 +1,190 @@
package app
import (
"context"
"fmt"
"net"
"net/http"
"os"
"route256/comments/infra/config"
"route256/comments/infra/db/postgres"
"route256/comments/internal/app/server"
"route256/comments/internal/domain/service"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/reflection"
mw "route256/comments/infra/grpc/middleware"
repository "route256/comments/infra/repository/sqlc"
pb "route256/pkg/api/comments/v1"
)
type App struct {
config *config.Config
controller *server.Server
grpcServer *grpc.Server
httpServer *http.Server
gwConn *grpc.ClientConn
}
func NewApp(configPath string) (*App, error) {
c, err := config.LoadConfig(configPath)
if err != nil {
return nil, fmt.Errorf("unable to load config: %w", err)
}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if c.Service.LogLevel != "" {
level, logErr := zerolog.ParseLevel(c.Service.LogLevel)
if logErr != nil {
return nil, fmt.Errorf("unknown log level `%s` provided: %w", c.Service.LogLevel, logErr)
}
zerolog.SetGlobalLevel(level)
}
log.WithLevel(zerolog.GlobalLevel()).Msgf("using logging level=`%s`", zerolog.GlobalLevel().String())
shards, err := getPostgresPools(c)
if err != nil {
return nil, err
}
repo := repository.NewCommentsRepository(shards[0], shards[1])
service := service.NewCommentsService(repo, c.App.EditInterval)
controller := server.NewServer(service)
app := &App{
config: c,
controller: controller,
}
return app, nil
}
func (app *App) Shutdown(ctx context.Context) (err error) {
if app.httpServer != nil {
err = app.httpServer.Shutdown(ctx)
if err != nil {
log.Error().Err(err).Msgf("failed http gateway server shutdown")
}
}
done := make(chan struct{})
if app.grpcServer != nil {
go func() {
app.grpcServer.GracefulStop()
close(done)
}()
}
select {
case <-done:
case <-ctx.Done():
if app.grpcServer != nil {
app.grpcServer.Stop()
}
}
if app.gwConn != nil {
err2 := app.gwConn.Close()
if err2 != nil {
err = err2
log.Error().Err(err).Msgf("failed gateway connection close")
}
}
return err
}
func (app *App) ListenAndServe(ctx context.Context) error {
grpcAddr := fmt.Sprintf("%s:%s", app.config.Service.Host, app.config.Service.GRPCPort)
l, err := net.Listen("tcp", grpcAddr)
if err != nil {
return err
}
app.grpcServer = grpc.NewServer(
grpc.ChainUnaryInterceptor(
mw.Logging,
mw.Validate,
),
)
reflection.Register(app.grpcServer)
pb.RegisterCommentsServer(app.grpcServer, app.controller)
go func() {
if err = app.grpcServer.Serve(l); err != nil {
log.Fatal().Err(err).Msg("failed to serve")
}
}()
log.Info().Msgf("Serving grpc loms at grpc://%s", l.Addr())
// Setup HTTP gateway
conn, err := grpc.NewClient(
grpcAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return fmt.Errorf("grpc.NewClient: %w", err)
}
app.gwConn = conn
gwmux := runtime.NewServeMux()
if err = pb.RegisterCommentsHandler(ctx, gwmux, conn); err != nil {
return fmt.Errorf("pb.RegisterLOMSHandler: %w", err)
}
root := http.NewServeMux()
root.Handle("/metrics", promhttp.Handler())
root.Handle("/", gwmux)
app.httpServer = &http.Server{
Addr: fmt.Sprintf("%s:%s", app.config.Service.Host, app.config.Service.HTTPPort),
Handler: root,
ReadTimeout: 10 * time.Second,
}
log.Info().Msgf("Serving http loms at http://%s", app.httpServer.Addr)
return app.httpServer.ListenAndServe()
}
func getPostgresPools(c *config.Config) ([]*pgxpool.Pool, error) {
conns := make([]string, len(c.DbShards))
for i, shard := range c.DbShards {
conns[i] = fmt.Sprintf(
"postgresql://%s:%s@%s:%s/%s?sslmode=disable",
shard.User,
shard.Password,
shard.Host,
shard.Port,
shard.DBName,
)
}
pools, err := postgres.NewPools(context.Background(), conns...)
if err != nil {
return nil, err
}
return pools, nil
}

View File

@@ -0,0 +1,133 @@
package server
import (
"context"
"errors"
"fmt"
"route256/comments/internal/domain/entity"
"route256/comments/internal/domain/model"
pb "route256/pkg/api/comments/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
var _ pb.CommentsServer = (*Server)(nil)
type CommentsService interface {
CommentGetByID(ctx context.Context, id int64) (*entity.Comment, error)
CommentCreate(ctx context.Context, comment *entity.Comment) (int64, error)
CommentListBySKU(ctx context.Context, sku int64) ([]*entity.Comment, error)
CommentListByUser(ctx context.Context, userID int64) ([]*entity.Comment, error)
CommentEdit(ctx context.Context, comment *entity.Comment) error
}
type Server struct {
pb.UnimplementedCommentsServer
service CommentsService
}
func NewServer(commentsService CommentsService) *Server {
return &Server{
service: commentsService,
}
}
func (s *Server) CommentAdd(ctx context.Context, req *pb.CreateCommentRequest) (*pb.CreateCommentResponse, error) {
id, err := s.service.CommentCreate(ctx, &entity.Comment{
UserID: req.UserId,
SKU: req.Sku,
Text: req.Comment,
})
if err != nil {
return nil, fmt.Errorf("service.InsertComment: %w", err)
}
return &pb.CreateCommentResponse{
Id: id,
}, nil
}
func (s *Server) CommentEdit(ctx context.Context, req *pb.EditCommentRequest) (*emptypb.Empty, error) {
err := s.service.CommentEdit(ctx, &entity.Comment{
ID: req.CommentId,
UserID: req.UserId,
Text: req.NewComment,
})
switch {
case errors.Is(err, model.ErrCommentEditUserMismatch):
return &emptypb.Empty{}, status.Error(codes.PermissionDenied, err.Error())
case errors.Is(err, model.ErrCommentEditTimeout):
return &emptypb.Empty{}, status.Error(codes.FailedPrecondition, err.Error())
case err != nil:
return &emptypb.Empty{}, status.Error(codes.Internal, err.Error())
}
return &emptypb.Empty{}, nil
}
func (s *Server) CommentGetByID(ctx context.Context, req *pb.GetCommentRequest) (*pb.GetCommentResponse, error) {
comm, err := s.service.CommentGetByID(ctx, req.Id)
if err != nil {
return nil, fmt.Errorf("service.GetCommentByID: %w", err)
}
return &pb.GetCommentResponse{
Comment: &pb.Comment{
Id: comm.ID,
UserId: comm.UserID,
Sku: comm.SKU,
Text: comm.Text,
CreatedAt: timestamppb.New(comm.CreatedAt),
},
}, nil
}
func (s *Server) CommentListBySKU(ctx context.Context, req *pb.ListBySkuRequest) (*pb.ListBySkuResponse, error) {
comms, err := s.service.CommentListBySKU(ctx, req.Sku)
if err != nil {
return nil, fmt.Errorf("service.ListCommentsBySku: %w", err)
}
comments := make([]*pb.Comment, len(comms))
for i, comm := range comms {
comments[i] = &pb.Comment{
Id: comm.ID,
UserId: comm.UserID,
Sku: comm.SKU,
Text: comm.Text,
CreatedAt: timestamppb.New(comm.CreatedAt),
}
}
return &pb.ListBySkuResponse{
Comments: comments,
}, nil
}
func (s *Server) CommentListByUser(ctx context.Context, req *pb.ListByUserRequest) (*pb.ListByUserResponse, error) {
comms, err := s.service.CommentListByUser(ctx, req.UserId)
if err != nil {
return nil, fmt.Errorf("service.ListCommentsByUser: %w", err)
}
comments := make([]*pb.Comment, len(comms))
for i, comm := range comms {
comments[i] = &pb.Comment{
Id: comm.ID,
UserId: comm.UserID,
Sku: comm.SKU,
Text: comm.Text,
CreatedAt: timestamppb.New(comm.CreatedAt),
}
}
return &pb.ListByUserResponse{
Comments: comments,
}, nil
}

View File

@@ -1,9 +1,11 @@
package entity
import "time"
type Comment struct {
ID int64
UserID int64
SKU int64
CreatedAt string
CreatedAt time.Time
Text string
}

View File

@@ -1,22 +0,0 @@
package model
import (
"fmt"
"time"
)
type Comment struct {
ID int64 `validate:"gt=0"`
UserID int64 `validate:"gt=0"`
SKU int64 `validate:"gt=0"`
CreatedAt time.Time
Text string `validate:"lte=255,gt=0"`
}
func (c *Comment) Validate() error {
if err := validate.Struct(c); err != nil {
return fmt.Errorf("invalid requested values: %w", err)
}
return nil
}

View File

@@ -3,3 +3,5 @@ package model
import "errors"
var ErrCommentNotFound = errors.New("comment not found")
var ErrCommentEditUserMismatch = errors.New("comment edit user mismatch")
var ErrCommentEditTimeout = errors.New("comment edit timeout")

View File

@@ -1,9 +0,0 @@
package model
import "github.com/go-playground/validator/v10"
var validate *validator.Validate
func init() {
validate = validator.New()
}

View File

@@ -1,3 +1,134 @@
package service
// TODO
import (
"context"
"fmt"
"sort"
"time"
"route256/comments/internal/domain/entity"
"route256/comments/internal/domain/model"
)
type CommentRepository interface {
GetCommentByID(ctx context.Context, id int64) (*entity.Comment, error)
InsertComment(ctx context.Context, comment *entity.Comment) (*entity.Comment, error)
ListCommentsBySku(ctx context.Context, sku int64) ([]*entity.Comment, error)
ListCommentsByUser(ctx context.Context, userID int64) ([]*entity.Comment, error)
UpdateComment(ctx context.Context, comment *entity.Comment) (*entity.Comment, error)
}
type CommentsService struct {
comments CommentRepository
timeout time.Duration
}
func NewCommentsService(commRepo CommentRepository, timeout time.Duration) *CommentsService {
return &CommentsService{
comments: commRepo,
timeout: timeout,
}
}
func (s *CommentsService) checkTimeout(newTime, oldTime time.Time) error {
if newTime.Sub(oldTime) >= s.timeout {
return model.ErrCommentEditTimeout
}
return nil
}
func (s *CommentsService) CommentCreate(ctx context.Context, comment *entity.Comment) (int64, error) {
comm, err := s.comments.InsertComment(ctx, &entity.Comment{
UserID: comment.UserID,
SKU: comment.SKU,
CreatedAt: time.Now(),
Text: comment.Text,
})
if err != nil {
return 0, fmt.Errorf("repository.InsertComment: %w", err)
}
return comm.ID, nil
}
func (s *CommentsService) CommentGetByID(ctx context.Context, id int64) (*entity.Comment, error) {
if id <= 0 {
return nil, fmt.Errorf("comment id must be greater than 0")
}
comm, err := s.comments.GetCommentByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("repository.InsertComment: %w", err)
}
return comm, nil
}
func (s *CommentsService) CommentEdit(ctx context.Context, newComment *entity.Comment) error {
if newComment.ID <= 0 {
return fmt.Errorf("comment id must be greater than 0")
}
oldComment, err := s.comments.GetCommentByID(ctx, newComment.ID)
if err != nil {
return fmt.Errorf("repository.GetCommentByID: %w", err)
}
if oldComment.UserID != newComment.UserID {
return model.ErrCommentEditUserMismatch
}
comment := &entity.Comment{
ID: newComment.ID,
UserID: newComment.UserID,
SKU: newComment.SKU,
CreatedAt: time.Now(),
Text: newComment.Text,
}
if err := s.checkTimeout(comment.CreatedAt, oldComment.CreatedAt); err != nil {
return err
}
if _, err := s.comments.UpdateComment(ctx, comment); err != nil {
return fmt.Errorf("repository.UpdateComment: %w", err)
}
return nil
}
func (s *CommentsService) CommentListBySKU(ctx context.Context, sku int64) ([]*entity.Comment, error) {
if sku <= 0 {
return nil, fmt.Errorf("sku must be greater than 0")
}
comms, err := s.comments.ListCommentsBySku(ctx, sku)
if err != nil {
return nil, fmt.Errorf("repository.ListCommentsBySku: %w", err)
}
return comms, nil
}
func (s *CommentsService) CommentListByUser(ctx context.Context, userID int64) ([]*entity.Comment, error) {
if userID <= 0 {
return nil, fmt.Errorf("userID must be greater than 0")
}
comms, err := s.comments.ListCommentsByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("repository.ListCommentsByUser: %w", err)
}
sort.Slice(comms, func(i, j int) bool {
if comms[i].CreatedAt.Equal(comms[j].CreatedAt) {
return comms[i].UserID < comms[j].UserID
}
return comms[i].CreatedAt.After(comms[j].CreatedAt)
})
return comms, nil
}