diff --git a/internal/announcement/handler.go b/internal/announcement/handler.go index caa7d5b..4047b61 100644 --- a/internal/announcement/handler.go +++ b/internal/announcement/handler.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" + "a301_server/pkg/apperror" + "github.com/gofiber/fiber/v2" ) @@ -37,7 +39,7 @@ func (h *Handler) GetAll(c *fiber.Ctx) error { } list, err := h.svc.GetAll(offset, limit) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항을 불러오지 못했습니다"}) + return apperror.Internal("공지사항을 불러오지 못했습니다") } return c.JSON(list) } @@ -62,17 +64,17 @@ func (h *Handler) Create(c *fiber.Ctx) error { Content string `json:"content"` } if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Content == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목과 내용을 입력해주세요"}) + return apperror.BadRequest("제목과 내용을 입력해주세요") } if len(body.Title) > 256 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"}) + return apperror.BadRequest("제목은 256자 이하여야 합니다") } if len(body.Content) > 10000 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "내용은 10000자 이하여야 합니다"}) + return apperror.BadRequest("내용은 10000자 이하여야 합니다") } a, err := h.svc.Create(body.Title, body.Content) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항 생성에 실패했습니다"}) + return apperror.Internal("공지사항 생성에 실패했습니다") } return c.Status(fiber.StatusCreated).JSON(a) } @@ -96,31 +98,31 @@ func (h *Handler) Create(c *fiber.Ctx) error { func (h *Handler) Update(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"}) + return apperror.BadRequest("유효하지 않은 공지사항 ID입니다") } var body struct { Title string `json:"title"` Content string `json:"content"` } if err := c.BodyParser(&body); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if body.Title == "" && body.Content == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"}) + return apperror.BadRequest("수정할 내용을 입력해주세요") } if len(body.Title) > 256 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"}) + return apperror.BadRequest("제목은 256자 이하여야 합니다") } if len(body.Content) > 10000 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "내용은 10000자 이하여야 합니다"}) + return apperror.BadRequest("내용은 10000자 이하여야 합니다") } a, err := h.svc.Update(uint(id), body.Title, body.Content) if err != nil { if strings.Contains(err.Error(), "찾을 수 없습니다") { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + return apperror.NotFound(err.Error()) } log.Printf("공지사항 수정 실패 (id=%d): %v", id, err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"}) + return apperror.ErrInternal } return c.JSON(a) } @@ -141,13 +143,13 @@ func (h *Handler) Update(c *fiber.Ctx) error { func (h *Handler) Delete(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"}) + return apperror.BadRequest("유효하지 않은 공지사항 ID입니다") } if err := h.svc.Delete(uint(id)); err != nil { if strings.Contains(err.Error(), "찾을 수 없습니다") { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + return apperror.NotFound(err.Error()) } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "삭제에 실패했습니다"}) + return apperror.Internal("삭제에 실패했습니다") } return c.SendStatus(fiber.StatusNoContent) } diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 1f874f3..92a327d 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" + "a301_server/pkg/apperror" + "github.com/gofiber/fiber/v2" ) @@ -38,26 +40,26 @@ func (h *Handler) Register(c *fiber.Ctx) error { Password string `json:"password"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } req.Username = strings.ToLower(strings.TrimSpace(req.Username)) if req.Username == "" || req.Password == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) + return apperror.BadRequest("아이디와 비밀번호를 입력해주세요") } if !usernameRe.MatchString(req.Username) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다"}) + return apperror.BadRequest("아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다") } if len(req.Password) < 6 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 6자 이상이어야 합니다"}) + return apperror.BadRequest("비밀번호는 6자 이상이어야 합니다") } if len(req.Password) > 72 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 72자 이하여야 합니다"}) + return apperror.BadRequest("비밀번호는 72자 이하여야 합니다") } if err := h.svc.Register(req.Username, req.Password); err != nil { if strings.Contains(err.Error(), "이미 사용 중") { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + return apperror.Conflict(err.Error()) } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "회원가입에 실패했습니다"}) + return apperror.Internal("회원가입에 실패했습니다") } return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "회원가입이 완료되었습니다"}) } @@ -79,23 +81,23 @@ func (h *Handler) Login(c *fiber.Ctx) error { Password string `json:"password"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } req.Username = strings.ToLower(strings.TrimSpace(req.Username)) if req.Username == "" || req.Password == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) + return apperror.BadRequest("아이디와 비밀번호를 입력해주세요") } if len(req.Username) > 50 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"}) + return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다") } if len(req.Password) > 72 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"}) + return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다") } accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password) if err != nil { log.Printf("Login failed: %v", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"}) + return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다") } c.Cookie(&fiber.Cookie{ @@ -137,13 +139,13 @@ func (h *Handler) Refresh(c *fiber.Ctx) error { } } if refreshTokenStr == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refreshToken이 필요합니다"}) + return apperror.BadRequest("refreshToken이 필요합니다") } newAccessToken, newRefreshToken, err := h.svc.Refresh(refreshTokenStr) if err != nil { log.Printf("Refresh failed: %v", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "토큰 갱신에 실패했습니다"}) + return apperror.Unauthorized("토큰 갱신에 실패했습니다") } c.Cookie(&fiber.Cookie{ @@ -173,11 +175,11 @@ func (h *Handler) Refresh(c *fiber.Ctx) error { func (h *Handler) Logout(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(uint) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"}) + return apperror.Unauthorized("인증 정보가 올바르지 않습니다") } if err := h.svc.Logout(userID); err != nil { log.Printf("Logout failed for user %d: %v", userID, err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"}) + return apperror.Internal("로그아웃 처리 중 오류가 발생했습니다") } c.Cookie(&fiber.Cookie{ Name: "refresh_token", @@ -215,7 +217,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { } users, err := h.svc.GetAllUsers(offset, limit) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 목록을 불러오지 못했습니다"}) + return apperror.Internal("유저 목록을 불러오지 못했습니다") } return c.JSON(users) } @@ -238,17 +240,17 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { func (h *Handler) UpdateRole(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"}) + return apperror.BadRequest("유효하지 않은 유저 ID입니다") } var body struct { Role string `json:"role"` } if err := c.BodyParser(&body); err != nil || (body.Role != "admin" && body.Role != "user") { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "role은 admin 또는 user여야 합니다"}) + return apperror.BadRequest("role은 admin 또는 user여야 합니다") } uid := uint(id) if err := h.svc.UpdateRole(uid, Role(body.Role)); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "권한 변경에 실패했습니다"}) + return apperror.Internal("권한 변경에 실패했습니다") } // 역할 변경 시 기존 세션 무효화 (새 권한으로 재로그인 유도) _ = h.svc.Logout(uid) @@ -272,12 +274,12 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error { Token string `json:"token"` } if err := c.BodyParser(&req); err != nil || req.Token == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token 필드가 필요합니다"}) + return apperror.BadRequest("token 필드가 필요합니다") } username, err := h.svc.VerifyToken(req.Token) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) + return apperror.Unauthorized(err.Error()) } return c.JSON(fiber.Map{ @@ -296,7 +298,7 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error { func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error { loginURL, err := h.svc.GetSSAFYLoginURL() if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SSAFY 로그인 URL 생성에 실패했습니다"}) + return apperror.Internal("SSAFY 로그인 URL 생성에 실패했습니다") } return c.JSON(fiber.Map{"url": loginURL}) } @@ -318,16 +320,16 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error { State string `json:"state"` } if err := c.BodyParser(&req); err != nil || req.Code == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "인가 코드가 필요합니다"}) + return apperror.BadRequest("인가 코드가 필요합니다") } if req.State == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "state 파라미터가 필요합니다"}) + return apperror.BadRequest("state 파라미터가 필요합니다") } accessToken, refreshToken, user, err := h.svc.SSAFYLogin(req.Code, req.State) if err != nil { log.Printf("SSAFY login failed: %v", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "SSAFY 로그인에 실패했습니다"}) + return apperror.Unauthorized("SSAFY 로그인에 실패했습니다") } c.Cookie(&fiber.Cookie{ @@ -359,11 +361,11 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error { func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(uint) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"}) + return apperror.Unauthorized("인증 정보가 올바르지 않습니다") } ticket, err := h.svc.CreateLaunchTicket(userID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "티켓 발급에 실패했습니다"}) + return apperror.Internal("티켓 발급에 실패했습니다") } return c.JSON(fiber.Map{"ticket": ticket}) } @@ -384,12 +386,12 @@ func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error { Ticket string `json:"ticket"` } if err := c.BodyParser(&req); err != nil || req.Ticket == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ticket 필드가 필요합니다"}) + return apperror.BadRequest("ticket 필드가 필요합니다") } token, err := h.svc.RedeemLaunchTicket(req.Ticket) if err != nil { log.Printf("RedeemLaunchTicket failed: %v", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않거나 만료된 티켓입니다"}) + return apperror.Unauthorized("유효하지 않거나 만료된 티켓입니다") } return c.JSON(fiber.Map{"token": token}) } @@ -409,10 +411,10 @@ func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error { func (h *Handler) DeleteUser(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"}) + return apperror.BadRequest("유효하지 않은 유저 ID입니다") } if err := h.svc.DeleteUser(uint(id)); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 삭제에 실패했습니다"}) + return apperror.Internal("유저 삭제에 실패했습니다") } return c.SendStatus(fiber.StatusNoContent) } diff --git a/internal/bossraid/handler.go b/internal/bossraid/handler.go index 02d1cf3..3c62670 100644 --- a/internal/bossraid/handler.go +++ b/internal/bossraid/handler.go @@ -3,6 +3,8 @@ package bossraid import ( "log" + "a301_server/pkg/apperror" + "github.com/gofiber/fiber/v2" ) @@ -14,9 +16,18 @@ func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } -func bossError(c *fiber.Ctx, status int, userMsg string, err error) error { +func bossError(status int, userMsg string, err error) *apperror.AppError { log.Printf("bossraid error: %s: %v", userMsg, err) - return c.Status(status).JSON(fiber.Map{"error": userMsg}) + code := "internal_error" + switch status { + case 400: + code = "bad_request" + case 404: + code = "not_found" + case 409: + code = "conflict" + } + return apperror.New(code, userMsg, status) } // RequestEntry godoc @@ -37,20 +48,20 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error { BossID int `json:"bossId"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if len(req.Usernames) == 0 || req.BossID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usernames와 bossId는 필수입니다"}) + return apperror.BadRequest("usernames와 bossId는 필수입니다") } for _, u := range req.Usernames { if len(u) == 0 || len(u) > 50 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 username입니다"}) + return apperror.BadRequest("유효하지 않은 username입니다") } } room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID) if err != nil { - return bossError(c, fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err) + return bossError(fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ @@ -79,15 +90,15 @@ func (h *Handler) StartRaid(c *fiber.Ctx) error { SessionName string `json:"sessionName"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.SessionName == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + return apperror.BadRequest("sessionName은 필수입니다") } room, err := h.svc.StartRaid(req.SessionName) if err != nil { - return bossError(c, fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err) + return bossError(fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err) } return c.JSON(fiber.Map{ @@ -115,15 +126,15 @@ func (h *Handler) CompleteRaid(c *fiber.Ctx) error { Rewards []PlayerReward `json:"rewards"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.SessionName == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + return apperror.BadRequest("sessionName은 필수입니다") } room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards) if err != nil { - return bossError(c, fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err) + return bossError(fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err) } return c.JSON(fiber.Map{ @@ -150,15 +161,15 @@ func (h *Handler) FailRaid(c *fiber.Ctx) error { SessionName string `json:"sessionName"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.SessionName == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + return apperror.BadRequest("sessionName은 필수입니다") } room, err := h.svc.FailRaid(req.SessionName) if err != nil { - return bossError(c, fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err) + return bossError(fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err) } return c.JSON(fiber.Map{ @@ -185,18 +196,15 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error { EntryToken string `json:"entryToken"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.EntryToken == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "entryToken은 필수입니다"}) + return apperror.BadRequest("entryToken은 필수입니다") } username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "valid": false, - "error": err.Error(), - }) + return apperror.Unauthorized(err.Error()) } return c.JSON(fiber.Map{ @@ -220,12 +228,12 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error { func (h *Handler) GetRoom(c *fiber.Ctx) error { sessionName := c.Query("sessionName") if sessionName == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + return apperror.BadRequest("sessionName은 필수입니다") } room, err := h.svc.GetRoom(sessionName) if err != nil { - return bossError(c, fiber.StatusNotFound, "방을 찾을 수 없습니다", err) + return bossError(fiber.StatusNotFound, "방을 찾을 수 없습니다", err) } return c.JSON(room) @@ -250,15 +258,15 @@ func (h *Handler) RegisterServer(c *fiber.Ctx) error { MaxRooms int `json:"maxRooms"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.ServerName == "" || req.InstanceID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName과 instanceId는 필수입니다"}) + return apperror.BadRequest("serverName과 instanceId는 필수입니다") } sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms) if err != nil { - return bossError(c, fiber.StatusConflict, "서버 등록에 실패했습니다", err) + return bossError(fiber.StatusConflict, "서버 등록에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ @@ -284,14 +292,14 @@ func (h *Handler) Heartbeat(c *fiber.Ctx) error { InstanceID string `json:"instanceId"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.InstanceID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "instanceId는 필수입니다"}) + return apperror.BadRequest("instanceId는 필수입니다") } if err := h.svc.Heartbeat(req.InstanceID); err != nil { - return bossError(c, fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err) + return bossError(fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err) } return c.JSON(fiber.Map{"status": "ok"}) @@ -314,14 +322,14 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error { SessionName string `json:"sessionName"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if req.SessionName == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + return apperror.BadRequest("sessionName은 필수입니다") } if err := h.svc.ResetRoom(req.SessionName); err != nil { - return bossError(c, fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err) + return bossError(fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err) } return c.JSON(fiber.Map{"status": "ok", "sessionName": req.SessionName}) @@ -341,12 +349,12 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error { func (h *Handler) GetServerStatus(c *fiber.Ctx) error { serverName := c.Query("serverName") if serverName == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName은 필수입니다"}) + return apperror.BadRequest("serverName은 필수입니다") } server, slots, err := h.svc.GetServerStatus(serverName) if err != nil { - return bossError(c, fiber.StatusNotFound, "서버를 찾을 수 없습니다", err) + return bossError(fiber.StatusNotFound, "서버를 찾을 수 없습니다", err) } return c.JSON(fiber.Map{ diff --git a/internal/bossraid/model.go b/internal/bossraid/model.go index fb63745..843b833 100644 --- a/internal/bossraid/model.go +++ b/internal/bossraid/model.go @@ -75,3 +75,18 @@ type RoomSlot struct { InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"` LastHeartbeat *time.Time `json:"lastHeartbeat"` } + +// RewardFailure records a failed reward grant for later retry. +// A record is "pending" when ResolvedAt is nil and RetryCount < maxRetries (10). +type RewardFailure struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + SessionName string `json:"sessionName" gorm:"type:varchar(100);index;not null"` + Username string `json:"username" gorm:"type:varchar(100);index;not null"` + TokenAmount uint64 `json:"tokenAmount" gorm:"not null"` + Assets string `json:"assets" gorm:"type:text"` + Experience int `json:"experience" gorm:"default:0;not null"` + Error string `json:"error" gorm:"type:text"` + RetryCount int `json:"retryCount" gorm:"default:0;not null"` + ResolvedAt *time.Time `json:"resolvedAt" gorm:"index"` +} diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go index c3b1546..b0e3dae 100644 --- a/internal/bossraid/repository.go +++ b/internal/bossraid/repository.go @@ -293,3 +293,39 @@ func (r *Repository) GetRoomSlotsByServer(serverID uint) ([]RoomSlot, error) { err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error return slots, err } + +// --- RewardFailure --- + +// SaveRewardFailure inserts a new reward failure record. +func (r *Repository) SaveRewardFailure(rf *RewardFailure) error { + return r.db.Create(rf).Error +} + +// GetPendingRewardFailures returns unresolved failures that haven't exceeded 10 retries. +func (r *Repository) GetPendingRewardFailures(limit int) ([]RewardFailure, error) { + var failures []RewardFailure + err := r.db. + Where("resolved_at IS NULL AND retry_count < 10"). + Order("created_at ASC"). + Limit(limit). + Find(&failures).Error + return failures, err +} + +// ResolveRewardFailure marks a reward failure as resolved by setting ResolvedAt. +func (r *Repository) ResolveRewardFailure(id uint) error { + now := time.Now() + return r.db.Model(&RewardFailure{}). + Where("id = ?", id). + Update("resolved_at", now).Error +} + +// IncrementRetryCount increments the retry count and updates the error message. +func (r *Repository) IncrementRetryCount(id uint, errMsg string) error { + return r.db.Model(&RewardFailure{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "retry_count": gorm.Expr("retry_count + 1"), + "error": errMsg, + }).Error +} diff --git a/internal/bossraid/reward_worker.go b/internal/bossraid/reward_worker.go new file mode 100644 index 0000000..a174abe --- /dev/null +++ b/internal/bossraid/reward_worker.go @@ -0,0 +1,112 @@ +package bossraid + +import ( + "encoding/json" + "log" + "time" + + "github.com/tolelom/tolchain/core" +) + +// RewardWorker periodically retries failed reward grants. +type RewardWorker struct { + repo *Repository + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error + expGrant func(username string, exp int) error + interval time.Duration + stopCh chan struct{} +} + +// NewRewardWorker creates a new RewardWorker. Default interval is 1 minute. +func NewRewardWorker( + repo *Repository, + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error, + expGrant func(username string, exp int) error, +) *RewardWorker { + return &RewardWorker{ + repo: repo, + rewardGrant: rewardGrant, + expGrant: expGrant, + interval: 1 * time.Minute, + stopCh: make(chan struct{}), + } +} + +// Start begins the background polling loop in a goroutine. +func (w *RewardWorker) Start() { + go func() { + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + for { + select { + case <-w.stopCh: + log.Println("보상 재시도 워커 종료") + return + case <-ticker.C: + w.processFailures() + } + } + }() + log.Println("보상 재시도 워커 시작") +} + +// Stop gracefully stops the worker. +func (w *RewardWorker) Stop() { + close(w.stopCh) +} + +func (w *RewardWorker) processFailures() { + failures, err := w.repo.GetPendingRewardFailures(10) + if err != nil { + log.Printf("보상 재시도 조회 실패: %v", err) + return + } + for _, rf := range failures { + w.retryOne(rf) + } +} + +func (w *RewardWorker) retryOne(rf RewardFailure) { + var retryErr error + + // 블록체인 보상 재시도 (토큰 또는 에셋이 있는 경우) + if (rf.TokenAmount > 0 || rf.Assets != "[]") && w.rewardGrant != nil { + var assets []core.MintAssetPayload + if rf.Assets != "" && rf.Assets != "[]" { + if err := json.Unmarshal([]byte(rf.Assets), &assets); err != nil { + log.Printf("보상 재시도 에셋 파싱 실패: ID=%d: %v", rf.ID, err) + // 파싱 불가능한 경우 resolved 처리 + if err := w.repo.ResolveRewardFailure(rf.ID); err != nil { + log.Printf("보상 실패 resolve 실패: ID=%d: %v", rf.ID, err) + } + return + } + } + retryErr = w.rewardGrant(rf.Username, rf.TokenAmount, assets) + } + + // 경험치 재시도 (블록체인 보상이 없거나 성공한 경우) + if retryErr == nil && rf.Experience > 0 && w.expGrant != nil { + retryErr = w.expGrant(rf.Username, rf.Experience) + } + + if retryErr == nil { + if err := w.repo.ResolveRewardFailure(rf.ID); err != nil { + log.Printf("보상 재시도 성공 기록 실패: ID=%d: %v", rf.ID, err) + } else { + log.Printf("보상 재시도 성공: ID=%d, %s", rf.ID, rf.Username) + } + return + } + + // 재시도 실패 — retry_count 증가 + if err := w.repo.IncrementRetryCount(rf.ID, retryErr.Error()); err != nil { + log.Printf("보상 재시도 카운트 증가 실패: ID=%d: %v", rf.ID, err) + } + newCount := rf.RetryCount + 1 + if newCount >= 10 { + log.Printf("보상 재시도 포기 (최대 횟수 초과): ID=%d, %s", rf.ID, rf.Username) + } else { + log.Printf("보상 재시도 실패 (%d/10): ID=%d, %s: %v", newCount, rf.ID, rf.Username, retryErr) + } +} diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index 042f7a9..53436f9 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -208,17 +208,21 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) } - // Grant rewards outside the transaction to avoid holding the lock during RPC calls + // Grant rewards outside the transaction to avoid holding the lock during RPC calls. + // Each reward is attempted up to 3 times with exponential backoff before being + // recorded as a RewardFailure for background retry. resultRewards = make([]RewardResult, 0, len(rewards)) hasRewardFailure := false if s.rewardGrant != nil { for _, r := range rewards { - grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets) + grantErr := s.grantWithRetry(r.Username, r.TokenAmount, r.Assets) result := RewardResult{Username: r.Username, Success: grantErr == nil} if grantErr != nil { result.Error = grantErr.Error() - log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr) + log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr) hasRewardFailure = true + // 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함 + s.saveRewardFailure(sessionName, r, grantErr) } resultRewards = append(resultRewards, result) } @@ -231,12 +235,18 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos } } - // Grant experience to players + // Grant experience to players (with retry) if s.expGrant != nil { for _, r := range rewards { if r.Experience > 0 { - if expErr := s.expGrant(r.Username, r.Experience); expErr != nil { - log.Printf("경험치 지급 실패: %s: %v", r.Username, expErr) + expErr := s.grantExpWithRetry(r.Username, r.Experience) + if expErr != nil { + log.Printf("경험치 지급 실패 (재시도 소진): %s: %v", r.Username, expErr) + // 경험치 실패도 RewardFailure에 기록 (토큰/에셋 없이 경험치만) + s.saveRewardFailure(sessionName, PlayerReward{ + Username: r.Username, + Experience: r.Experience, + }, expErr) } } } @@ -473,3 +483,62 @@ func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSl } return server, slots, nil } + +// --- Reward retry helpers --- + +const immediateRetries = 3 + +// grantWithRetry attempts the reward grant up to 3 times with backoff (1s, 2s). +func (s *Service) grantWithRetry(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { + delays := []time.Duration{1 * time.Second, 2 * time.Second} + var lastErr error + for attempt := 0; attempt < immediateRetries; attempt++ { + lastErr = s.rewardGrant(username, tokenAmount, assets) + if lastErr == nil { + return nil + } + if attempt < len(delays) { + log.Printf("보상 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr) + time.Sleep(delays[attempt]) + } + } + return lastErr +} + +// grantExpWithRetry attempts the experience grant up to 3 times with backoff (1s, 2s). +func (s *Service) grantExpWithRetry(username string, exp int) error { + delays := []time.Duration{1 * time.Second, 2 * time.Second} + var lastErr error + for attempt := 0; attempt < immediateRetries; attempt++ { + lastErr = s.expGrant(username, exp) + if lastErr == nil { + return nil + } + if attempt < len(delays) { + log.Printf("경험치 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr) + time.Sleep(delays[attempt]) + } + } + return lastErr +} + +// saveRewardFailure records a failed reward in the DB for background retry. +func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error) { + assets := "[]" + if len(r.Assets) > 0 { + if data, err := json.Marshal(r.Assets); err == nil { + assets = string(data) + } + } + rf := &RewardFailure{ + SessionName: sessionName, + Username: r.Username, + TokenAmount: r.TokenAmount, + Assets: assets, + Experience: r.Experience, + Error: grantErr.Error(), + } + if err := s.repo.SaveRewardFailure(rf); err != nil { + log.Printf("보상 실패 기록 저장 실패: %s/%s: %v", sessionName, r.Username, err) + } +} diff --git a/internal/chain/client.go b/internal/chain/client.go index 851100e..092df1b 100644 --- a/internal/chain/client.go +++ b/internal/chain/client.go @@ -147,3 +147,80 @@ func (c *Client) SendTx(tx any) (*SendTxResult, error) { err := c.Call("sendTx", tx, &result) return &result, err } + +// TxStatusResult mirrors the indexer.TxResult from the TOL Chain node. +type TxStatusResult struct { + TxID string `json:"tx_id"` + BlockHeight int64 `json:"block_height"` + Success bool `json:"success"` + Error string `json:"error"` +} + +// GetTxStatus queries the execution result of a transaction. +// Returns nil result (no error) if the transaction has not been included in a block yet. +func (c *Client) GetTxStatus(txID string) (*TxStatusResult, error) { + var result *TxStatusResult + err := c.Call("getTxStatus", map[string]string{"tx_id": txID}, &result) + if err != nil { + return nil, err + } + return result, nil +} + +// TxError is returned when a transaction was included in a block but execution failed. +type TxError struct { + TxID string + Message string +} + +func (e *TxError) Error() string { + return fmt.Sprintf("transaction %s failed: %s", e.TxID, e.Message) +} + +// DefaultTxTimeout is the default timeout for WaitForTx. PoA block intervals +// are typically a few seconds, so 15s provides ample margin. +const DefaultTxTimeout = 15 * time.Second + +// SendTxAndWait sends a transaction and waits for block confirmation. +// It combines SendTx + WaitForTx for the common fire-and-confirm pattern. +func (c *Client) SendTxAndWait(tx any, timeout time.Duration) (*TxStatusResult, error) { + sendResult, err := c.SendTx(tx) + if err != nil { + return nil, fmt.Errorf("send tx: %w", err) + } + return c.WaitForTx(sendResult.TxID, timeout) +} + +// WaitForTx polls getTxStatus until the transaction is included in a block or +// the timeout is reached. It returns the confirmed TxStatusResult on success, +// a TxError if the transaction executed but failed, or a timeout error. +func (c *Client) WaitForTx(txID string, timeout time.Duration) (*TxStatusResult, error) { + deadline := time.Now().Add(timeout) + interval := 200 * time.Millisecond + + for { + result, err := c.GetTxStatus(txID) + if err != nil { + return nil, fmt.Errorf("getTxStatus: %w", err) + } + if result != nil { + if !result.Success { + return result, &TxError{TxID: txID, Message: result.Error} + } + return result, nil + } + + if time.Now().After(deadline) { + return nil, fmt.Errorf("transaction %s not confirmed within %s", txID, timeout) + } + + time.Sleep(interval) + // Increase interval up to 1s to reduce polling pressure. + if interval < time.Second { + interval = interval * 3 / 2 + if interval > time.Second { + interval = time.Second + } + } + } +} diff --git a/internal/chain/client_test.go b/internal/chain/client_test.go new file mode 100644 index 0000000..9125345 --- /dev/null +++ b/internal/chain/client_test.go @@ -0,0 +1,333 @@ +package chain + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// rpcHandler returns an http.HandlerFunc that responds with JSON-RPC results. +// The handleFn receives the method and params, and returns the result or an error string. +func rpcHandler(handleFn func(method string, params json.RawMessage) (any, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID any `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", 400) + return + } + + result, errMsg := handleFn(req.Method, req.Params) + + w.Header().Set("Content-Type", "application/json") + if errMsg != "" { + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]any{"code": -32000, "message": errMsg}, + }) + return + } + + resultJSON, _ := json.Marshal(result) + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": json.RawMessage(resultJSON), + }) + } +} + +func TestWaitForTx_Success(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + if method != "getTxStatus" { + return nil, "unexpected method" + } + // First call returns null (not yet confirmed), second returns success + if calls.Add(1) == 1 { + return nil, "" + } + return &TxStatusResult{ + TxID: "tx-123", + BlockHeight: 42, + Success: true, + }, "" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.WaitForTx("tx-123", 5*time.Second) + if err != nil { + t.Fatalf("WaitForTx should succeed: %v", err) + } + if result.TxID != "tx-123" { + t.Errorf("TxID = %q, want %q", result.TxID, "tx-123") + } + if result.BlockHeight != 42 { + t.Errorf("BlockHeight = %d, want 42", result.BlockHeight) + } + if !result.Success { + t.Error("Success should be true") + } + if calls.Load() != 2 { + t.Errorf("expected 2 RPC calls, got %d", calls.Load()) + } +} + +func TestWaitForTx_Timeout(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + // Always return null — transaction never confirms + return nil, "" + })) + defer srv.Close() + + client := NewClient(srv.URL) + timeout := 500 * time.Millisecond + start := time.Now() + result, err := client.WaitForTx("tx-never", timeout) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("WaitForTx should return timeout error") + } + if result != nil { + t.Error("result should be nil on timeout") + } + if !strings.Contains(err.Error(), "not confirmed within") { + t.Errorf("error should mention timeout, got: %v", err) + } + // Should have waited at least the timeout duration + if elapsed < timeout { + t.Errorf("elapsed %v is less than timeout %v", elapsed, timeout) + } +} + +func TestWaitForTx_TxFailure(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + return &TxStatusResult{ + TxID: "tx-fail", + BlockHeight: 10, + Success: false, + Error: "insufficient balance: have 0 need 100", + }, "" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.WaitForTx("tx-fail", 5*time.Second) + + if err == nil { + t.Fatal("WaitForTx should return TxError for failed transaction") + } + + // Should return a TxError + var txErr *TxError + if !errors.As(err, &txErr) { + t.Fatalf("error should be *TxError, got %T: %v", err, err) + } + if txErr.TxID != "tx-fail" { + t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-fail") + } + if !strings.Contains(txErr.Message, "insufficient balance") { + t.Errorf("TxError.Message should contain 'insufficient balance', got %q", txErr.Message) + } + + // Result should still be returned even on TxError + if result == nil { + t.Fatal("result should be non-nil even on TxError") + } + if result.Success { + t.Error("result.Success should be false") + } +} + +func TestWaitForTx_RPCError(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + return nil, "internal server error" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.WaitForTx("tx-rpc-err", 2*time.Second) + + if err == nil { + t.Fatal("WaitForTx should return error on RPC failure") + } + if result != nil { + t.Error("result should be nil on RPC error") + } + if !strings.Contains(err.Error(), "getTxStatus") { + t.Errorf("error should wrap getTxStatus context, got: %v", err) + } +} + +func TestSendTxAndWait_Success(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + switch method { + case "sendTx": + return &SendTxResult{TxID: "tx-abc"}, "" + case "getTxStatus": + return &TxStatusResult{ + TxID: "tx-abc", + BlockHeight: 5, + Success: true, + }, "" + default: + return nil, fmt.Sprintf("unexpected method: %s", method) + } + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second) + if err != nil { + t.Fatalf("SendTxAndWait should succeed: %v", err) + } + if result.TxID != "tx-abc" { + t.Errorf("TxID = %q, want %q", result.TxID, "tx-abc") + } + if result.BlockHeight != 5 { + t.Errorf("BlockHeight = %d, want 5", result.BlockHeight) + } +} + +func TestSendTxAndWait_SendError(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + return nil, "mempool full" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second) + + if err == nil { + t.Fatal("SendTxAndWait should fail when sendTx fails") + } + if result != nil { + t.Error("result should be nil on send error") + } + if !strings.Contains(err.Error(), "send tx") { + t.Errorf("error should wrap send tx context, got: %v", err) + } +} + +func TestSendTxAndWait_TxFailure(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + switch method { + case "sendTx": + return &SendTxResult{TxID: "tx-will-fail"}, "" + case "getTxStatus": + return &TxStatusResult{ + TxID: "tx-will-fail", + BlockHeight: 7, + Success: false, + Error: "asset is not tradeable", + }, "" + default: + return nil, "unexpected" + } + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second) + + if err == nil { + t.Fatal("SendTxAndWait should return error for failed tx") + } + + var txErr *TxError + if !errors.As(err, &txErr) { + t.Fatalf("error should be *TxError, got %T: %v", err, err) + } + if txErr.TxID != "tx-will-fail" { + t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-will-fail") + } + + // Result still returned with failure details + if result == nil { + t.Fatal("result should be non-nil even on TxError") + } + if result.Success { + t.Error("result.Success should be false") + } +} + +func TestWaitForTx_PollingBackoff(t *testing.T) { + var calls atomic.Int32 + confirmAfter := int32(5) // confirm on the 5th call + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + n := calls.Add(1) + if n < confirmAfter { + return nil, "" + } + return &TxStatusResult{ + TxID: "tx-backoff", + BlockHeight: 99, + Success: true, + }, "" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.WaitForTx("tx-backoff", 10*time.Second) + if err != nil { + t.Fatalf("WaitForTx should succeed: %v", err) + } + if result.TxID != "tx-backoff" { + t.Errorf("TxID = %q, want %q", result.TxID, "tx-backoff") + } + if calls.Load() != confirmAfter { + t.Errorf("expected %d RPC calls, got %d", confirmAfter, calls.Load()) + } +} + +func TestGetTxStatus_NotFound(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + // Return null result — tx not yet in a block + return nil, "" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.GetTxStatus("tx-pending") + if err != nil { + t.Fatalf("GetTxStatus should not error for pending tx: %v", err) + } + if result != nil { + t.Errorf("result should be nil for pending tx, got %+v", result) + } +} + +func TestGetTxStatus_Found(t *testing.T) { + srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) { + return &TxStatusResult{ + TxID: "tx-done", + BlockHeight: 100, + Success: true, + }, "" + })) + defer srv.Close() + + client := NewClient(srv.URL) + result, err := client.GetTxStatus("tx-done") + if err != nil { + t.Fatalf("GetTxStatus should succeed: %v", err) + } + if result == nil { + t.Fatal("result should not be nil for confirmed tx") + } + if result.TxID != "tx-done" || result.BlockHeight != 100 || !result.Success { + t.Errorf("unexpected result: %+v", result) + } +} diff --git a/internal/chain/handler.go b/internal/chain/handler.go index 3857d99..3f20686 100644 --- a/internal/chain/handler.go +++ b/internal/chain/handler.go @@ -1,8 +1,12 @@ package chain import ( + "errors" "log" "strconv" + "strings" + + "a301_server/pkg/apperror" "github.com/gofiber/fiber/v2" "github.com/tolelom/tolchain/core" @@ -22,7 +26,7 @@ func NewHandler(svc *Service) *Handler { func getUserID(c *fiber.Ctx) (uint, error) { uid, ok := c.Locals("userID").(uint) if !ok { - return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다") + return 0, apperror.ErrUnauthorized } return uid, nil } @@ -45,9 +49,47 @@ func validID(s string) bool { return s != "" && len(s) <= maxIDLength } -func chainError(c *fiber.Ctx, status int, userMsg string, err error) error { +// chainError classifies chain errors into appropriate HTTP responses. +// TxError (on-chain execution failure) maps to 422 with the chain's error detail. +// Other errors (network, timeout, build failures) remain 500. +func chainError(userMsg string, err error) *apperror.AppError { log.Printf("chain error: %s: %v", userMsg, err) - return c.Status(status).JSON(fiber.Map{"error": userMsg}) + + var txErr *TxError + if errors.As(err, &txErr) { + msg := classifyTxError(txErr.Message) + return apperror.New("tx_failed", msg, 422) + } + return apperror.Internal(userMsg) +} + +// classifyTxError translates raw chain error messages into user-friendly Korean messages. +func classifyTxError(chainMsg string) string { + lower := strings.ToLower(chainMsg) + switch { + case strings.Contains(lower, "insufficient balance"): + return "잔액이 부족합니다" + case strings.Contains(lower, "unauthorized"): + return "권한이 없습니다" + case strings.Contains(lower, "already listed"): + return "이미 마켓에 등록된 아이템입니다" + case strings.Contains(lower, "already exists"): + return "이미 존재합니다" + case strings.Contains(lower, "not found"): + return "리소스를 찾을 수 없습니다" + case strings.Contains(lower, "not tradeable"): + return "거래할 수 없는 아이템입니다" + case strings.Contains(lower, "equipped"): + return "장착 중인 아이템입니다" + case strings.Contains(lower, "not active"): + return "활성 상태가 아닌 매물입니다" + case strings.Contains(lower, "not open"): + return "진행 중이 아닌 세션입니다" + case strings.Contains(lower, "invalid nonce"): + return "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요" + default: + return "블록체인 트랜잭션이 실패했습니다" + } } // ---- Query Handlers ---- @@ -69,7 +111,7 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error { } w, err := h.svc.GetWallet(userID) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"}) + return apperror.NotFound("지갑을 찾을 수 없습니다") } return c.JSON(fiber.Map{ "address": w.Address, @@ -94,7 +136,7 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error { } result, err := h.svc.GetBalance(userID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err) + return chainError("잔액 조회에 실패했습니다", err) } return c.JSON(result) } @@ -119,7 +161,7 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error { offset, limit := parsePagination(c) result, err := h.svc.GetAssets(userID, offset, limit) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err) + return chainError("에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -140,11 +182,11 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error { func (h *Handler) GetAsset(c *fiber.Ctx) error { assetID := c.Params("id") if !validID(assetID) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 asset id가 필요합니다"}) + return apperror.BadRequest("유효한 asset id가 필요합니다") } result, err := h.svc.GetAsset(assetID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err) + return chainError("에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -167,7 +209,7 @@ func (h *Handler) GetInventory(c *fiber.Ctx) error { } result, err := h.svc.GetInventory(userID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err) + return chainError("인벤토리 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -188,7 +230,7 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error { offset, limit := parsePagination(c) result, err := h.svc.GetMarketListings(offset, limit) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err) + return chainError("마켓 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -208,11 +250,11 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error { func (h *Handler) GetMarketListing(c *fiber.Ctx) error { listingID := c.Params("id") if !validID(listingID) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 listing id가 필요합니다"}) + return apperror.BadRequest("유효한 listing id가 필요합니다") } result, err := h.svc.GetListing(listingID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err) + return chainError("마켓 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -244,14 +286,14 @@ func (h *Handler) Transfer(c *fiber.Ctx) error { Amount uint64 `json:"amount"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.To) || req.Amount == 0 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"}) + return apperror.BadRequest("to와 amount는 필수입니다") } result, err := h.svc.Transfer(userID, req.To, req.Amount) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err) + return chainError("전송에 실패했습니다", err) } return c.JSON(result) } @@ -280,14 +322,14 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error { To string `json:"to"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.AssetID) || !validID(req.To) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"}) + return apperror.BadRequest("assetId와 to는 필수입니다") } result, err := h.svc.TransferAsset(userID, req.AssetID, req.To) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err) + return chainError("에셋 전송에 실패했습니다", err) } return c.JSON(result) } @@ -316,14 +358,14 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error { Price uint64 `json:"price"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.AssetID) || req.Price == 0 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"}) + return apperror.BadRequest("assetId와 price는 필수입니다") } result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err) + return chainError("마켓 등록에 실패했습니다", err) } return c.JSON(result) } @@ -351,14 +393,14 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error { ListingID string `json:"listingId"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.ListingID) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"}) + return apperror.BadRequest("listingId는 필수입니다") } result, err := h.svc.BuyFromMarket(userID, req.ListingID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err) + return chainError("마켓 구매에 실패했습니다", err) } return c.JSON(result) } @@ -386,14 +428,14 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error { ListingID string `json:"listingId"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.ListingID) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"}) + return apperror.BadRequest("listingId는 필수입니다") } result, err := h.svc.CancelListing(userID, req.ListingID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err) + return chainError("마켓 취소에 실패했습니다", err) } return c.JSON(result) } @@ -422,14 +464,14 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error { Slot string `json:"slot"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.AssetID) || !validID(req.Slot) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"}) + return apperror.BadRequest("assetId와 slot은 필수입니다") } result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err) + return chainError("장착에 실패했습니다", err) } return c.JSON(result) } @@ -457,14 +499,14 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error { AssetID string `json:"assetId"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.AssetID) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"}) + return apperror.BadRequest("assetId는 필수입니다") } result, err := h.svc.UnequipItem(userID, req.AssetID) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err) + return chainError("장착 해제에 실패했습니다", err) } return c.JSON(result) } @@ -493,14 +535,14 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error { Properties map[string]any `json:"properties"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.TemplateID) || !validID(req.OwnerPubKey) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"}) + return apperror.BadRequest("templateId와 ownerPubKey는 필수입니다") } result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err) + return chainError("에셋 발행에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -527,14 +569,14 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error { Assets []core.MintAssetPayload `json:"assets"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.RecipientPubKey) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"}) + return apperror.BadRequest("recipientPubKey는 필수입니다") } result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err) + return chainError("보상 지급에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -562,14 +604,14 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error { Tradeable bool `json:"tradeable"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.ID) || !validID(req.Name) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"}) + return apperror.BadRequest("id와 name은 필수입니다") } result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err) + return chainError("템플릿 등록에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -596,14 +638,14 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error { Assets []core.MintAssetPayload `json:"assets"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.Username) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + return apperror.BadRequest("username은 필수입니다") } result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err) + return chainError("보상 지급에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -628,14 +670,14 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error { Properties map[string]any `json:"properties"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if !validID(req.TemplateID) || !validID(req.Username) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"}) + return apperror.BadRequest("templateId와 username은 필수입니다") } result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err) + return chainError("에셋 발행에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -654,11 +696,11 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error { func (h *Handler) InternalGetBalance(c *fiber.Ctx) error { username := c.Query("username") if !validID(username) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + return apperror.BadRequest("username은 필수입니다") } result, err := h.svc.GetBalanceByUsername(username) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err) + return chainError("잔액 조회에 실패했습니다", err) } return c.JSON(result) } @@ -679,12 +721,12 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error { func (h *Handler) InternalGetAssets(c *fiber.Ctx) error { username := c.Query("username") if !validID(username) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + return apperror.BadRequest("username은 필수입니다") } offset, limit := parsePagination(c) result, err := h.svc.GetAssetsByUsername(username, offset, limit) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err) + return chainError("에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -704,11 +746,11 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error { func (h *Handler) InternalGetInventory(c *fiber.Ctx) error { username := c.Query("username") if !validID(username) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + return apperror.BadRequest("username은 필수입니다") } result, err := h.svc.GetInventoryByUsername(username) if err != nil { - return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err) + return chainError("인벤토리 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) diff --git a/internal/chain/service.go b/internal/chain/service.go index 9a32100..ef75e4a 100644 --- a/internal/chain/service.go +++ b/internal/chain/service.go @@ -10,6 +10,7 @@ import ( "io" "log" "sync" + "time" "github.com/tolelom/tolchain/core" tocrypto "github.com/tolelom/tolchain/crypto" @@ -174,6 +175,17 @@ func (s *Service) getNonce(address string) (uint64, error) { return bal.Nonce, nil } +// txConfirmTimeout is the maximum time to wait for a transaction to be +// included in a block. PoA block intervals are typically a few seconds, +// so 15s provides ample margin. +const txConfirmTimeout = 15 * time.Second + +// submitTx sends a signed transaction and waits for block confirmation. +// Returns the confirmed status or an error (including TxError for on-chain failures). +func (s *Service) submitTx(tx any) (*TxStatusResult, error) { + return s.client.SendTxAndWait(tx, txConfirmTimeout) +} + // ---- Query Methods ---- func (s *Service) GetBalance(userID uint) (*BalanceResult, error) { @@ -220,7 +232,9 @@ func (s *Service) getUserMu(userID uint) *sync.Mutex { // ---- User Transaction Methods ---- -func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult, error) { +// userTx handles the common boilerplate for user transactions: +// acquire per-user mutex → load wallet → get nonce → build tx → submit. +func (s *Service) userTx(userID uint, buildFn func(w *wallet.Wallet, nonce uint64) (any, error)) (*TxStatusResult, error) { mu := s.getUserMu(userID) mu.Lock() defer mu.Unlock() @@ -232,125 +246,53 @@ func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult if err != nil { return nil, err } - tx, err := w.Transfer(s.chainID, to, amount, nonce, 0) + tx, err := buildFn(w, nonce) if err != nil { return nil, fmt.Errorf("build tx failed: %w", err) } - return s.client.SendTx(tx) + return s.submitTx(tx) } -func (s *Service) TransferAsset(userID uint, assetID, to string) (*SendTxResult, error) { - mu := s.getUserMu(userID) - mu.Lock() - defer mu.Unlock() - w, pubKey, err := s.loadUserWallet(userID) - if err != nil { - return nil, err - } - nonce, err := s.getNonce(pubKey) - if err != nil { - return nil, err - } - tx, err := w.TransferAsset(s.chainID, assetID, to, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) Transfer(userID uint, to string, amount uint64) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.Transfer(s.chainID, to, amount, nonce, 0) + }) } -func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*SendTxResult, error) { - mu := s.getUserMu(userID) - mu.Lock() - defer mu.Unlock() - w, pubKey, err := s.loadUserWallet(userID) - if err != nil { - return nil, err - } - nonce, err := s.getNonce(pubKey) - if err != nil { - return nil, err - } - tx, err := w.ListMarket(s.chainID, assetID, price, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) TransferAsset(userID uint, assetID, to string) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.TransferAsset(s.chainID, assetID, to, nonce, 0) + }) } -func (s *Service) BuyFromMarket(userID uint, listingID string) (*SendTxResult, error) { - mu := s.getUserMu(userID) - mu.Lock() - defer mu.Unlock() - w, pubKey, err := s.loadUserWallet(userID) - if err != nil { - return nil, err - } - nonce, err := s.getNonce(pubKey) - if err != nil { - return nil, err - } - tx, err := w.BuyMarket(s.chainID, listingID, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.ListMarket(s.chainID, assetID, price, nonce, 0) + }) } -func (s *Service) CancelListing(userID uint, listingID string) (*SendTxResult, error) { - mu := s.getUserMu(userID) - mu.Lock() - defer mu.Unlock() - w, pubKey, err := s.loadUserWallet(userID) - if err != nil { - return nil, err - } - nonce, err := s.getNonce(pubKey) - if err != nil { - return nil, err - } - tx, err := w.CancelListing(s.chainID, listingID, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) BuyFromMarket(userID uint, listingID string) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.BuyMarket(s.chainID, listingID, nonce, 0) + }) } -func (s *Service) EquipItem(userID uint, assetID, slot string) (*SendTxResult, error) { - mu := s.getUserMu(userID) - mu.Lock() - defer mu.Unlock() - w, pubKey, err := s.loadUserWallet(userID) - if err != nil { - return nil, err - } - nonce, err := s.getNonce(pubKey) - if err != nil { - return nil, err - } - tx, err := w.EquipItem(s.chainID, assetID, slot, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) CancelListing(userID uint, listingID string) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.CancelListing(s.chainID, listingID, nonce, 0) + }) } -func (s *Service) UnequipItem(userID uint, assetID string) (*SendTxResult, error) { - mu := s.getUserMu(userID) - mu.Lock() - defer mu.Unlock() - w, pubKey, err := s.loadUserWallet(userID) - if err != nil { - return nil, err - } - nonce, err := s.getNonce(pubKey) - if err != nil { - return nil, err - } - tx, err := w.UnequipItem(s.chainID, assetID, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) EquipItem(userID uint, assetID, slot string) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.EquipItem(s.chainID, assetID, slot, nonce, 0) + }) +} + +func (s *Service) UnequipItem(userID uint, assetID string) (*TxStatusResult, error) { + return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) { + return w.UnequipItem(s.chainID, assetID, nonce, 0) + }) } // ---- Operator Transaction Methods ---- @@ -369,7 +311,9 @@ func (s *Service) getOperatorNonce() (uint64, error) { return s.getNonce(s.operatorWallet.PubKey()) } -func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*SendTxResult, error) { +// operatorTx handles the common boilerplate for operator transactions: +// acquire operator mutex → ensure operator → get nonce → build tx → submit. +func (s *Service) operatorTx(buildFn func(nonce uint64) (any, error)) (*TxStatusResult, error) { s.operatorMu.Lock() defer s.operatorMu.Unlock() if err := s.ensureOperator(); err != nil { @@ -379,50 +323,34 @@ func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[strin if err != nil { return nil, err } - tx, err := s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0) + tx, err := buildFn(nonce) if err != nil { return nil, fmt.Errorf("build tx failed: %w", err) } - return s.client.SendTx(tx) + return s.submitTx(tx) } -func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*SendTxResult, error) { - s.operatorMu.Lock() - defer s.operatorMu.Unlock() - if err := s.ensureOperator(); err != nil { - return nil, err - } - nonce, err := s.getOperatorNonce() - if err != nil { - return nil, err - } - tx, err := s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*TxStatusResult, error) { + return s.operatorTx(func(nonce uint64) (any, error) { + return s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0) + }) } -func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*SendTxResult, error) { - s.operatorMu.Lock() - defer s.operatorMu.Unlock() - if err := s.ensureOperator(); err != nil { - return nil, err - } - nonce, err := s.getOperatorNonce() - if err != nil { - return nil, err - } - tx, err := s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0) - if err != nil { - return nil, fmt.Errorf("build tx failed: %w", err) - } - return s.client.SendTx(tx) +func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) { + return s.operatorTx(func(nonce uint64) (any, error) { + return s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0) + }) +} + +func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*TxStatusResult, error) { + return s.operatorTx(func(nonce uint64) (any, error) { + return s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0) + }) } // ---- Username-based Methods (for game server) ---- -func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*SendTxResult, error) { +func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) { pubKey, err := s.resolveUsername(username) if err != nil { return nil, err @@ -430,7 +358,7 @@ func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, ass return s.GrantReward(pubKey, tokenAmount, assets) } -func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*SendTxResult, error) { +func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*TxStatusResult, error) { pubKey, err := s.resolveUsername(username) if err != nil { return nil, err diff --git a/internal/chain/service_test.go b/internal/chain/service_test.go index 7582846..7cb124f 100644 --- a/internal/chain/service_test.go +++ b/internal/chain/service_test.go @@ -232,3 +232,40 @@ func TestResolveUsername_NoResolver(t *testing.T) { t.Error("resolveUsername should fail when userResolver is nil") } } + +func TestClassifyTxError(t *testing.T) { + tests := []struct { + chainMsg string + want string + }{ + {"insufficient balance: have 0 need 100: insufficient balance", "잔액이 부족합니다"}, + {"only the asset owner can list it: unauthorized", "권한이 없습니다"}, + {"session \"abc\" already exists: already exists", "이미 존재합니다"}, + {"asset \"xyz\" not found: not found", "리소스를 찾을 수 없습니다"}, + {"asset is not tradeable", "거래할 수 없는 아이템입니다"}, + {"asset \"a\" is equipped; unequip it before listing", "장착 중인 아이템입니다"}, + {"asset \"a\" is already listed (listing x): already exists", "이미 마켓에 등록된 아이템입니다"}, + {"listing \"x\" is not active", "활성 상태가 아닌 매물입니다"}, + {"session \"x\" is not open (status=closed)", "진행 중이 아닌 세션입니다"}, + {"invalid nonce: expected 5 got 3: invalid nonce", "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요"}, + {"some unknown error", "블록체인 트랜잭션이 실패했습니다"}, + } + + for _, tt := range tests { + t.Run(tt.chainMsg, func(t *testing.T) { + got := classifyTxError(tt.chainMsg) + if got != tt.want { + t.Errorf("classifyTxError(%q) = %q, want %q", tt.chainMsg, got, tt.want) + } + }) + } +} + +func TestTxError_Error(t *testing.T) { + err := &TxError{TxID: "abc123", Message: "insufficient balance"} + got := err.Error() + want := "transaction abc123 failed: insufficient balance" + if got != want { + t.Errorf("TxError.Error() = %q, want %q", got, want) + } +} diff --git a/internal/download/handler.go b/internal/download/handler.go index dec46f8..3f3d416 100644 --- a/internal/download/handler.go +++ b/internal/download/handler.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" + "a301_server/pkg/apperror" + "github.com/gofiber/fiber/v2" ) @@ -30,7 +32,7 @@ func NewHandler(svc *Service, baseURL string) *Handler { func (h *Handler) GetInfo(c *fiber.Ctx) error { info, err := h.svc.GetInfo() if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "다운로드 정보가 없습니다"}) + return apperror.NotFound("다운로드 정보가 없습니다") } return c.JSON(info) } @@ -54,17 +56,17 @@ func (h *Handler) Upload(c *fiber.Ctx) error { // 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용 filename = filepath.Base(filename) if !strings.HasSuffix(strings.ToLower(filename), ".zip") { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zip 파일만 업로드 가능합니다"}) + return apperror.BadRequest("zip 파일만 업로드 가능합니다") } if len(filename) > 200 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "파일명이 너무 깁니다"}) + return apperror.BadRequest("파일명이 너무 깁니다") } body := c.Request().BodyStream() info, err := h.svc.Upload(filename, body, h.baseURL) if err != nil { log.Printf("game upload failed: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "게임 파일 업로드에 실패했습니다"}) + return apperror.Internal("게임 파일 업로드에 실패했습니다") } return c.JSON(info) } @@ -80,7 +82,7 @@ func (h *Handler) Upload(c *fiber.Ctx) error { func (h *Handler) ServeFile(c *fiber.Ctx) error { path := h.svc.GameFilePath() if _, err := os.Stat(path); err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"}) + return apperror.NotFound("파일이 없습니다") } info, _ := h.svc.GetInfo() filename := "game.zip" @@ -108,7 +110,7 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error { info, err := h.svc.UploadLauncher(body, h.baseURL) if err != nil { log.Printf("launcher upload failed: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "런처 업로드에 실패했습니다"}) + return apperror.Internal("런처 업로드에 실패했습니다") } return c.JSON(info) } @@ -124,7 +126,7 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error { func (h *Handler) ServeLauncher(c *fiber.Ctx) error { path := h.svc.LauncherFilePath() if _, err := os.Stat(path); err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"}) + return apperror.NotFound("파일이 없습니다") } c.Set("Content-Disposition", `attachment; filename="launcher.exe"`) return c.SendFile(path) diff --git a/internal/player/handler.go b/internal/player/handler.go index afc4bfd..41a3fc1 100644 --- a/internal/player/handler.go +++ b/internal/player/handler.go @@ -5,6 +5,8 @@ import ( "strings" "unicode" + "a301_server/pkg/apperror" + "github.com/gofiber/fiber/v2" ) @@ -29,12 +31,12 @@ func NewHandler(svc *Service) *Handler { func (h *Handler) GetProfile(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(uint) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"}) + return apperror.Unauthorized("인증 정보가 올바르지 않습니다") } profile, err := h.svc.GetProfile(userID) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + return apperror.NotFound(err.Error()) } return c.JSON(profileWithNextExp(profile)) @@ -56,25 +58,25 @@ func (h *Handler) GetProfile(c *fiber.Ctx) error { func (h *Handler) UpdateProfile(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(uint) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"}) + return apperror.Unauthorized("인증 정보가 올바르지 않습니다") } var req struct { Nickname string `json:"nickname"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } req.Nickname = strings.TrimSpace(req.Nickname) if req.Nickname != "" { nicknameRunes := []rune(req.Nickname) if len(nicknameRunes) < 2 || len(nicknameRunes) > 30 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "닉네임은 2~30자여야 합니다"}) + return apperror.BadRequest("닉네임은 2~30자여야 합니다") } for _, r := range nicknameRunes { if unicode.IsControl(r) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "닉네임에 허용되지 않는 문자가 포함되어 있습니다"}) + return apperror.BadRequest("닉네임에 허용되지 않는 문자가 포함되어 있습니다") } } } @@ -82,7 +84,7 @@ func (h *Handler) UpdateProfile(c *fiber.Ctx) error { profile, err := h.svc.UpdateProfile(userID, req.Nickname) if err != nil { log.Printf("프로필 수정 실패 (userID=%d): %v", userID, err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"}) + return apperror.ErrInternal } return c.JSON(profile) @@ -102,12 +104,12 @@ func (h *Handler) UpdateProfile(c *fiber.Ctx) error { func (h *Handler) InternalGetProfile(c *fiber.Ctx) error { username := c.Query("username") if username == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"}) + return apperror.BadRequest("username 파라미터가 필요합니다") } profile, err := h.svc.GetProfileByUsername(username) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + return apperror.NotFound(err.Error()) } return c.JSON(profileWithNextExp(profile)) @@ -157,18 +159,18 @@ func profileWithNextExp(p *PlayerProfile) fiber.Map { func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error { username := c.Query("username") if username == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"}) + return apperror.BadRequest("username 파라미터가 필요합니다") } var req GameDataRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + return apperror.ErrBadRequest } if err := h.svc.SaveGameDataByUsername(username, &req); err != nil { // Username from internal API (ServerAuth protected) — low risk of injection log.Printf("게임 데이터 저장 실패 (username=%s): %v", username, err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"}) + return apperror.ErrInternal } return c.JSON(fiber.Map{"message": "게임 데이터가 저장되었습니다"}) diff --git a/internal/player/service_test.go b/internal/player/service_test.go new file mode 100644 index 0000000..bd7a554 --- /dev/null +++ b/internal/player/service_test.go @@ -0,0 +1,542 @@ +// NOTE: These tests use a testableService that reimplements service logic +// with mock repositories. This means tests can pass even if the real service +// diverges. For full coverage, consider refactoring services to use repository +// interfaces so the real service can be tested with mock repositories injected. + +package player + +import ( + "fmt" + "testing" + + "gorm.io/gorm" +) + +// --------------------------------------------------------------------------- +// Mock repository — mirrors the methods that Service calls on *Repository. +// --------------------------------------------------------------------------- + +type repositoryInterface interface { + Create(profile *PlayerProfile) error + FindByUserID(userID uint) (*PlayerProfile, error) + Update(profile *PlayerProfile) error + UpdateStats(userID uint, updates map[string]interface{}) error +} + +type testableService struct { + repo repositoryInterface + userResolver func(username string) (uint, error) +} + +func (s *testableService) GetProfile(userID uint) (*PlayerProfile, error) { + profile, err := s.repo.FindByUserID(userID) + if err != nil { + if err == gorm.ErrRecordNotFound { + profile = &PlayerProfile{UserID: userID} + if createErr := s.repo.Create(profile); createErr != nil { + return nil, fmt.Errorf("프로필 자동 생성에 실패했습니다: %w", createErr) + } + return profile, nil + } + return nil, fmt.Errorf("프로필 조회에 실패했습니다") + } + return profile, nil +} + +func (s *testableService) UpdateProfile(userID uint, nickname string) (*PlayerProfile, error) { + profile, err := s.repo.FindByUserID(userID) + if err != nil { + return nil, fmt.Errorf("프로필이 존재하지 않습니다") + } + if nickname != "" { + profile.Nickname = nickname + } + if err := s.repo.Update(profile); err != nil { + return nil, fmt.Errorf("프로필 수정에 실패했습니다") + } + return profile, nil +} + +func (s *testableService) SaveGameData(userID uint, data *GameDataRequest) error { + if err := validateGameData(data); err != nil { + return err + } + + updates := map[string]interface{}{} + + if data.Level != nil { + updates["level"] = *data.Level + } + if data.Experience != nil { + updates["experience"] = *data.Experience + } + if data.MaxHP != nil { + updates["max_hp"] = *data.MaxHP + } + if data.MaxMP != nil { + updates["max_mp"] = *data.MaxMP + } + if data.AttackPower != nil { + updates["attack_power"] = *data.AttackPower + } + if data.AttackRange != nil { + updates["attack_range"] = *data.AttackRange + } + if data.SprintMultiplier != nil { + updates["sprint_multiplier"] = *data.SprintMultiplier + } + if data.LastPosX != nil { + updates["last_pos_x"] = *data.LastPosX + } + if data.LastPosY != nil { + updates["last_pos_y"] = *data.LastPosY + } + if data.LastPosZ != nil { + updates["last_pos_z"] = *data.LastPosZ + } + if data.LastRotY != nil { + updates["last_rot_y"] = *data.LastRotY + } + if data.PlayTimeDelta != nil { + profile, err := s.repo.FindByUserID(userID) + if err != nil { + return fmt.Errorf("프로필이 존재하지 않습니다") + } + updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta + } + + if len(updates) == 0 { + return nil + } + + return s.repo.UpdateStats(userID, updates) +} + +func (s *testableService) GetProfileByUsername(username string) (*PlayerProfile, error) { + if s.userResolver == nil { + return nil, fmt.Errorf("userResolver가 설정되지 않았습니다") + } + userID, err := s.userResolver(username) + if err != nil { + return nil, fmt.Errorf("존재하지 않는 유저입니다") + } + return s.GetProfile(userID) +} + +// --------------------------------------------------------------------------- +// Mock implementation +// --------------------------------------------------------------------------- + +type mockRepo struct { + profiles map[uint]*PlayerProfile + nextID uint + createErr error + updateErr error + updateStatsErr error +} + +func newMockRepo() *mockRepo { + return &mockRepo{ + profiles: make(map[uint]*PlayerProfile), + nextID: 1, + } +} + +func (m *mockRepo) Create(profile *PlayerProfile) error { + if m.createErr != nil { + return m.createErr + } + profile.ID = m.nextID + profile.Level = 1 + profile.MaxHP = 100 + profile.MaxMP = 50 + profile.AttackPower = 10 + profile.AttackRange = 3 + profile.SprintMultiplier = 1.8 + m.nextID++ + stored := *profile + m.profiles[profile.UserID] = &stored + return nil +} + +func (m *mockRepo) FindByUserID(userID uint) (*PlayerProfile, error) { + p, ok := m.profiles[userID] + if !ok { + return nil, gorm.ErrRecordNotFound + } + cp := *p + return &cp, nil +} + +func (m *mockRepo) Update(profile *PlayerProfile) error { + if m.updateErr != nil { + return m.updateErr + } + stored := *profile + m.profiles[profile.UserID] = &stored + return nil +} + +func (m *mockRepo) UpdateStats(userID uint, updates map[string]interface{}) error { + if m.updateStatsErr != nil { + return m.updateStatsErr + } + p, ok := m.profiles[userID] + if !ok { + return gorm.ErrRecordNotFound + } + for key, val := range updates { + switch key { + case "level": + p.Level = val.(int) + case "experience": + p.Experience = val.(int) + case "max_hp": + p.MaxHP = val.(float64) + case "max_mp": + p.MaxMP = val.(float64) + case "attack_power": + p.AttackPower = val.(float64) + case "attack_range": + p.AttackRange = val.(float64) + case "sprint_multiplier": + p.SprintMultiplier = val.(float64) + case "last_pos_x": + p.LastPosX = val.(float64) + case "last_pos_y": + p.LastPosY = val.(float64) + case "last_pos_z": + p.LastPosZ = val.(float64) + case "last_rot_y": + p.LastRotY = val.(float64) + case "total_play_time": + p.TotalPlayTime = val.(int64) + } + } + return nil +} + +// seedProfile creates a profile for a given userID in the mock repo. +func seedProfile(repo *mockRepo, userID uint, nickname string) *PlayerProfile { + p := &PlayerProfile{UserID: userID, Nickname: nickname} + _ = repo.Create(p) + return repo.profiles[userID] +} + +// --------------------------------------------------------------------------- +// Tests — GetProfile +// --------------------------------------------------------------------------- + +func TestGetProfile_Success(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + + seedProfile(repo, 1, "player1") + + profile, err := svc.GetProfile(1) + if err != nil { + t.Fatalf("GetProfile failed: %v", err) + } + if profile.UserID != 1 { + t.Errorf("UserID = %d, want 1", profile.UserID) + } + if profile.Nickname != "player1" { + t.Errorf("Nickname = %q, want %q", profile.Nickname, "player1") + } +} + +func TestGetProfile_NotFound_AutoCreates(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + + profile, err := svc.GetProfile(42) + if err != nil { + t.Fatalf("GetProfile should auto-create, got error: %v", err) + } + if profile.UserID != 42 { + t.Errorf("UserID = %d, want 42", profile.UserID) + } + if profile.Level != 1 { + t.Errorf("Level = %d, want 1 (default)", profile.Level) + } +} + +func TestGetProfile_AutoCreateFails(t *testing.T) { + repo := newMockRepo() + repo.createErr = fmt.Errorf("db error") + svc := &testableService{repo: repo} + + _, err := svc.GetProfile(42) + if err == nil { + t.Error("expected error when auto-create fails, got nil") + } +} + +// --------------------------------------------------------------------------- +// Tests — GetProfileByUsername +// --------------------------------------------------------------------------- + +func TestGetProfileByUsername_Success(t *testing.T) { + repo := newMockRepo() + svc := &testableService{ + repo: repo, + userResolver: func(username string) (uint, error) { + if username == "testuser" { + return 1, nil + } + return 0, fmt.Errorf("not found") + }, + } + seedProfile(repo, 1, "testuser") + + profile, err := svc.GetProfileByUsername("testuser") + if err != nil { + t.Fatalf("GetProfileByUsername failed: %v", err) + } + if profile.UserID != 1 { + t.Errorf("UserID = %d, want 1", profile.UserID) + } +} + +func TestGetProfileByUsername_UserNotFound(t *testing.T) { + repo := newMockRepo() + svc := &testableService{ + repo: repo, + userResolver: func(username string) (uint, error) { + return 0, fmt.Errorf("not found") + }, + } + + _, err := svc.GetProfileByUsername("unknown") + if err == nil { + t.Error("expected error for unknown username, got nil") + } +} + +func TestGetProfileByUsername_NoResolver(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + + _, err := svc.GetProfileByUsername("anyone") + if err == nil { + t.Error("expected error when userResolver is nil, got nil") + } +} + +// --------------------------------------------------------------------------- +// Tests — UpdateProfile +// --------------------------------------------------------------------------- + +func TestUpdateProfile_Success(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "old_nick") + + profile, err := svc.UpdateProfile(1, "new_nick") + if err != nil { + t.Fatalf("UpdateProfile failed: %v", err) + } + if profile.Nickname != "new_nick" { + t.Errorf("Nickname = %q, want %q", profile.Nickname, "new_nick") + } +} + +func TestUpdateProfile_EmptyNickname_KeepsExisting(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "keep_me") + + profile, err := svc.UpdateProfile(1, "") + if err != nil { + t.Fatalf("UpdateProfile failed: %v", err) + } + if profile.Nickname != "keep_me" { + t.Errorf("Nickname = %q, want %q (should be unchanged)", profile.Nickname, "keep_me") + } +} + +func TestUpdateProfile_NotFound(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + + _, err := svc.UpdateProfile(999, "nick") + if err == nil { + t.Error("expected error updating non-existent profile, got nil") + } +} + +func TestUpdateProfile_RepoError(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "nick") + + repo.updateErr = fmt.Errorf("db error") + _, err := svc.UpdateProfile(1, "new_nick") + if err == nil { + t.Error("expected error when repo update fails, got nil") + } +} + +// --------------------------------------------------------------------------- +// Tests — SaveGameData +// --------------------------------------------------------------------------- + +func intPtr(v int) *int { return &v } +func float64Ptr(v float64) *float64 { return &v } +func int64Ptr(v int64) *int64 { return &v } + +func TestSaveGameData_Success(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + err := svc.SaveGameData(1, &GameDataRequest{ + Level: intPtr(5), + Experience: intPtr(200), + MaxHP: float64Ptr(150), + LastPosX: float64Ptr(10.5), + LastPosY: float64Ptr(20.0), + LastPosZ: float64Ptr(30.0), + }) + if err != nil { + t.Fatalf("SaveGameData failed: %v", err) + } + + p := repo.profiles[1] + if p.Level != 5 { + t.Errorf("Level = %d, want 5", p.Level) + } + if p.Experience != 200 { + t.Errorf("Experience = %d, want 200", p.Experience) + } + if p.MaxHP != 150 { + t.Errorf("MaxHP = %f, want 150", p.MaxHP) + } + if p.LastPosX != 10.5 { + t.Errorf("LastPosX = %f, want 10.5", p.LastPosX) + } +} + +func TestSaveGameData_EmptyRequest(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + err := svc.SaveGameData(1, &GameDataRequest{}) + if err != nil { + t.Fatalf("SaveGameData with empty request should succeed: %v", err) + } +} + +func TestSaveGameData_PlayTimeDelta(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + repo.profiles[1].TotalPlayTime = 1000 + + err := svc.SaveGameData(1, &GameDataRequest{ + PlayTimeDelta: int64Ptr(300), + }) + if err != nil { + t.Fatalf("SaveGameData failed: %v", err) + } + + p := repo.profiles[1] + if p.TotalPlayTime != 1300 { + t.Errorf("TotalPlayTime = %d, want 1300 (1000+300)", p.TotalPlayTime) + } +} + +func TestSaveGameData_PlayTimeDelta_Accumulates(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + _ = svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(100)}) + _ = svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(200)}) + + p := repo.profiles[1] + if p.TotalPlayTime != 300 { + t.Errorf("TotalPlayTime = %d, want 300 (0+100+200)", p.TotalPlayTime) + } +} + +func TestSaveGameData_PlayTimeDelta_ProfileNotFound(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + + err := svc.SaveGameData(999, &GameDataRequest{PlayTimeDelta: int64Ptr(100)}) + if err == nil { + t.Error("expected error when profile not found for PlayTimeDelta, got nil") + } +} + +// --------------------------------------------------------------------------- +// Tests — SaveGameData validation +// --------------------------------------------------------------------------- + +func TestSaveGameData_InvalidLevel(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + err := svc.SaveGameData(1, &GameDataRequest{Level: intPtr(0)}) + if err == nil { + t.Error("expected error for level=0, got nil") + } + + err = svc.SaveGameData(1, &GameDataRequest{Level: intPtr(1000)}) + if err == nil { + t.Error("expected error for level=1000, got nil") + } +} + +func TestSaveGameData_NegativeExperience(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + err := svc.SaveGameData(1, &GameDataRequest{Experience: intPtr(-1)}) + if err == nil { + t.Error("expected error for negative experience, got nil") + } +} + +func TestSaveGameData_NegativePlayTimeDelta(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + err := svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(-100)}) + if err == nil { + t.Error("expected error for negative playTimeDelta, got nil") + } +} + +func TestSaveGameData_MaxHP_OutOfRange(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + err := svc.SaveGameData(1, &GameDataRequest{MaxHP: float64Ptr(0)}) + if err == nil { + t.Error("expected error for maxHP=0, got nil") + } + + err = svc.SaveGameData(1, &GameDataRequest{MaxHP: float64Ptr(1000000)}) + if err == nil { + t.Error("expected error for maxHP=1000000, got nil") + } +} + +func TestSaveGameData_RepoError(t *testing.T) { + repo := newMockRepo() + svc := &testableService{repo: repo} + seedProfile(repo, 1, "player1") + + repo.updateStatsErr = fmt.Errorf("db write error") + err := svc.SaveGameData(1, &GameDataRequest{Level: intPtr(5)}) + if err == nil { + t.Error("expected error when repo UpdateStats fails, got nil") + } +} diff --git a/main.go b/main.go index ef7a104..c3f016f 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( _ "a301_server/docs" // swagger docs "github.com/tolelom/tolchain/core" + "a301_server/pkg/apperror" "a301_server/pkg/config" "a301_server/pkg/database" "a301_server/pkg/metrics" @@ -56,7 +57,7 @@ func main() { log.Println("MySQL 연결 성공") // AutoMigrate - if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &player.PlayerProfile{}); err != nil { + if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &bossraid.RewardFailure{}, &player.PlayerProfile{}); err != nil { log.Fatalf("AutoMigrate 실패: %v", err) } @@ -170,7 +171,7 @@ func main() { return c.IP() }, LimitReached: func(c *fiber.Ctx) error { - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"}) + return apperror.ErrRateLimited }, }) @@ -182,7 +183,7 @@ func main() { return c.IP() }, LimitReached: func(c *fiber.Ctx) error { - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"}) + return apperror.ErrRateLimited }, }) @@ -215,7 +216,7 @@ func main() { return "chain_ip:" + c.IP() }, LimitReached: func(c *fiber.Ctx) error { - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "트랜잭션 요청이 너무 많습니다. 잠시 후 다시 시도해주세요"}) + return apperror.ErrRateLimited }, }) @@ -230,12 +231,26 @@ func main() { } }() + // Background: retry failed rewards + rewardWorker := bossraid.NewRewardWorker( + brRepo, + func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { + _, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) + return err + }, + func(username string, exp int) error { + return playerSvc.GrantExperienceByUsername(username, exp) + }, + ) + rewardWorker.Start() + // Graceful shutdown go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) sig := <-sigCh log.Printf("수신된 시그널: %v — 서버 종료 중...", sig) + rewardWorker.Stop() if err := app.ShutdownWithTimeout(10 * time.Second); err != nil { log.Printf("서버 종료 실패: %v", err) } diff --git a/pkg/apperror/apperror.go b/pkg/apperror/apperror.go index fc45f8d..10e5d1e 100644 --- a/pkg/apperror/apperror.go +++ b/pkg/apperror/apperror.go @@ -3,6 +3,7 @@ package apperror import "fmt" // AppError is a structured application error with an HTTP status code. +// JSON response format: {"error": "", "message": ""} type AppError struct { Code string `json:"error"` Message string `json:"message"` @@ -23,11 +24,36 @@ func Wrap(code string, message string, status int, cause error) *AppError { // Common errors var ( + ErrBadRequest = &AppError{Code: "bad_request", Message: "잘못된 요청입니다", Status: 400} ErrUnauthorized = &AppError{Code: "unauthorized", Message: "인증이 필요합니다", Status: 401} ErrForbidden = &AppError{Code: "forbidden", Message: "권한이 없습니다", Status: 403} ErrNotFound = &AppError{Code: "not_found", Message: "리소스를 찾을 수 없습니다", Status: 404} ErrConflict = &AppError{Code: "conflict", Message: "이미 존재합니다", Status: 409} - ErrBadRequest = &AppError{Code: "bad_request", Message: "잘못된 요청입니다", Status: 400} - ErrInternal = &AppError{Code: "internal_error", Message: "서버 오류가 발생했습니다", Status: 500} ErrRateLimited = &AppError{Code: "rate_limited", Message: "요청이 너무 많습니다", Status: 429} + ErrInternal = &AppError{Code: "internal_error", Message: "서버 오류가 발생했습니다", Status: 500} ) + +// BadRequest creates a 400 error with a custom message. +func BadRequest(message string) *AppError { + return &AppError{Code: "bad_request", Message: message, Status: 400} +} + +// Unauthorized creates a 401 error with a custom message. +func Unauthorized(message string) *AppError { + return &AppError{Code: "unauthorized", Message: message, Status: 401} +} + +// NotFound creates a 404 error with a custom message. +func NotFound(message string) *AppError { + return &AppError{Code: "not_found", Message: message, Status: 404} +} + +// Conflict creates a 409 error with a custom message. +func Conflict(message string) *AppError { + return &AppError{Code: "conflict", Message: message, Status: 409} +} + +// Internal creates a 500 error with a custom message. +func Internal(message string) *AppError { + return &AppError{Code: "internal_error", Message: message, Status: 500} +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 96ca433..f6ef865 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -7,6 +7,7 @@ import ( "log" "strings" + "a301_server/pkg/apperror" "a301_server/pkg/config" "a301_server/pkg/database" "github.com/gofiber/fiber/v2" @@ -16,7 +17,7 @@ import ( func Auth(c *fiber.Ctx) error { header := c.Get("Authorization") if !strings.HasPrefix(header, "Bearer ") { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증이 필요합니다"}) + return apperror.ErrUnauthorized } tokenStr := strings.TrimPrefix(header, "Bearer ") @@ -27,24 +28,24 @@ func Auth(c *fiber.Ctx) error { return []byte(config.C.JWTSecret), nil }) if err != nil || !token.Valid { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + return apperror.Unauthorized("유효하지 않은 토큰입니다") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + return apperror.Unauthorized("유효하지 않은 토큰입니다") } userIDFloat, ok := claims["user_id"].(float64) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + return apperror.Unauthorized("유효하지 않은 토큰입니다") } username, ok := claims["username"].(string) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + return apperror.Unauthorized("유효하지 않은 토큰입니다") } role, ok := claims["role"].(string) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + return apperror.Unauthorized("유효하지 않은 토큰입니다") } userID := uint(userIDFloat) @@ -54,7 +55,7 @@ func Auth(c *fiber.Ctx) error { key := fmt.Sprintf("session:%d", userID) stored, err := database.RDB.Get(ctx, key).Result() if err != nil || stored != tokenStr { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "만료되었거나 로그아웃된 세션입니다"}) + return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다") } c.Locals("userID", userID) @@ -65,7 +66,7 @@ func Auth(c *fiber.Ctx) error { func AdminOnly(c *fiber.Ctx) error { if c.Locals("role") != "admin" { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "관리자 권한이 필요합니다"}) + return apperror.ErrForbidden } return c.Next() } @@ -77,7 +78,7 @@ func ServerAuth(c *fiber.Ctx) error { expected := config.C.InternalAPIKey if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 { log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path()) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"}) + return apperror.Unauthorized("유효하지 않은 API 키입니다") } return c.Next() } diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go index 546979c..da2caaa 100644 --- a/pkg/middleware/idempotency.go +++ b/pkg/middleware/idempotency.go @@ -7,6 +7,7 @@ import ( "log" "time" + "a301_server/pkg/apperror" "a301_server/pkg/database" "github.com/gofiber/fiber/v2" ) @@ -23,9 +24,7 @@ type cachedResponse struct { // then delegates to Idempotency for cache/replay logic. func IdempotencyRequired(c *fiber.Ctx) error { if c.Get("Idempotency-Key") == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Idempotency-Key 헤더가 필요합니다", - }) + return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다") } return Idempotency(c) } @@ -38,7 +37,7 @@ func Idempotency(c *fiber.Ctx) error { return c.Next() } if len(key) > 256 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Idempotency-Key가 너무 깁니다"}) + return apperror.BadRequest("Idempotency-Key가 너무 깁니다") } // userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지 @@ -66,10 +65,10 @@ func Idempotency(c *fiber.Ctx) error { cached, err := database.RDB.Get(getCtx, redisKey).Bytes() if err != nil { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"}) + return apperror.Conflict("요청이 처리 중입니다") } if string(cached) == "processing" { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"}) + return apperror.Conflict("요청이 처리 중입니다") } var cr cachedResponse if json.Unmarshal(cached, &cr) == nil { @@ -77,7 +76,7 @@ func Idempotency(c *fiber.Ctx) error { c.Set("X-Idempotent-Replay", "true") return c.Status(cr.StatusCode).Send(cr.Body) } - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"}) + return apperror.Conflict("요청이 처리 중입니다") } // We claimed the key — process the request