diff --git a/trading_assistant_api/services/user/internal/handler/user_handler.go b/trading_assistant_api/services/user/internal/handler/user_handler.go new file mode 100644 index 0000000..597155c --- /dev/null +++ b/trading_assistant_api/services/user/internal/handler/user_handler.go @@ -0,0 +1,86 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "common/types" + "user/internal/model" + "user/internal/service" +) + +type Handler struct { + S *service.Service +} + +func New(s *service.Service) *Handler { + return &Handler{S: s} +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, false, "method not allowed", nil) + return + } + var req model.RegisterReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, false, "invalid json", nil) + return + } + userID, err := h.S.Register(req.Account, req.Password) + if err != nil { + switch err { + case service.ErrInvalidInput: + writeJSON(w, http.StatusBadRequest, false, "invalid account or password", nil) + case service.ErrAccountExists: + writeJSON(w, http.StatusConflict, false, "account exists", nil) + default: + writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) + } + return + } + writeJSON(w, http.StatusCreated, true, "ok", map[string]string{"user_id": userID}) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, false, "method not allowed", nil) + return + } + var req model.LoginReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, false, "invalid json", nil) + return + } + userID, err := h.S.Login(req.Account, req.Password) + if err != nil { + switch err { + case service.ErrInvalidInput: + writeJSON(w, http.StatusBadRequest, false, "invalid account or password", nil) + case service.ErrUnauthorized: + writeJSON(w, http.StatusUnauthorized, false, "unauthorized", nil) + default: + writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) + } + return + } + writeJSON(w, http.StatusOK, true, "ok", map[string]string{"user_id": userID}) +} + +func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, true, "ok", nil) +} + +func (h *Handler) Version(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, true, "ok", map[string]string{"version": "user-service v0.1.0"}) +} + +func (h *Handler) Root(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, true, "ok", map[string]string{"service": "user"}) +} + +func writeJSON(w http.ResponseWriter, code int, status bool, msg string, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(types.Response{Status: status, Message: msg, Data: data}) +} diff --git a/trading_assistant_api/services/user/internal/model/types.go b/trading_assistant_api/services/user/internal/model/types.go new file mode 100644 index 0000000..30960b6 --- /dev/null +++ b/trading_assistant_api/services/user/internal/model/types.go @@ -0,0 +1,11 @@ +package model + +type RegisterReq struct { + Account string `json:"account"` + Password string `json:"password"` +} + +type LoginReq struct { + Account string `json:"account"` + Password string `json:"password"` +} diff --git a/trading_assistant_api/services/user/internal/repository/user_repo.go b/trading_assistant_api/services/user/internal/repository/user_repo.go new file mode 100644 index 0000000..aa4d401 --- /dev/null +++ b/trading_assistant_api/services/user/internal/repository/user_repo.go @@ -0,0 +1,47 @@ +package repository + +import ( + "database/sql" +) + +type Repo struct { + DB *sql.DB +} + +func New(db *sql.DB) *Repo { + return &Repo{DB: db} +} + +func (r *Repo) CreateUser(tx *sql.Tx) (string, error) { + var userID string + if err := tx.QueryRow(`INSERT INTO users DEFAULT VALUES RETURNING user_id`).Scan(&userID); err != nil { + return "", err + } + return userID, nil +} + +func (r *Repo) CreateLoginAccount(tx *sql.Tx, userID, account string) error { + _, err := tx.Exec(`INSERT INTO user_login_accounts (user_id, value, deleted) VALUES ($1, $2, false)`, userID, account) + return err +} + +func (r *Repo) CreateLoginPassword(tx *sql.Tx, userID, hashed string) error { + _, err := tx.Exec(`INSERT INTO user_login_passwords (user_id, value, deleted) VALUES ($1, $2, false)`, userID, hashed) + return err +} + +func (r *Repo) GetUserIDByAccount(account string) (string, error) { + var userID string + if err := r.DB.QueryRow(`SELECT user_id FROM user_login_accounts WHERE value = $1 AND deleted = false`, account).Scan(&userID); err != nil { + return "", err + } + return userID, nil +} + +func (r *Repo) GetHashedPassword(userID string) (string, error) { + var hashed string + if err := r.DB.QueryRow(`SELECT value FROM user_login_passwords WHERE user_id = $1 AND deleted = false`, userID).Scan(&hashed); err != nil { + return "", err + } + return hashed, nil +} diff --git a/trading_assistant_api/services/user/internal/router/router.go b/trading_assistant_api/services/user/internal/router/router.go new file mode 100644 index 0000000..55d9005 --- /dev/null +++ b/trading_assistant_api/services/user/internal/router/router.go @@ -0,0 +1,17 @@ +package router + +import ( + "net/http" + + "user/internal/handler" +) + +func New(h *handler.Handler) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", h.Healthz) + mux.HandleFunc("/version", h.Version) + mux.HandleFunc("/", h.Root) + mux.HandleFunc("/register", h.Register) + mux.HandleFunc("/login", h.Login) + return mux +} diff --git a/trading_assistant_api/services/user/internal/service/user_service.go b/trading_assistant_api/services/user/internal/service/user_service.go new file mode 100644 index 0000000..1a30181 --- /dev/null +++ b/trading_assistant_api/services/user/internal/service/user_service.go @@ -0,0 +1,92 @@ +package service + +import ( + "database/sql" + "errors" + + "github.com/jackc/pgconn" + "golang.org/x/crypto/bcrypt" + "user/internal/repository" +) + +var ( + ErrInvalidInput = errors.New("invalid_input") + ErrAccountExists = errors.New("account_exists") + ErrUnauthorized = errors.New("unauthorized") +) + +type Service struct { + Repo *repository.Repo +} + +func New(repo *repository.Repo) *Service { + return &Service{Repo: repo} +} + +func (s *Service) Register(account, password string) (string, error) { + if !validAccount(account) || !validPassword(password) { + return "", ErrInvalidInput + } + hashed, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", err + } + tx, err := s.Repo.DB.Begin() + if err != nil { + return "", err + } + defer func() { _ = tx.Rollback() }() + userID, err := s.Repo.CreateUser(tx) + if err != nil { + return "", err + } + if err := s.Repo.CreateLoginAccount(tx, userID, account); err != nil { + if isUniqueViolation(err) { + return "", ErrAccountExists + } + return "", err + } + if err := s.Repo.CreateLoginPassword(tx, userID, string(hashed)); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return userID, nil +} + +func (s *Service) Login(account, password string) (string, error) { + if !validAccount(account) || !validPassword(password) { + return "", ErrInvalidInput + } + userID, err := s.Repo.GetUserIDByAccount(account) + if err != nil { + return "", ErrUnauthorized + } + hashed, err := s.Repo.GetHashedPassword(userID) + if err != nil { + return "", ErrUnauthorized + } + if bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) != nil { + return "", ErrUnauthorized + } + return userID, nil +} + +func validAccount(a string) bool { + n := len(a) + return n >= 3 && n <= 100 +} + +func validPassword(p string) bool { + n := len(p) + return n >= 8 && n <= 128 +} + +func isUniqueViolation(err error) bool { + var pe *pgconn.PgError + if errors.As(err, &pe) { + return pe.Code == "23505" + } + return false +} diff --git a/trading_assistant_api/services/user/main.go b/trading_assistant_api/services/user/main.go index 2d29d8e..5e58a2d 100644 --- a/trading_assistant_api/services/user/main.go +++ b/trading_assistant_api/services/user/main.go @@ -2,11 +2,6 @@ package main import ( "context" - "fmt" - "io" - "encoding/json" - "errors" - "strings" "net/http" "os" "os/signal" @@ -17,9 +12,10 @@ import ( "common/db" "common/logger" "common/utils" - "common/types" - "golang.org/x/crypto/bcrypt" - "github.com/jackc/pgconn" + "user/internal/handler" + "user/internal/repository" + "user/internal/router" + "user/internal/service" ) var pg *sql.DB @@ -65,133 +61,10 @@ func main() { } func routes() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, true, "ok", nil) - }) - mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, true, "ok", map[string]string{"version": "user-service v0.1.0"}) - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, true, "ok", map[string]string{"service": "user"}) - }) - mux.HandleFunc("/register", registerHandler) - mux.HandleFunc("/login", loginHandler) - return mux -} - -type registerReq struct { - Account string `json:"account"` - Password string `json:"password"` -} -type loginReq struct { - Account string `json:"account"` - Password string `json:"password"` -} -type loginResp struct { - UserID string `json:"user_id"` -} - -func registerHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, false, "method not allowed", nil) - return - } - var req registerReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, false, "invalid json", nil) - return - } - if !validAccount(req.Account) || !validPassword(req.Password) { - writeJSON(w, http.StatusBadRequest, false, "invalid account or password", nil) - return - } - hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12) - if err != nil { - writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) - return - } - tx, err := pg.Begin() - if err != nil { - writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) - return - } - defer func() { - _ = tx.Rollback() - }() - var userID string - if err := tx.QueryRow(`INSERT INTO users DEFAULT VALUES RETURNING user_id`).Scan(&userID); err != nil { - writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) - return - } - if _, err := tx.Exec(`INSERT INTO user_login_accounts (user_id, value, deleted) VALUES ($1, $2, false)`, userID, req.Account); err != nil { - if isUniqueViolation(err) { - writeJSON(w, http.StatusConflict, false, "account exists", nil) - return - } - writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) - return - } - if _, err := tx.Exec(`INSERT INTO user_login_passwords (user_id, value, deleted) VALUES ($1, $2, false)`, userID, string(hashed)); err != nil { - writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) - return - } - if err := tx.Commit(); err != nil { - writeJSON(w, http.StatusInternalServerError, false, "internal error", nil) - return - } - writeJSON(w, http.StatusCreated, true, "ok", map[string]string{"user_id": userID}) -} - -func loginHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, false, "method not allowed", nil) - return - } - var req loginReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, false, "invalid json", nil) - return - } - if !validAccount(req.Account) || !validPassword(req.Password) { - writeJSON(w, http.StatusBadRequest, false, "invalid account or password", nil) - return - } - var userID string - if err := pg.QueryRow(`SELECT user_id FROM user_login_accounts WHERE value = $1 AND deleted = false`, req.Account).Scan(&userID); err != nil { - writeJSON(w, http.StatusUnauthorized, false, "unauthorized", nil) - return - } - var hashed string - if err := pg.QueryRow(`SELECT value FROM user_login_passwords WHERE user_id = $1 AND deleted = false`, userID).Scan(&hashed); err != nil { - writeJSON(w, http.StatusUnauthorized, false, "unauthorized", nil) - return - } - if bcrypt.CompareHashAndPassword([]byte(hashed), []byte(req.Password)) != nil { - writeJSON(w, http.StatusUnauthorized, false, "unauthorized", nil) - return - } - writeJSON(w, http.StatusOK, true, "ok", map[string]string{"user_id": userID}) -} - -func validAccount(a string) bool { - n := len(a) - return n >= 3 && n <= 100 -} -func validPassword(p string) bool { - n := len(p) - return n >= 8 && n <= 128 -} - -func isUniqueViolation(err error) bool { - var pe *pgconn.PgError - if errors.As(err, &pe) { - return pe.Code == "23505" - } - if err != nil && (strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint")) { - return true - } - return false + repo := repository.New(pg) + svc := service.New(repo) + h := handler.New(svc) + return router.New(h) } func applySchema(path string) error { @@ -202,9 +75,3 @@ func applySchema(path string) error { _, err = pg.Exec(string(b)) return err } - -func writeJSON(w http.ResponseWriter, code int, status bool, msg string, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(types.Response{Status: status, Message: msg, Data: data}) -}