diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7185b25 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +SECRET_KEY=yoursecretkey +RUN_ADDRESS=http://localhost:8080 +DATABASE_URI='host=localhost user=admin password=123123 dbname=gophermat sslmode=disable' +ACCRUAL_SYSTEM_ADDRESS=http://localhost:8081 +ENV_TYPE=development \ No newline at end of file diff --git a/.gitignore b/.gitignore index 50d43ce..ddddf03 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ vendor/ # IDEs directories .idea .vscode + +.env diff --git a/cmd/gophermart/main.go b/cmd/gophermart/main.go index 38dd16d..2daf242 100644 --- a/cmd/gophermart/main.go +++ b/cmd/gophermart/main.go @@ -1,3 +1,12 @@ package main -func main() {} +import ( + "github.com/nessai1/gophermat/internal/gophermart" + "log" +) + +func main() { + if err := gophermart.Start(); err != nil { + log.Fatalf("error while listening application: %s", err.Error()) + } +} diff --git a/dev/sql_scheme.drawio b/dev/sql_scheme.drawio new file mode 100644 index 0000000..92bf67a --- /dev/null +++ b/dev/sql_scheme.drawio @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..669d1e4 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/nessai1/gophermat + +go 1.21.3 + +require ( + github.com/go-chi/chi v1.5.4 + github.com/stretchr/testify v1.8.4 + go.uber.org/zap v1.26.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-migrate/migrate/v4 v4.16.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.0 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.9.0 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/text v0.9.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..31f281e --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= +github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= +github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e8cb777 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,72 @@ +package config + +import ( + "flag" + "github.com/joho/godotenv" + "os" +) + +type EnvType string + +const ( + EnvTypeDevelopment EnvType = "development" + EnvTypeStage EnvType = "stage" + EnvTypeProduction EnvType = "production" +) + +const defaultSecretKey = "default_secret_key" + +type Config struct { + ServiceAddr string + AccrualServiceAddr string + DBConnectionStr string + SecretKey string + EnvType EnvType +} + +func GetConfig() *Config { + return fetchConfig() +} + +func fetchConfig() *Config { + + serviceAddr := flag.String("a", "", "Address of service") + databaseConnection := flag.String("d", "", "Database connection uri") + accrualServiceAddr := flag.String("r", "", "Accrual service url") + + flag.Parse() + godotenv.Load() // May not have .env + + if serviceAddrEnv := os.Getenv("RUN_ADDRESS"); serviceAddrEnv != "" { + *serviceAddr = serviceAddrEnv + } + + if databaseConnectionEnv := os.Getenv("DATABASE_URI"); databaseConnectionEnv != "" { + *databaseConnection = databaseConnectionEnv + } + + if accrualServiceAddrEnv := os.Getenv("ACCRUAL_SYSTEM_ADDRESS"); accrualServiceAddrEnv != "" { + *accrualServiceAddr = accrualServiceAddrEnv + } + + var envType EnvType + envTypeStr := os.Getenv("ENV_TYPE") + if envTypeStr == "" { + envType = EnvTypeProduction + } else { + envType = EnvType(envTypeStr) + } + + secretKey := os.Getenv("ACCRUAL_SYSTEM_ADDRESS") + if secretKey == "" { + secretKey = defaultSecretKey + } + + return &Config{ + ServiceAddr: *serviceAddr, + AccrualServiceAddr: *accrualServiceAddr, + DBConnectionStr: *databaseConnection, + SecretKey: secretKey, + EnvType: envType, + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..1e53d1d --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,51 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + + _ "github.com/jackc/pgx/v5/stdlib" + + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func InitSQLDriverByConnectionURI(connectionURI string) (*sql.DB, error) { + db, err := sql.Open("pgx", connectionURI) + if err != nil { + return nil, fmt.Errorf("cannot open sql connection: %w", err) + } + + err = db.Ping() + if err != nil { + return nil, fmt.Errorf("cannot ping database: %w", err) + } + + err = initMigrations(db) + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + return nil, fmt.Errorf("cannot init migrations for sql connection: %w", err) + } + + return db, nil +} + +func initMigrations(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return fmt.Errorf("error while create driver with instance: %w", err) + } + + migrations, err := migrate.NewWithDatabaseInstance("file:migrations", "postgres", driver) + if err != nil { + return fmt.Errorf("error while create migrations: %w", err) + } + + if err = migrations.Up(); err != nil { + return err + } + + return nil +} diff --git a/internal/gophermart/gophermart.go b/internal/gophermart/gophermart.go new file mode 100644 index 0000000..046a671 --- /dev/null +++ b/internal/gophermart/gophermart.go @@ -0,0 +1,86 @@ +package gophermart + +import ( + "fmt" + "github.com/go-chi/chi" + "github.com/nessai1/gophermat/internal/config" + "github.com/nessai1/gophermat/internal/database" + "github.com/nessai1/gophermat/internal/handler" + "github.com/nessai1/gophermat/internal/intransaction" + "github.com/nessai1/gophermat/internal/logger" + "github.com/nessai1/gophermat/internal/order" + "github.com/nessai1/gophermat/internal/user" + "github.com/nessai1/gophermat/internal/zip" + "go.uber.org/zap" + "net/http" +) + +func Start() error { + router := chi.NewRouter() + cfg := config.GetConfig() + + log, err := logger.NewLogger(cfg.EnvType) + if err != nil { + return fmt.Errorf("cannot initialize logger on start service: %w", err) + } + + db, err := database.InitSQLDriverByConnectionURI(cfg.DBConnectionStr) + if err != nil { + return fmt.Errorf("cannot initialize database on start service: %w", err) + } + + userController := user.NewController(user.CreatePGXRepository(db)) + authHandler := handler.NewAuthHandler(log, cfg.SecretKey, userController) + + router.Use(zip.GetZipMiddleware(log)) + + authMux := chi.NewMux() + authMux.Post("/api/user/register", authHandler.HandleRegisterUser) + authMux.Post("/api/user/login", authHandler.HandleAuthUser) + + transaction := intransaction.NewPGXTransaction(db) + + enrollmentController := order.NewEnrollmentController(cfg.AccrualServiceAddr, order.CreatePGXEnrollmentRepository(db), userController) + ch, err := order.StartEnrollmentWorker(userController, enrollmentController, log, cfg.AccrualServiceAddr, transaction) + if err != nil { + return fmt.Errorf("error while starting enrollment worker: %w", err) + } + + enrollmentController.EnrollmentCh = ch + + enrollmentHandler := handler.EnrollmentOrderHandler{ + Logger: log, + EnrollmentController: enrollmentController, + } + enrollmentMux := chi.NewMux() + enrollmentMux.Use(authHandler.MiddlewareAuthorizeRequest()) + enrollmentMux.Post("/", enrollmentHandler.HandleLoadOrders) + enrollmentMux.Get("/", enrollmentHandler.HandleGetOrders) + + balanceHandler := handler.BalanceHandler{ + Logger: log, + WithdrawController: order.NewWithdrawController(order.NewPGXWithdrawRepository(db), userController, transaction), + } + + balanceMux := chi.NewMux() + balanceMux.Use(authHandler.MiddlewareAuthorizeRequest()) + balanceMux.Get("/", balanceHandler.HandleGetBalance) + balanceMux.Post("/withdraw", balanceHandler.HandleAddWithdraw) + + withdrawInfoMux := chi.NewMux() + withdrawInfoMux.Use(authHandler.MiddlewareAuthorizeRequest()) + withdrawInfoMux.Get("/", balanceHandler.HandleGetListWithdraw) + + router.Mount("/", authMux) + router.Mount("/api/user/orders", enrollmentMux) + router.Mount("/api/user/balance", balanceMux) + router.Mount("/api/user/withdrawals", withdrawInfoMux) + + log.Info("starting service", zap.String("service address", cfg.ServiceAddr)) + + if err := http.ListenAndServe(cfg.ServiceAddr, router); err != nil { + return err + } + + return nil +} diff --git a/internal/handler/auth-handler.go b/internal/handler/auth-handler.go new file mode 100644 index 0000000..6780c22 --- /dev/null +++ b/internal/handler/auth-handler.go @@ -0,0 +1,232 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/nessai1/gophermat/internal/user" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v4" + "go.uber.org/zap" +) + +const authCookieName = "GOPHERMAT_JWT" +const AuthorizeUserContext AuthorizeUserContextKey = "AuthorizeUserContext" +const TokenTTL = time.Hour * 24 + +type AuthorizeUserContextKey string + +var ErrWrongSign = errors.New("got wrong sign") + +type UserCredentials struct { + Login string `json:"login"` + Password string `json:"password"` +} + +type userJWTClaims struct { + jwt.RegisteredClaims + Login string +} + +type AuthHandler struct { + Logger *zap.Logger + secretKey string + UserController *user.Controller +} + +func NewAuthHandler(logger *zap.Logger, secretKey string, userController *user.Controller) *AuthHandler { + return &AuthHandler{ + Logger: logger, + secretKey: secretKey, + UserController: userController, + } +} + +func (handler *AuthHandler) HandleAuthUser(writer http.ResponseWriter, request *http.Request) { + + var buffer bytes.Buffer + _, err := buffer.ReadFrom(request.Body) + if err != nil { + handler.Logger.Debug("client sends invalid request", zap.Error(err)) + writer.WriteHeader(http.StatusBadRequest) + return + } + + var credentials UserCredentials + err = json.Unmarshal(buffer.Bytes(), &credentials) + if err != nil { + handler.Logger.Debug("cannot unmarshal client request", zap.Error(err)) + writer.WriteHeader(http.StatusBadRequest) + return + } + + if credentials.Password == "" || credentials.Login == "" { + handler.Logger.Debug("user sends empty login/password") + writer.WriteHeader(http.StatusBadRequest) + return + } + + fetchedUser, err := handler.UserController.GetUserByCredentials(request.Context(), credentials.Login, credentials.Password) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) || errors.Is(err, user.ErrIncorrectUserPassword) { + handler.Logger.Debug("user send invalid credentials on login") + writer.WriteHeader(http.StatusUnauthorized) + return + } + + handler.Logger.Error("error while get user on login", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + sign, err := handler.createSign(fetchedUser) + if err != nil { + handler.Logger.Error("error while sign created account", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + c := &http.Cookie{Name: authCookieName, Value: sign} + http.SetCookie(writer, c) + + handler.Logger.Debug("user successful authorized by request", zap.String("user login", fetchedUser.Login)) + + writer.WriteHeader(http.StatusOK) +} + +func (handler *AuthHandler) HandleRegisterUser(writer http.ResponseWriter, request *http.Request) { + + var buffer bytes.Buffer + _, err := buffer.ReadFrom(request.Body) + if err != nil { + handler.Logger.Debug("client sends invalid request", zap.Error(err)) + writer.WriteHeader(http.StatusBadRequest) + return + } + + var credentials UserCredentials + err = json.Unmarshal(buffer.Bytes(), &credentials) + if err != nil { + handler.Logger.Debug("cannot unmarshal client request", zap.Error(err)) + writer.WriteHeader(http.StatusBadRequest) + return + } + + if credentials.Password == "" || credentials.Login == "" { + handler.Logger.Debug("user sends empty login/password") + writer.WriteHeader(http.StatusBadRequest) + return + } + + createdUser, err := handler.UserController.AddUser(request.Context(), credentials.Login, credentials.Password) + if err != nil { + if errors.Is(err, user.ErrLoginAlreadyExists) { + handler.Logger.Debug("user try register existing account", zap.String("login", credentials.Login)) + writer.WriteHeader(http.StatusConflict) + return + } + + handler.Logger.Error("error while create new user by register", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + sign, err := handler.createSign(createdUser) + if err != nil { + handler.Logger.Error("error while sign created account", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + c := &http.Cookie{Name: authCookieName, Value: sign} + http.SetCookie(writer, c) + + handler.Logger.Debug("user successful registered by request", zap.String("user login", createdUser.Login)) + + writer.WriteHeader(http.StatusOK) +} + +func (handler *AuthHandler) MiddlewareAuthorizeRequest() func(handler http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + cookie, err := request.Cookie(authCookieName) + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + handler.Logger.Debug("user has no auth cookie", zap.String("client address", request.RemoteAddr)) + writer.WriteHeader(http.StatusUnauthorized) + return + } + + handler.Logger.Error("undefined error occurred in auth middleware", zap.String("client address", request.RemoteAddr), zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := request.Context() + authUser, err := handler.fetchUser(ctx, cookie.Value) + if err != nil { + if errors.Is(err, ErrWrongSign) { + handler.Logger.Debug("user sends invalid sign cookie", zap.Error(err)) + c := &http.Cookie{ + Value: "", + Name: authCookieName, + MaxAge: -1, + } + http.SetCookie(writer, c) + writer.WriteHeader(http.StatusUnauthorized) + return + } + + handler.Logger.Error("error while getting user for auth middleware", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + handler.Logger.Debug("user successful authorized", zap.Int("user id", authUser.ID)) + + request = request.WithContext(context.WithValue(ctx, AuthorizeUserContext, authUser)) + + next.ServeHTTP(writer, request) + }) + } +} + +func (handler *AuthHandler) fetchUser(ctx context.Context, sign string) (*user.User, error) { + claims := &userJWTClaims{} + _, err := jwt.ParseWithClaims(sign, claims, func(token *jwt.Token) (interface{}, error) { + return []byte(handler.secretKey), nil + }) + + if err != nil { + return nil, errors.Join(ErrWrongSign, err) + } + + fetchedUser, err := handler.UserController.GetUserByLogin(ctx, claims.Login) + if err != nil && errors.Is(err, user.ErrUserNotFound) { + return nil, errors.Join(ErrWrongSign, err) + } else if err != nil { + return nil, fmt.Errorf("error while fetch user in jwt: %w", err) + } + + return fetchedUser, nil +} + +func (handler *AuthHandler) createSign(signedUser *user.User) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, userJWTClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenTTL)), + }, + Login: signedUser.Login, + }) + + tokenString, err := token.SignedString([]byte(handler.secretKey)) + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/internal/handler/balance-handler.go b/internal/handler/balance-handler.go new file mode 100644 index 0000000..a51b690 --- /dev/null +++ b/internal/handler/balance-handler.go @@ -0,0 +1,188 @@ +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "github.com/nessai1/gophermat/internal/order" + "github.com/nessai1/gophermat/internal/user" + "go.uber.org/zap" + "net/http" + "time" +) + +type BalanceHandler struct { + Logger *zap.Logger + WithdrawController *order.WithdrawController +} + +type AddWithdrawRequest struct { + Order string `json:"order"` + Sum json.Number `json:"sum"` +} + +type GetWithdrawResponse struct { + Current float32 `json:"current"` + Withdrawn float32 `json:"withdrawn"` +} + +type GetWithdrawItemResponse struct { + Order string `json:"order"` + Sum float32 `json:"sum"` + ProcessedAt time.Time `json:"processed_at"` +} + +func (handler *BalanceHandler) HandleGetBalance(writer http.ResponseWriter, request *http.Request) { + ctxUserVal := request.Context().Value(AuthorizeUserContext) + if ctxUserVal == nil { + handler.Logger.Error("load orders handler must have user in context, but not found") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + ctxUser, ok := ctxUserVal.(*user.User) + if !ok { + handler.Logger.Error("cannot cast user in request context while load order") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + withdrawSum, err := handler.WithdrawController.GetWithdrawSumByUser(request.Context(), ctxUser) + if err != nil { + handler.Logger.Error("error while get withdraw sum for user", zap.Int("user id", ctxUser.ID), zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + res := GetWithdrawResponse{ + Current: float32(ctxUser.Balance) / 100, + Withdrawn: float32(withdrawSum) / 100, + } + + body, err := json.Marshal(res) + if err != nil { + handler.Logger.Error("cannot marshal info of user balance", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + writer.Header().Set("Content-Type", "application/json") + _, err = writer.Write(body) + if err != nil { + handler.Logger.Error("error while write ifo of user balance to body") + writer.WriteHeader(http.StatusInternalServerError) + } +} + +func (handler *BalanceHandler) HandleAddWithdraw(writer http.ResponseWriter, request *http.Request) { + ctxUserVal := request.Context().Value(AuthorizeUserContext) + if ctxUserVal == nil { + handler.Logger.Error("load orders handler must have user in context, but not found") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + ctxUser, ok := ctxUserVal.(*user.User) + if !ok { + handler.Logger.Error("cannot cast user in request context while load order") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + bf := bytes.Buffer{} + _, err := bf.ReadFrom(request.Body) + if err != nil { + handler.Logger.Debug("user sends invalid body to withdraw", zap.Error(err)) + writer.WriteHeader(http.StatusBadRequest) + return + } + + var requestWithdraw AddWithdrawRequest + err = json.Unmarshal(bf.Bytes(), &requestWithdraw) + if err != nil || requestWithdraw.Sum == "" || requestWithdraw.Order == "" { + handler.Logger.Debug("user sends invalid withdraw request object", zap.Error(err)) + writer.WriteHeader(http.StatusBadRequest) + return + } + + sum, err := user.ParseBalance(string(requestWithdraw.Sum)) + if err != nil { + handler.Logger.Debug("user sends invalid sum of withdraw", zap.Error(err)) + writer.WriteHeader(http.StatusUnprocessableEntity) + return + } + + withdraw, err := handler.WithdrawController.CreateWithdrawByUser(request.Context(), ctxUser, requestWithdraw.Order, sum) + if err != nil { + if errors.Is(err, order.ErrInvalidOrderNumber) { + writer.WriteHeader(http.StatusUnprocessableEntity) + handler.Logger.Debug("user send invalid format of order number", zap.String("order number", requestWithdraw.Order)) + } else if errors.Is(err, order.ErrNoMoney) { + writer.WriteHeader(http.StatusPaymentRequired) + handler.Logger.Debug("user has no money to withdraw", zap.Int64("user balance", ctxUser.Balance), zap.Int64("required sum", sum)) + } else if errors.Is(err, order.ErrEmptyBalance) { + writer.WriteHeader(http.StatusBadRequest) + handler.Logger.Debug("user sends empty sum to withdraw") + } else { + writer.WriteHeader(http.StatusInternalServerError) + handler.Logger.Error("server error while create withdraw", zap.Error(err)) + } + + return + } + + handler.Logger.Debug("user successful register withdraw", zap.String("order ID", withdraw.OrderID), zap.Int64("sum", withdraw.Sum)) + writer.WriteHeader(http.StatusOK) +} + +func (handler *BalanceHandler) HandleGetListWithdraw(writer http.ResponseWriter, request *http.Request) { + ctxUserVal := request.Context().Value(AuthorizeUserContext) + if ctxUserVal == nil { + handler.Logger.Error("load orders handler must have user in context, but not found") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + ctxUser, ok := ctxUserVal.(*user.User) + if !ok { + handler.Logger.Error("cannot cast user in request context while load order") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + withdrawList, err := handler.WithdrawController.GetWithdrawByUser(request.Context(), ctxUser) + if err != nil { + handler.Logger.Error("error while get list of withdraw", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + if len(withdrawList) == 0 { + handler.Logger.Debug("user get list of withdraw but he have no one withdraw", zap.Int("user id", ctxUser.ID)) + writer.WriteHeader(http.StatusNoContent) + return + } + + responseBody := make([]GetWithdrawItemResponse, len(withdrawList)) + for i := 0; i < len(withdrawList); i++ { + responseBody[i] = GetWithdrawItemResponse{ + Order: withdrawList[i].OrderID, + Sum: float32(withdrawList[i].Sum) / 100, + ProcessedAt: withdrawList[i].ProcessedAt, + } + } + + rs, err := json.Marshal(responseBody) + if err != nil { + handler.Logger.Error("error while marshal list of withdraw", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + writer.Header().Set("Content-Type", "application/json") + _, err = writer.Write(rs) + if err != nil { + handler.Logger.Error("error while write list of withdraw", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + } +} diff --git a/internal/handler/enrollment-order-handler.go b/internal/handler/enrollment-order-handler.go new file mode 100644 index 0000000..8ee5f87 --- /dev/null +++ b/internal/handler/enrollment-order-handler.go @@ -0,0 +1,139 @@ +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "github.com/nessai1/gophermat/internal/order" + "github.com/nessai1/gophermat/internal/user" + "net/http" + "time" + + "go.uber.org/zap" +) + +type EnrollmentOrderHandler struct { + Logger *zap.Logger + EnrollmentController *order.EnrollmentController +} + +type EnrollmentItem struct { + OrderID string `json:"number"` + Status string `json:"status"` + Accrual float32 `json:"accrual,omitempty"` + UploadedAt time.Time `json:"uploaded_at"` +} + +func (handler *EnrollmentOrderHandler) HandleLoadOrders(writer http.ResponseWriter, request *http.Request) { + ctxUserVal := request.Context().Value(AuthorizeUserContext) + if ctxUserVal == nil { + handler.Logger.Error("load orders handler must have user in context, but not found") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + ctxUser, ok := ctxUserVal.(*user.User) + if !ok { + handler.Logger.Error("cannot cast user in request context while load order") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + var buffer bytes.Buffer + _, err := buffer.ReadFrom(request.Body) + if err != nil { + handler.Logger.Debug("user sends invalid request") + writer.WriteHeader(http.StatusBadRequest) + return + } + + orderNumber := buffer.String() + enrollmentOrder, err := handler.EnrollmentController.RequireOrder(request.Context(), orderNumber, ctxUser.ID) + if err != nil && errors.Is(err, order.ErrInvalidOrderNumber) { + handler.Logger.Debug("user register order number with invalid format", zap.String("order number", orderNumber)) + writer.WriteHeader(http.StatusUnprocessableEntity) + return + } + + if enrollmentOrder.UserID != ctxUser.ID { + handler.Logger.Debug( + "user register someone else's order", + zap.String("order number", orderNumber), + zap.Int("request user id", ctxUser.ID), + zap.Int("order owner user id", ctxUser.ID), + ) + + writer.WriteHeader(http.StatusConflict) + return + } + + if enrollmentOrder.Status == order.EnrollmentStatusNew { + handler.Logger.Debug( + "user successful load new order", + zap.String("order number", orderNumber), + zap.Int("user id", ctxUser.ID), + ) + writer.WriteHeader(http.StatusAccepted) + return + } + + handler.Logger.Debug( + "user try to load already exists own order", + zap.String("order number", orderNumber), + zap.Int("user id", ctxUser.ID), + ) + + writer.WriteHeader(http.StatusOK) +} + +func (handler *EnrollmentOrderHandler) HandleGetOrders(writer http.ResponseWriter, request *http.Request) { + ctxUserVal := request.Context().Value(AuthorizeUserContext) + if ctxUserVal == nil { + handler.Logger.Error("load orders handler must have user in context, but not found") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + ctxUser, ok := ctxUserVal.(*user.User) + if !ok { + handler.Logger.Error("cannot cast user in request context while load order") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + enrollmentList, err := handler.EnrollmentController.GetUserOrderListByID(request.Context(), ctxUser.ID) + if err != nil { + handler.Logger.Error("error while get enrollment list for user", zap.Int("user id", ctxUser.ID), zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + } + + if len(enrollmentList) == 0 { + writer.WriteHeader(http.StatusNoContent) + } + + resultEnrollmentList := make([]EnrollmentItem, len(enrollmentList)) + for i := 0; i < len(enrollmentList); i++ { + item := EnrollmentItem{ + OrderID: enrollmentList[i].OrderID, + Status: enrollmentList[i].Status, + Accrual: float32(enrollmentList[i].Accrual) / 100, + UploadedAt: enrollmentList[i].UploadedAt, + } + + resultEnrollmentList[i] = item + } + + rs, err := json.Marshal(resultEnrollmentList) + if err != nil { + handler.Logger.Error("error while marshal list of user enrollments", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return + } + + writer.Header().Set("Content-Type", "application/json") + _, err = writer.Write(rs) + if err != nil { + handler.Logger.Error("error while write enrollment list to result body", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + } +} diff --git a/internal/intransaction/intransaction.go b/internal/intransaction/intransaction.go new file mode 100644 index 0000000..25cfb5a --- /dev/null +++ b/internal/intransaction/intransaction.go @@ -0,0 +1,44 @@ +package intransaction + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +type Transaction interface { + InTransaction(context.Context, func(innerCtx context.Context) error) error +} + +type PGXTransaction struct { + db *sql.DB +} + +func NewPGXTransaction(db *sql.DB) Transaction { + return &PGXTransaction{db: db} +} + +func (transaction *PGXTransaction) InTransaction(ctx context.Context, consistent func(innerCtx context.Context) error) error { + tx, err := transaction.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("error while start transaction: %w", err) + } + + err = consistent(ctx) + if err != nil { + rollErr := tx.Rollback() + if rollErr != nil { + return fmt.Errorf("error while rollback unsuccessful transaction: %w", errors.Join(err, rollErr)) + } + + return fmt.Errorf("rollback unsuccessful transaction: %w", err) + } + + commitErr := tx.Commit() + if commitErr != nil { + return fmt.Errorf("error while commit successful transaction: %w", commitErr) + } + + return nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..69992d0 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,13 @@ +package logger + +import ( + "github.com/nessai1/gophermat/internal/config" + + "go.uber.org/zap" +) + +func NewLogger(envType config.EnvType) (*zap.Logger, error) { + logger, err := createZapLogger(envType) + + return logger, err +} diff --git a/internal/logger/zap-logger.go b/internal/logger/zap-logger.go new file mode 100644 index 0000000..7ff638c --- /dev/null +++ b/internal/logger/zap-logger.go @@ -0,0 +1,43 @@ +package logger + +import ( + "fmt" + "github.com/nessai1/gophermat/internal/config" + "os" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func createZapLogger(envType config.EnvType) (*zap.Logger, error) { + atom := zap.NewAtomicLevel() + + logLevel, err := getZapLogLevelByEnvLevel(envType) + if err != nil { + return nil, err + } + + atom.SetLevel(logLevel) + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.RFC3339TimeEncoder + + logger := zap.New(zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + atom, + )) + + return logger, nil +} + +func getZapLogLevelByEnvLevel(envType config.EnvType) (zapcore.Level, error) { + if envType == config.EnvTypeProduction { + return zapcore.ErrorLevel, nil + } else if envType == config.EnvTypeStage { + return zapcore.InfoLevel, nil + } else if envType == config.EnvTypeDevelopment { + return zapcore.DebugLevel, nil + } + + return 0, fmt.Errorf("unexpected EnvType got (%s)", envType) +} diff --git a/internal/order/enrollment-worker.go b/internal/order/enrollment-worker.go new file mode 100644 index 0000000..8ef591d --- /dev/null +++ b/internal/order/enrollment-worker.go @@ -0,0 +1,181 @@ +package order + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/nessai1/gophermat/internal/intransaction" + "github.com/nessai1/gophermat/internal/user" + "go.uber.org/zap" + "net/http" + "strconv" + "time" +) + +type EnrollmentWorker struct { + ch chan Enrollment + client http.Client + enrollmentController *EnrollmentController + userController *user.Controller + logger *zap.Logger + serviceAddr string + transaction intransaction.Transaction +} + +func (worker *EnrollmentWorker) Listen() { + for enrollment := range worker.ch { + worker.requireOrder(&enrollment) + } +} + +func (worker *EnrollmentWorker) requireOrder(enrollment *Enrollment) { + worker.logger.Debug("start required order", zap.String("order id", enrollment.OrderID), zap.String("order init status", enrollment.Status)) + err := worker.enrollmentController.ChangeStatusByOrderID(context.TODO(), enrollment.OrderID, EnrollmentStatusProcessing) + if err != nil { + worker.logger.Error("error while try to change order status on processing in worker", zap.Error(err), zap.String("order id", enrollment.OrderID)) + go func(e Enrollment) { + time.Sleep(time.Second * 5) // задержка что-бы не заспамить в случае какой-то поломки СУБД + worker.ch <- e // кладем в конец очереди на следующую попытку + }(*enrollment) + } + + for { + ctx := context.TODO() + resp, err := worker.client.Get(worker.serviceAddr + "/api/orders/" + enrollment.OrderID) + if err != nil { + break + } + + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := resp.Header.Get("Retry-After") + retryAfterInt, _ := strconv.Atoi(retryAfter) + worker.logger.Info("got many request to accrual service", zap.Int("retry after", retryAfterInt)) + resp.Body.Close() + time.Sleep(time.Second * time.Duration(retryAfterInt)) + continue + } + + if resp.StatusCode != http.StatusOK { + worker.logger.Error("got unsuccessful status from accrual service", zap.String("status", resp.Status)) + resp.Body.Close() + time.Sleep(time.Second * 5) + continue + } + + var buffer bytes.Buffer + _, err = buffer.ReadFrom(resp.Body) + resp.Body.Close() + if err != nil { + worker.logger.Error("error while read response from accrual service", zap.String("order id", enrollment.OrderID)) + time.Sleep(time.Second * 5) + continue + } + + var accrualInfo OrderAccrualInfo + err = json.Unmarshal(buffer.Bytes(), &accrualInfo) + if err != nil { + worker.logger.Error("error while unmarshal response from accrual service", zap.Error(err)) + } + + if accrualInfo.Status == orderAccrualStatusInvalid { + err = worker.enrollmentController.ChangeStatusByOrderID(ctx, enrollment.OrderID, EnrollmentStatusInvalid) + if err != nil { + worker.logger.Error("error while change status in accrual worker", zap.Error(err), zap.String("status", EnrollmentStatusInvalid), zap.String("order id", enrollment.OrderID)) + time.Sleep(time.Second * 5) + continue + } + + worker.logger.Info("accrual complete", zap.String("status", EnrollmentStatusInvalid), zap.String("order id", enrollment.OrderID)) + return + } + + if accrualInfo.Status == orderAccrualStatusProcessing || accrualInfo.Status == orderAccrualStatusRegistered { + worker.logger.Info("order is still awaiting accrual", zap.String("current status", accrualInfo.Status), zap.String("order id", enrollment.OrderID)) + time.Sleep(time.Second * 5) + continue + } + + err = worker.transaction.InTransaction(ctx, func(innerCtx context.Context) error { + + txErr := worker.enrollmentController.ChangeStatusByOrderID(innerCtx, accrualInfo.Order, EnrollmentStatusProcessed) + if txErr != nil { + return fmt.Errorf("error while change order status: %w", txErr) + } + + df, txErr := user.ParseBalance(string(accrualInfo.Accrual)) + if txErr != nil { + return fmt.Errorf("error while parse balance: %w", txErr) + } + + txErr = worker.enrollmentController.UpdateOrderAccrualByID(innerCtx, enrollment.OrderID, int(df)) + if txErr != nil { + return fmt.Errorf("error while get user from controller: %w", txErr) + } + + owner, txErr := worker.userController.GetUserByID(innerCtx, enrollment.UserID) + if err != nil { + return fmt.Errorf("error while get user: %w", txErr) + } + + balance := owner.Balance + df + txErr = worker.userController.SetUserBalanceByID(context.TODO(), owner.ID, balance) + if txErr != nil { + return fmt.Errorf("error while update user balance: %w", txErr) + } + + worker.logger.Error("successful accruled order", zap.String("order id", enrollment.OrderID), zap.Int("sum", int(df))) + + return nil + }) + + if err != nil { + worker.logger.Error("error while make user-accrual update transaction", zap.Error(err)) + time.Sleep(time.Second * 5) + continue + } + + return + } +} + +func StartEnrollmentWorker(userController *user.Controller, enrollmentController *EnrollmentController, logger *zap.Logger, serviceAddr string, transaction intransaction.Transaction) (chan<- Enrollment, error) { + ch := make(chan Enrollment, 10) + + enrollmentList, err := enrollmentController.GetProcessedEnrollments(context.TODO()) + if err != nil { + return nil, fmt.Errorf("error while get new enrollments: %w", err) + } + + if len(enrollmentList) > 0 { + logger.Info("service has unresolved orders, start preload goroutine", zap.Int("enrollments len", len(enrollmentList))) + go preloadOrders(enrollmentList, ch) + } + + go func(ch chan Enrollment) { + client := http.Client{ + Timeout: time.Second * 10, + } + + worker := EnrollmentWorker{ + + client: client, + ch: ch, + enrollmentController: enrollmentController, + userController: userController, + serviceAddr: serviceAddr, + logger: logger, + transaction: transaction, + } + + worker.Listen() + }(ch) + + return ch, nil +} + +func preloadOrders(enrollments []*Enrollment, ch chan<- Enrollment) { + for _, enrollment := range enrollments { + ch <- *enrollment + } +} diff --git a/internal/order/enrollment.go b/internal/order/enrollment.go new file mode 100644 index 0000000..8de90b2 --- /dev/null +++ b/internal/order/enrollment.go @@ -0,0 +1,111 @@ +package order + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/nessai1/gophermat/internal/user" + "time" +) + +var ErrEnrollmentNotFound = errors.New("enrollment not found") +var ErrEnrollmentAlreadyExists = errors.New("enrollment already exists") + +const ( + EnrollmentStatusNew = "NEW" // Заказ загружен в систему, но не попал в обработку + EnrollmentStatusProcessing = "PROCESSING" // Вознаграждение за заказ рассчитывается + EnrollmentStatusInvalid = "INVALID" // Система расчёта вознаграждений отказала в расчёте + EnrollmentStatusProcessed = "PROCESSED" // Данные по заказу проверены и информация о расчёте успешно получена +) + +// Статусы внешнего сервиса +const ( + orderAccrualStatusRegistered = "REGISTERED" + orderAccrualStatusInvalid = "INVALID" + orderAccrualStatusProcessing = "PROCESSING" + orderAccrualStatusProcessed = "PROCESSED" +) + +type OrderAccrualInfo struct { + Order string `json:"order"` + Status string `json:"status"` + Accrual json.Number `json:"accrual"` +} + +type EnrollmentController struct { + orderServiceAddr string + repository EnrollmentRepository + userController *user.Controller + EnrollmentCh chan<- Enrollment +} + +type Enrollment struct { + UserID int + OrderID string + Status string + Accrual int64 + UploadedAt time.Time +} + +type EnrollmentRepository interface { + GetByID(ctx context.Context, orderID string) (*Enrollment, error) + CreateNewOrder(ctx context.Context, orderID string, ownerID int) (*Enrollment, error) + ChangeStatus(ctx context.Context, orderID, status string) error + UpdateOrderAccrual(ctx context.Context, orderID string, accrual int) error + GetListByUserID(ctx context.Context, userID int) ([]*Enrollment, error) + GetProcessedEnrollments(ctx context.Context) ([]*Enrollment, error) +} + +func NewEnrollmentController(orderServiceAddr string, repository EnrollmentRepository, userController *user.Controller) *EnrollmentController { + return &EnrollmentController{orderServiceAddr: orderServiceAddr, repository: repository, userController: userController} +} + +func (controller *EnrollmentController) RequireOrder(ctx context.Context, orderNumber string, userID int) (*Enrollment, error) { + if !IsOrderNumberCorrect(orderNumber) { + return nil, ErrInvalidOrderNumber + } + + enrollment, err := controller.repository.GetByID(ctx, orderNumber) + if err != nil && errors.Is(err, ErrEnrollmentNotFound) { + enrollment, err = controller.repository.CreateNewOrder(ctx, orderNumber, userID) + if err != nil { + return nil, fmt.Errorf("error while create new enrollment order: %w", err) + } + } else if err != nil { + return nil, fmt.Errorf("error while require enrollment order: %w", err) + } + + if enrollment.UserID == userID && enrollment.Status == EnrollmentStatusNew { + go controller.loadOrder(enrollment) + if err != nil { + return nil, fmt.Errorf("error while start order loading operation: %w", err) + } + } + + return enrollment, nil +} + +func (controller *EnrollmentController) ChangeStatusByOrderID(ctx context.Context, orderID, status string) error { + return controller.repository.ChangeStatus(ctx, orderID, status) +} + +func (controller *EnrollmentController) GetProcessedEnrollments(ctx context.Context) ([]*Enrollment, error) { + enrollmentList, err := controller.repository.GetProcessedEnrollments(ctx) + + return enrollmentList, err +} + +func (controller *EnrollmentController) loadOrder(enrollment *Enrollment) { + controller.EnrollmentCh <- *enrollment +} + +func (controller *EnrollmentController) GetUserOrderListByID(ctx context.Context, userID int) ([]*Enrollment, error) { + enrollmentList, err := controller.repository.GetListByUserID(ctx, userID) + + return enrollmentList, err +} + +func (controller *EnrollmentController) UpdateOrderAccrualByID(ctx context.Context, orderID string, accrual int) error { + return controller.repository.UpdateOrderAccrual(ctx, orderID, accrual) +} diff --git a/internal/order/order.go b/internal/order/order.go new file mode 100644 index 0000000..7b95e3b --- /dev/null +++ b/internal/order/order.go @@ -0,0 +1,35 @@ +package order + +import ( + "errors" + "strconv" +) + +var ErrInvalidOrderNumber = errors.New("invalid order number") +var ErrEmptyBalance = errors.New("empty balance") + +func IsOrderNumberCorrect(orderNumber string) bool { + sum := 0 + numSize := len(orderNumber) + for i := 0; i < numSize; i++ { + num, err := strconv.Atoi(string(orderNumber[numSize-i-1])) + if err != nil { + return false + } + + if i%2 == 1 { + num *= 2 + + if num >= 10 { + buf := strconv.Itoa(num) + v1, _ := strconv.Atoi(string(buf[0])) + v2, _ := strconv.Atoi(string(buf[1])) + num = v1 + v2 + } + } + + sum += num + } + + return sum%10 == 0 +} diff --git a/internal/order/order_test.go b/internal/order/order_test.go new file mode 100644 index 0000000..5c2f019 --- /dev/null +++ b/internal/order/order_test.go @@ -0,0 +1,71 @@ +package order + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestIsOrderNumberCorrect(t *testing.T) { + tests := []struct { + name string + val string + isValid bool + }{ + { + name: "Random string", + val: "iLoveGo", + isValid: false, + }, + { + name: "String with number prefix", + val: "1337Abakada", + isValid: false, + }, + { + name: "Random string #2", + val: "qwertyuiopasdfgh", + isValid: false, + }, + { + name: "Invalid number", + val: "1233444455556666", + isValid: false, + }, + { + name: "Valid number #1", + val: "12345678903", + isValid: true, + }, + { + name: "Valid number #2", + val: "9278923470", + isValid: true, + }, + { + name: "Valid number #3", + val: "346436439", + isValid: true, + }, + { + name: "Valid number #4", + val: "2377225624", + isValid: true, + }, + { + name: "Invalid number #2", + val: "2377225626", + isValid: false, + }, + { + name: "Valid number #3", + val: "9278923473", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.isValid, IsOrderNumberCorrect(tt.val)) + }) + } +} diff --git a/internal/order/pgx-enrollment-repository.go b/internal/order/pgx-enrollment-repository.go new file mode 100644 index 0000000..e02bba4 --- /dev/null +++ b/internal/order/pgx-enrollment-repository.go @@ -0,0 +1,123 @@ +package order + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/jackc/pgx/v5/pgconn" + "github.com/nessai1/gophermat/internal/postgrescodes" +) + +type PGXEnrollmentRepository struct { + db *sql.DB +} + +func CreatePGXEnrollmentRepository(db *sql.DB) *PGXEnrollmentRepository { + return &PGXEnrollmentRepository{db: db} +} + +func (repository *PGXEnrollmentRepository) GetByID(ctx context.Context, orderID string) (*Enrollment, error) { + row := repository.db.QueryRowContext(ctx, "SELECT user_id, order_id, status, accrual FROM enrollment_order WHERE order_id = $1", orderID) + + if row.Err() != nil { + return nil, fmt.Errorf("error while query for get user by login: %w", row.Err()) + } + + var enrollment Enrollment + + err := row.Scan(&enrollment.UserID, &enrollment.OrderID, &enrollment.Status, &enrollment.Accrual) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, ErrEnrollmentNotFound + } else if err != nil { + return nil, fmt.Errorf("error while scan row for get enrollment by login: %w", err) + } + + return &enrollment, nil +} + +func (repository *PGXEnrollmentRepository) CreateNewOrder(ctx context.Context, orderID string, ownerID int) (*Enrollment, error) { + _, err := repository.db.ExecContext(ctx, "INSERT INTO enrollment_order (order_id, user_id, status) VALUES ($1, $2, $3)", orderID, ownerID, EnrollmentStatusNew) + + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == postgrescodes.PostgresErrCodeUniqueViolation { + return nil, ErrEnrollmentAlreadyExists + } + } + + return nil, fmt.Errorf("error while creating user: %w", err) + } + + enrollment := Enrollment{ + UserID: ownerID, + OrderID: orderID, + Status: EnrollmentStatusNew, + Accrual: 0, + } + + return &enrollment, nil +} + +func (repository *PGXEnrollmentRepository) ChangeStatus(ctx context.Context, orderID, status string) error { + _, err := repository.db.ExecContext(ctx, "UPDATE enrollment_order SET status = $1 WHERE order_id = $2", status, orderID) + return err +} + +func (repository *PGXEnrollmentRepository) UpdateOrderAccrual(ctx context.Context, orderID string, accrual int) error { + _, err := repository.db.ExecContext(ctx, "UPDATE enrollment_order SET accrual = $1 WHERE order_id = $2", accrual, orderID) + return err +} + +func (repository *PGXEnrollmentRepository) GetListByUserID(ctx context.Context, userID int) ([]*Enrollment, error) { + rows, err := repository.db.QueryContext(ctx, "SELECT order_id, status, accrual, uploaded_at FROM enrollment_order WHERE user_id = $1 ORDER BY uploaded_at ASC", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + enrollmentList := make([]*Enrollment, 0) + + for rows.Next() { + if err = rows.Err(); err != nil { + return nil, err + } + + enrollment := Enrollment{UserID: userID} + err = rows.Scan(&enrollment.OrderID, &enrollment.Status, &enrollment.Accrual, &enrollment.UploadedAt) + if err != nil { + return nil, err + } + + enrollmentList = append(enrollmentList, &enrollment) + } + + return enrollmentList, nil +} + +func (repository *PGXEnrollmentRepository) GetProcessedEnrollments(ctx context.Context) ([]*Enrollment, error) { + rows, err := repository.db.QueryContext(ctx, "SELECT order_id, user_id, status, accrual, uploaded_at FROM enrollment_order WHERE status IN ($1, $2) ORDER BY uploaded_at ASC", EnrollmentStatusNew, EnrollmentStatusProcessing) + if err != nil { + return nil, err + } + defer rows.Close() + + enrollmentList := make([]*Enrollment, 0) + + for rows.Next() { + if err = rows.Err(); err != nil { + return nil, err + } + + enrollment := Enrollment{} + err = rows.Scan(&enrollment.OrderID, &enrollment.UserID, &enrollment.Status, &enrollment.Accrual, &enrollment.UploadedAt) + if err != nil { + return nil, err + } + + enrollmentList = append(enrollmentList, &enrollment) + } + + return enrollmentList, nil +} diff --git a/internal/order/pgx-withdraw-repository.go b/internal/order/pgx-withdraw-repository.go new file mode 100644 index 0000000..f826b8b --- /dev/null +++ b/internal/order/pgx-withdraw-repository.go @@ -0,0 +1,74 @@ +package order + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +type PGXWithdrawRepository struct { + db *sql.DB +} + +func NewPGXWithdrawRepository(db *sql.DB) *PGXWithdrawRepository { + return &PGXWithdrawRepository{db: db} +} + +func (repository *PGXWithdrawRepository) AddWithdraw(ctx context.Context, userID int, orderID string, sum int64) (*Withdraw, error) { + now := time.Now() + _, err := repository.db.ExecContext(ctx, "INSERT INTO withdraw_order (order_id, user_id, sum, processed_at) VALUES ($1, $2, $3, $4)", orderID, userID, sum, now) + + if err != nil { + return nil, fmt.Errorf("error while creating user withdraw: %w", err) + } + + withdraw := Withdraw{ + OrderID: orderID, + Sum: sum, + ProcessedAt: now, + } + + return &withdraw, nil +} + +func (repository *PGXWithdrawRepository) GetWithdrawListByUserID(ctx context.Context, userID int) ([]*Withdraw, error) { + rows, err := repository.db.QueryContext(ctx, "SELECT sum, order_id, processed_at FROM withdraw_order WHERE user_id = $1 ORDER BY processed_at ASC", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + withdrawList := make([]*Withdraw, 0) + + for rows.Next() { + if err = rows.Err(); err != nil { + return nil, err + } + + withdraw := Withdraw{} + err = rows.Scan(&withdraw.Sum, &withdraw.OrderID, &withdraw.ProcessedAt) + if err != nil { + return nil, err + } + + withdrawList = append(withdrawList, &withdraw) + } + + return withdrawList, nil +} + +func (repository *PGXWithdrawRepository) GetWithdrawSumByUserID(ctx context.Context, userID int) (int64, error) { + row := repository.db.QueryRowContext(ctx, "SELECT COALESCE(SUM(sum),0) FROM withdraw_order WHERE user_id = $1", userID) + + if row.Err() != nil { + return 0, fmt.Errorf("error while query for get user withdraw sum by id: %w", row.Err()) + } + + var sum int64 + if err := row.Scan(&sum); err != nil { + return 0, fmt.Errorf("error while scan sum after query of user withdraw sum: %w", err) + } + + return sum, nil +} diff --git a/internal/order/withdraw.go b/internal/order/withdraw.go new file mode 100644 index 0000000..5e154bb --- /dev/null +++ b/internal/order/withdraw.go @@ -0,0 +1,86 @@ +package order + +import ( + "context" + "errors" + "fmt" + "github.com/nessai1/gophermat/internal/intransaction" + "github.com/nessai1/gophermat/internal/user" + "time" +) + +var ErrNoMoney = errors.New("user has no money to transit") + +type Withdraw struct { + Sum int64 + OrderID string + ProcessedAt time.Time +} + +type WithdrawRepository interface { + AddWithdraw(ctx context.Context, userID int, orderID string, sum int64) (*Withdraw, error) + GetWithdrawListByUserID(ctx context.Context, userID int) ([]*Withdraw, error) + GetWithdrawSumByUserID(ctx context.Context, userID int) (int64, error) +} + +type WithdrawController struct { + repository WithdrawRepository + userController *user.Controller + transaction intransaction.Transaction +} + +func NewWithdrawController(repository WithdrawRepository, userController *user.Controller, transaction intransaction.Transaction) *WithdrawController { + return &WithdrawController{ + repository: repository, + userController: userController, + transaction: transaction, + } +} + +func (controller *WithdrawController) CreateWithdrawByUser(ctx context.Context, innerUser *user.User, orderID string, sum int64) (*Withdraw, error) { + + if !IsOrderNumberCorrect(orderID) { + return nil, ErrInvalidOrderNumber + } + + if sum == 0 { + return nil, ErrEmptyBalance + } + + if sum > innerUser.Balance { + return nil, ErrNoMoney + } + + var withdraw *Withdraw + err := controller.transaction.InTransaction(ctx, func(innerCtx context.Context) error { + balance := innerUser.Balance - sum + txErr := controller.userController.SetUserBalanceByID(ctx, innerUser.ID, balance) + if txErr != nil { + return fmt.Errorf("error while set user balance: %w", txErr) + } + + withdraw, txErr = controller.repository.AddWithdraw(ctx, innerUser.ID, orderID, sum) + + if txErr != nil { + return fmt.Errorf("error while add withdraw: %w", txErr) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error while create withdraw by user: %w", err) + } + + return withdraw, nil +} + +func (controller *WithdrawController) GetWithdrawByUser(ctx context.Context, innerUser *user.User) ([]*Withdraw, error) { + list, err := controller.repository.GetWithdrawListByUserID(ctx, innerUser.ID) + return list, err +} + +func (controller *WithdrawController) GetWithdrawSumByUser(ctx context.Context, innerUser *user.User) (int64, error) { + sum, err := controller.repository.GetWithdrawSumByUserID(ctx, innerUser.ID) + return sum, err +} diff --git a/internal/postgrescodes/postgrescodes.go b/internal/postgrescodes/postgrescodes.go new file mode 100644 index 0000000..aa9a878 --- /dev/null +++ b/internal/postgrescodes/postgrescodes.go @@ -0,0 +1,3 @@ +package postgrescodes + +const PostgresErrCodeUniqueViolation = "23505" diff --git a/internal/user/map-repository.go b/internal/user/map-repository.go new file mode 100644 index 0000000..5e8d952 --- /dev/null +++ b/internal/user/map-repository.go @@ -0,0 +1,48 @@ +package user + +import "context" + +type MapRepository struct { + data map[string]User +} + +func (repository *MapRepository) GetUserByLogin(_ context.Context, login string) (*User, error) { + user, isFind := repository.data[login] + + if !isFind { + return nil, ErrUserNotFound + } + + return &user, nil +} + +func (repository *MapRepository) CreateUser(_ context.Context, user *User) error { + _, isFound := repository.data[user.Login] + if isFound { + return ErrLoginAlreadyExists + } + + repository.data[user.Login] = *user + return nil +} + +func (repository *MapRepository) SetUserBalanceByID(_ context.Context, userID int, balance int64) error { + for i, user := range repository.data { + if user.ID == userID { + user.Balance = balance + repository.data[i] = user + } + } + + return ErrUserNotFound +} + +func (repository *MapRepository) GetUserByID(ctx context.Context, userID int) (*User, error) { + for _, user := range repository.data { + if user.ID == userID { + return &user, nil + } + } + + return nil, ErrUserNotFound +} diff --git a/internal/user/pgx-repository.go b/internal/user/pgx-repository.go new file mode 100644 index 0000000..136bb8a --- /dev/null +++ b/internal/user/pgx-repository.go @@ -0,0 +1,79 @@ +package user + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/nessai1/gophermat/internal/postgrescodes" + + "github.com/jackc/pgx/v5/pgconn" +) + +type PGXRepository struct { + db *sql.DB +} + +func CreatePGXRepository(db *sql.DB) *PGXRepository { + return &PGXRepository{db: db} +} + +func (repository *PGXRepository) GetUserByLogin(ctx context.Context, login string) (*User, error) { + row := repository.db.QueryRowContext(ctx, "SELECT id, login, password, balance FROM \"user\" WHERE login = $1", login) + + if row.Err() != nil { + return nil, fmt.Errorf("error while query for get user by login: %w", row.Err()) + } + + var user User + + err := row.Scan(&user.ID, &user.Login, &user.password, &user.Balance) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } else if err != nil { + return nil, fmt.Errorf("error while scan row for get user by login: %w", err) + } + + return &user, nil +} + +func (repository *PGXRepository) CreateUser(ctx context.Context, user *User) error { + _, err := repository.db.ExecContext(ctx, "INSERT INTO \"user\" (login, password, balance) VALUES ($1, $2, $3)", user.Login, user.password, user.Balance) + + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == postgrescodes.PostgresErrCodeUniqueViolation { + return ErrLoginAlreadyExists + } + } + + return fmt.Errorf("error while creating user: %w", err) + } + + return nil +} + +func (repository *PGXRepository) GetUserByID(ctx context.Context, id int) (*User, error) { + row := repository.db.QueryRowContext(ctx, "SELECT id, login, password, balance FROM \"user\" WHERE id = $1", id) + + if row.Err() != nil { + return nil, fmt.Errorf("error while query for get user by id: %w", row.Err()) + } + + var user User + + err := row.Scan(&user.ID, &user.Login, &user.password, &user.Balance) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } else if err != nil { + return nil, fmt.Errorf("error while scan row for get user by id: %w", err) + } + + return &user, nil +} + +func (repository *PGXRepository) SetUserBalanceByID(ctx context.Context, userID int, balance int64) error { + _, err := repository.db.ExecContext(ctx, "UPDATE \"user\" SET balance = $1 WHERE id = $2", balance, userID) + return err +} diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..f918660 --- /dev/null +++ b/internal/user/user.go @@ -0,0 +1,136 @@ +package user + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" +) + +var ErrUserNotFound = errors.New("user not found") +var ErrIncorrectUserPassword = errors.New("user password is wrong") +var ErrLoginAlreadyExists = errors.New("input user login already exists") + +type User struct { + ID int + Login string + Balance int64 + + password string +} + +func ParseBalance(balance string) (int64, error) { + if balance == "" { + return 0, nil + } + + parts := strings.Split(balance, ".") + + if len(parts) > 2 { + return 0, fmt.Errorf("parts of balance must be less than 3, got %d", len(parts)) + } + + bigPart, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, err + } + + val := int64(bigPart * 100) + + if len(parts) == 2 { + smallPart, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, err + } + + if smallPart >= 100 { + return 0, fmt.Errorf("small part must be less than 100") + } + + if smallPart < 10 { + val += int64(smallPart * 10) + } else { + val += int64(smallPart) + } + } + + return val, nil +} + +type Repository interface { + GetUserByLogin(context.Context, string) (*User, error) + CreateUser(context.Context, *User) error + GetUserByID(context.Context, int) (*User, error) + SetUserBalanceByID(context.Context, int, int64) error +} + +type Controller struct { + repository Repository +} + +func NewController(repository Repository) *Controller { + return &Controller{repository: repository} +} + +func (controller *Controller) GetUserByCredentials(ctx context.Context, login, password string) (*User, error) { + user, err := controller.repository.GetUserByLogin(ctx, login) + + if err != nil && errors.Is(err, ErrUserNotFound) { + return nil, err + } else if err != nil { + return nil, fmt.Errorf("unhandled error while getting user by credetials: %w", err) + } + + if user.password != buildPasswordHash(password) { + return nil, ErrIncorrectUserPassword + } + + return user, nil +} + +func (controller *Controller) GetUserByLogin(ctx context.Context, login string) (*User, error) { + user, err := controller.repository.GetUserByLogin(ctx, login) + return user, err +} + +func (controller *Controller) GetUserByID(ctx context.Context, id int) (*User, error) { + user, err := controller.repository.GetUserByID(ctx, id) + return user, err +} + +func (controller *Controller) AddUser(ctx context.Context, login, password string) (*User, error) { + passwordHash := buildPasswordHash(password) + + user := User{ + Login: login, + Balance: 0, + + password: passwordHash, + } + + err := controller.repository.CreateUser(ctx, &user) + if err != nil && !errors.Is(err, ErrLoginAlreadyExists) { + return nil, err + } else if err != nil { + return nil, fmt.Errorf("repository error while add user in controller: %w", err) + } + + return &user, nil +} + +func (controller *Controller) SetUserBalanceByID(ctx context.Context, userID int, balance int64) error { + return controller.repository.SetUserBalanceByID(ctx, userID, balance) +} + +// +//func (controller *Controller) AddBalanceByID(ctx context.Context, userID int, balance float32) error { +// return controller.repository.AddBalanceByID(ctx, userID, balance) +//} + +func buildPasswordHash(password string) string { + shaSum := sha256.Sum256([]byte(password)) + + return fmt.Sprintf("%x", shaSum) +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go new file mode 100644 index 0000000..926df77 --- /dev/null +++ b/internal/user/user_test.go @@ -0,0 +1,169 @@ +package user + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestController_GetUserByCredentials(t *testing.T) { + userOnePassword := "superSecret" + userOneLogin := "userOne" + userOneBalance := int64(300) + users := map[string]User{ + userOneLogin: { + Login: userOneLogin, + Balance: userOneBalance, + password: buildPasswordHash(userOnePassword), + }, + } + + controller := NewController(&MapRepository{data: users}) + ctx := context.TODO() + + user, err := controller.GetUserByCredentials(ctx, userOneLogin, userOnePassword) + if assert.NoErrorf(t, err, "user with login %s must be found", userOneLogin) { + assert.Equalf(t, userOneLogin, user.Login, "user login not equeal (%s != %s)", userOneLogin, user.Login) + assert.Equalf(t, userOneBalance, user.Balance, "user balance not equeal (%f != %f)", userOneBalance, user.Balance) + } + + user, err = controller.GetUserByCredentials(ctx, userOneLogin, "superSecrets") + assert.ErrorIs(t, err, ErrIncorrectUserPassword, "method must be returned incorrect password error") + assert.Nil(t, user, "user pointer must be nil on incorrect password find") + + user, err = controller.GetUserByCredentials(ctx, "userTwo", userOnePassword) + assert.ErrorIs(t, err, ErrUserNotFound, "method must be returned user not found error") + assert.Nil(t, user, "user pointer must be nil on incorrect login find") +} + +func TestController_AddUser(t *testing.T) { + repository := MapRepository{data: map[string]User{}} + controller := NewController(&repository) + + userLogin := "userOne" + userPassword := "passwordOne" + + ctx := context.TODO() + + _, err := controller.GetUserByCredentials(ctx, userLogin, userPassword) + require.ErrorIs(t, err, ErrUserNotFound) + + user, err := controller.AddUser(ctx, userLogin, userPassword) + require.NoError(t, err) + require.Equalf(t, len(repository.data), 1, "repository len must be 1, got %d", len(repository.data)) + + expectedUser := User{ + Login: userLogin, + Balance: 0, + password: buildPasswordHash(userPassword), + } + + assert.Equal(t, expectedUser, *user, "created and expected users are not equal") + + secondUser, err := controller.GetUserByCredentials(ctx, userLogin, userPassword) + require.NoError(t, err) + assert.Equal(t, *secondUser, *user) + + _, err = controller.AddUser(ctx, userLogin, userPassword) + require.ErrorIs(t, err, ErrLoginAlreadyExists) +} + +func Test_buildPasswordHash(t *testing.T) { + superSecret := "superSecret" + superSecretHash := "056b7fe47141b6e48e87caf8f8e5bb92120ac12c6e6944cf7dbcda2db23581cc" + + superSecrets := "superSecrets" + + h := buildPasswordHash(superSecret) + assert.Truef(t, h == superSecretHash, "hash must be equal (%s == %s)", h, superSecretHash) + + h = buildPasswordHash(superSecrets) + assert.Falsef(t, h == superSecretHash, "hash must be not equal (%s != %s)", h, superSecretHash) + + h = buildPasswordHash(superSecret) // second check for invariance + assert.Truef(t, h == superSecretHash, "hash must be equal (%s == %s)", h, superSecretHash) +} + +func TestParseBalance(t *testing.T) { + tests := []struct { + name string + hasError bool + in string + out int64 + }{ + { + name: "Correct val 1", + hasError: false, + in: "42.10", + out: 4210, + }, + { + name: "Correct val 2", + hasError: false, + in: "42.7", + out: 4270, + }, + { + name: "Correct val 3", + hasError: false, + in: "42", + out: 4200, + }, + { + name: "Incorrect val 1", + hasError: true, + in: "foo", + out: 0, + }, + { + name: "Incorrect val 2", + hasError: true, + in: "foo.bar", + out: 0, + }, + { + name: "Incorrect val 3", + hasError: true, + in: "54.ad", + out: 0, + }, + { + name: "Incorrect val 4", + hasError: true, + in: "56.100", + out: 0, + }, + { + name: "Incorrect val 5", + hasError: true, + in: "fa.42", + out: 0, + }, + { + name: "Incorrect val 6", + hasError: true, + in: "23a.4bd", + out: 0, + }, + { + name: "Incorrect val 7", + hasError: true, + in: "65.32.22", + out: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := ParseBalance(tt.in) + assert.Equal(t, tt.out, val) + if tt.hasError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/zip/zip.go b/internal/zip/zip.go new file mode 100644 index 0000000..15a77bd --- /dev/null +++ b/internal/zip/zip.go @@ -0,0 +1,56 @@ +package zip + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + + "go.uber.org/zap" +) + +type gzipWriter struct { + http.ResponseWriter + Writer io.Writer +} + +func (writer gzipWriter) Write(bytes []byte) (int, error) { + return writer.Writer.Write(bytes) +} + +func GetZipMiddleware(logger *zap.Logger) func(handler http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if !strings.Contains(request.Header.Get("Accept-Encoding"), "gzip") { + logger.Debug("Client doesn't accept gzip format.") + next.ServeHTTP(writer, request) + return + } + + gz, err := gzip.NewWriterLevel(writer, gzip.BestSpeed) + if err != nil { + logger.Fatal("Gzip encoding level doesn't work!", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte("Internal error while encode content to gzip: " + err.Error())) + return + } + + defer gz.Close() + + writer = gzipWriter{ + ResponseWriter: writer, + Writer: gz, + } + writer.Header().Set("Content-Encoding", "gzip") + + if strings.Contains(request.Header.Get("Content-Encoding"), "gzip") { + request.Body, err = gzip.NewReader(request.Body) + if err != nil { + logger.Fatal("Internal error while encode body content to gzip", zap.Error(err)) + } + } + + next.ServeHTTP(writer, request) + }) + } +} diff --git a/migrations/001_create_user_table.down.sql b/migrations/001_create_user_table.down.sql new file mode 100644 index 0000000..96b591d --- /dev/null +++ b/migrations/001_create_user_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "user"; \ No newline at end of file diff --git a/migrations/001_create_user_table.up.sql b/migrations/001_create_user_table.up.sql new file mode 100644 index 0000000..894b31e --- /dev/null +++ b/migrations/001_create_user_table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "user" ( + id serial primary key, + login varchar(255) not null unique, + password varchar(255) not null, + balance bigint not null default 0 +) \ No newline at end of file diff --git a/migrations/002_create_enrollment_order_table.down.sql b/migrations/002_create_enrollment_order_table.down.sql new file mode 100644 index 0000000..ae28b0f --- /dev/null +++ b/migrations/002_create_enrollment_order_table.down.sql @@ -0,0 +1,4 @@ +BEGIN; +DROP INDEX IF EXISTS enrollment_order_user_id_idx; +DROP TABLE IF EXISTS enrollment_order; +COMMIT; \ No newline at end of file diff --git a/migrations/002_create_enrollment_order_table.up.sql b/migrations/002_create_enrollment_order_table.up.sql new file mode 100644 index 0000000..4bc2dc7 --- /dev/null +++ b/migrations/002_create_enrollment_order_table.up.sql @@ -0,0 +1,10 @@ +BEGIN; +CREATE TABLE enrollment_order ( + order_id varchar(100) not null PRIMARY KEY, + user_id int not null references "user" (id), + status varchar(30) not null, + accrual bigint not null default 0, + uploaded_at timestamp not null default now() +); +CREATE INDEX enrollment_order_user_id_idx ON enrollment_order (user_id); +COMMIT; \ No newline at end of file diff --git a/migrations/003_create_withdraw_order_table.down.sql b/migrations/003_create_withdraw_order_table.down.sql new file mode 100644 index 0000000..36437fa --- /dev/null +++ b/migrations/003_create_withdraw_order_table.down.sql @@ -0,0 +1,4 @@ +BEGIN; +DROP INDEX IF EXISTS withdraw_order_user_id_idx; +DROP TABLE IF EXISTS withdraw_order; +COMMIT; \ No newline at end of file diff --git a/migrations/003_create_withdraw_order_table.up.sql b/migrations/003_create_withdraw_order_table.up.sql new file mode 100644 index 0000000..169418b --- /dev/null +++ b/migrations/003_create_withdraw_order_table.up.sql @@ -0,0 +1,10 @@ +BEGIN; +CREATE TABLE IF NOT EXISTS withdraw_order ( + id serial PRIMARY KEY, + order_id varchar(100) not null, + user_id int not null references "user" (id), + processed_at timestamp not null default now(), + sum bigint not null +); +CREATE INDEX withdraw_order_user_id_idx ON withdraw_order (user_id); +COMMIT; \ No newline at end of file