feat: 보상 재시도 + TX 확정 대기 + 에러 포맷 통일 + 품질 고도화
- 보상 지급 실패 시 즉시 재시도(3회 backoff) + DB 기록 + 백그라운드 워커 재시도 - WaitForTx 폴링으로 블록체인 TX 확정 대기, SendTxAndWait 편의 메서드 - chain 트랜잭션 코드 중복 제거 (userTx/operatorTx 헬퍼, 50% 감소) - AppError 기반 에러 응답 포맷 통일 (8개 코드, 전 핸들러 마이그레이션) - TX 에러 분류 + 한국어 사용자 메시지 매핑 (11가지 패턴) - player 서비스 테스트 20개 + chain WaitForTx 테스트 10개 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ func (h *Handler) GetAll(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
list, err := h.svc.GetAll(offset, limit)
|
list, err := h.svc.GetAll(offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항을 불러오지 못했습니다"})
|
return apperror.Internal("공지사항을 불러오지 못했습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(list)
|
return c.JSON(list)
|
||||||
}
|
}
|
||||||
@@ -62,17 +64,17 @@ func (h *Handler) Create(c *fiber.Ctx) error {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.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 {
|
if len(body.Title) > 256 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"})
|
return apperror.BadRequest("제목은 256자 이하여야 합니다")
|
||||||
}
|
}
|
||||||
if len(body.Content) > 10000 {
|
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)
|
a, err := h.svc.Create(body.Title, body.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항 생성에 실패했습니다"})
|
return apperror.Internal("공지사항 생성에 실패했습니다")
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusCreated).JSON(a)
|
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 {
|
func (h *Handler) Update(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"})
|
return apperror.BadRequest("유효하지 않은 공지사항 ID입니다")
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&body); err != nil {
|
if err := c.BodyParser(&body); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if body.Title == "" && body.Content == "" {
|
if body.Title == "" && body.Content == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"})
|
return apperror.BadRequest("수정할 내용을 입력해주세요")
|
||||||
}
|
}
|
||||||
if len(body.Title) > 256 {
|
if len(body.Title) > 256 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"})
|
return apperror.BadRequest("제목은 256자 이하여야 합니다")
|
||||||
}
|
}
|
||||||
if len(body.Content) > 10000 {
|
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)
|
a, err := h.svc.Update(uint(id), body.Title, body.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "찾을 수 없습니다") {
|
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)
|
log.Printf("공지사항 수정 실패 (id=%d): %v", id, err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"})
|
return apperror.ErrInternal
|
||||||
}
|
}
|
||||||
return c.JSON(a)
|
return c.JSON(a)
|
||||||
}
|
}
|
||||||
@@ -141,13 +143,13 @@ func (h *Handler) Update(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) Delete(c *fiber.Ctx) error {
|
func (h *Handler) Delete(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
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 err := h.svc.Delete(uint(id)); err != nil {
|
||||||
if strings.Contains(err.Error(), "찾을 수 없습니다") {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,26 +40,26 @@ func (h *Handler) Register(c *fiber.Ctx) error {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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))
|
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
return apperror.BadRequest("아이디와 비밀번호를 입력해주세요")
|
||||||
}
|
}
|
||||||
if !usernameRe.MatchString(req.Username) {
|
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 {
|
if len(req.Password) < 6 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 6자 이상이어야 합니다"})
|
return apperror.BadRequest("비밀번호는 6자 이상이어야 합니다")
|
||||||
}
|
}
|
||||||
if len(req.Password) > 72 {
|
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 err := h.svc.Register(req.Username, req.Password); err != nil {
|
||||||
if strings.Contains(err.Error(), "이미 사용 중") {
|
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": "회원가입이 완료되었습니다"})
|
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"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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))
|
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
return apperror.BadRequest("아이디와 비밀번호를 입력해주세요")
|
||||||
}
|
}
|
||||||
if len(req.Username) > 50 {
|
if len(req.Username) > 50 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
|
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
if len(req.Password) > 72 {
|
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)
|
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Login failed: %v", err)
|
log.Printf("Login failed: %v", err)
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
|
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Cookie(&fiber.Cookie{
|
c.Cookie(&fiber.Cookie{
|
||||||
@@ -137,13 +139,13 @@ func (h *Handler) Refresh(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if refreshTokenStr == "" {
|
if refreshTokenStr == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refreshToken이 필요합니다"})
|
return apperror.BadRequest("refreshToken이 필요합니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
newAccessToken, newRefreshToken, err := h.svc.Refresh(refreshTokenStr)
|
newAccessToken, newRefreshToken, err := h.svc.Refresh(refreshTokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Refresh failed: %v", err)
|
log.Printf("Refresh failed: %v", err)
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "토큰 갱신에 실패했습니다"})
|
return apperror.Unauthorized("토큰 갱신에 실패했습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Cookie(&fiber.Cookie{
|
c.Cookie(&fiber.Cookie{
|
||||||
@@ -173,11 +175,11 @@ func (h *Handler) Refresh(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) Logout(c *fiber.Ctx) error {
|
func (h *Handler) Logout(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(uint)
|
userID, ok := c.Locals("userID").(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
|
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
if err := h.svc.Logout(userID); err != nil {
|
if err := h.svc.Logout(userID); err != nil {
|
||||||
log.Printf("Logout failed for user %d: %v", userID, err)
|
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{
|
c.Cookie(&fiber.Cookie{
|
||||||
Name: "refresh_token",
|
Name: "refresh_token",
|
||||||
@@ -215,7 +217,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
users, err := h.svc.GetAllUsers(offset, limit)
|
users, err := h.svc.GetAllUsers(offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 목록을 불러오지 못했습니다"})
|
return apperror.Internal("유저 목록을 불러오지 못했습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(users)
|
return c.JSON(users)
|
||||||
}
|
}
|
||||||
@@ -238,17 +240,17 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) UpdateRole(c *fiber.Ctx) error {
|
func (h *Handler) UpdateRole(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"})
|
return apperror.BadRequest("유효하지 않은 유저 ID입니다")
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&body); err != nil || (body.Role != "admin" && body.Role != "user") {
|
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)
|
uid := uint(id)
|
||||||
if err := h.svc.UpdateRole(uid, Role(body.Role)); err != nil {
|
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)
|
_ = h.svc.Logout(uid)
|
||||||
@@ -272,12 +274,12 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil || req.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)
|
username, err := h.svc.VerifyToken(req.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
return apperror.Unauthorized(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
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 {
|
func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error {
|
||||||
loginURL, err := h.svc.GetSSAFYLoginURL()
|
loginURL, err := h.svc.GetSSAFYLoginURL()
|
||||||
if err != nil {
|
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})
|
return c.JSON(fiber.Map{"url": loginURL})
|
||||||
}
|
}
|
||||||
@@ -318,16 +320,16 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil || req.Code == "" {
|
if err := c.BodyParser(&req); err != nil || req.Code == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "인가 코드가 필요합니다"})
|
return apperror.BadRequest("인가 코드가 필요합니다")
|
||||||
}
|
}
|
||||||
if req.State == "" {
|
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)
|
accessToken, refreshToken, user, err := h.svc.SSAFYLogin(req.Code, req.State)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("SSAFY login failed: %v", err)
|
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{
|
c.Cookie(&fiber.Cookie{
|
||||||
@@ -359,11 +361,11 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error {
|
func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(uint)
|
userID, ok := c.Locals("userID").(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
|
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
ticket, err := h.svc.CreateLaunchTicket(userID)
|
ticket, err := h.svc.CreateLaunchTicket(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "티켓 발급에 실패했습니다"})
|
return apperror.Internal("티켓 발급에 실패했습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"ticket": ticket})
|
return c.JSON(fiber.Map{"ticket": ticket})
|
||||||
}
|
}
|
||||||
@@ -384,12 +386,12 @@ func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error {
|
|||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil || req.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)
|
token, err := h.svc.RedeemLaunchTicket(req.Ticket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("RedeemLaunchTicket failed: %v", err)
|
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})
|
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 {
|
func (h *Handler) DeleteUser(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
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 {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package bossraid
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,9 +16,18 @@ func NewHandler(svc *Service) *Handler {
|
|||||||
return &Handler{svc: svc}
|
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)
|
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
|
// RequestEntry godoc
|
||||||
@@ -37,20 +48,20 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error {
|
|||||||
BossID int `json:"bossId"`
|
BossID int `json:"bossId"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
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 {
|
for _, u := range req.Usernames {
|
||||||
if len(u) == 0 || len(u) > 50 {
|
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)
|
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
|
return bossError(fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||||
@@ -79,15 +90,15 @@ func (h *Handler) StartRaid(c *fiber.Ctx) error {
|
|||||||
SessionName string `json:"sessionName"`
|
SessionName string `json:"sessionName"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.SessionName == "" {
|
if req.SessionName == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
return apperror.BadRequest("sessionName은 필수입니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
room, err := h.svc.StartRaid(req.SessionName)
|
room, err := h.svc.StartRaid(req.SessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
|
return bossError(fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
@@ -115,15 +126,15 @@ func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
|
|||||||
Rewards []PlayerReward `json:"rewards"`
|
Rewards []PlayerReward `json:"rewards"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.SessionName == "" {
|
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)
|
room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
|
return bossError(fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
@@ -150,15 +161,15 @@ func (h *Handler) FailRaid(c *fiber.Ctx) error {
|
|||||||
SessionName string `json:"sessionName"`
|
SessionName string `json:"sessionName"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.SessionName == "" {
|
if req.SessionName == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
return apperror.BadRequest("sessionName은 필수입니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
room, err := h.svc.FailRaid(req.SessionName)
|
room, err := h.svc.FailRaid(req.SessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
|
return bossError(fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
@@ -185,18 +196,15 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
|
|||||||
EntryToken string `json:"entryToken"`
|
EntryToken string `json:"entryToken"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.EntryToken == "" {
|
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)
|
username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
return apperror.Unauthorized(err.Error())
|
||||||
"valid": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
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 {
|
func (h *Handler) GetRoom(c *fiber.Ctx) error {
|
||||||
sessionName := c.Query("sessionName")
|
sessionName := c.Query("sessionName")
|
||||||
if sessionName == "" {
|
if sessionName == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
|
return apperror.BadRequest("sessionName은 필수입니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
room, err := h.svc.GetRoom(sessionName)
|
room, err := h.svc.GetRoom(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
|
return bossError(fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(room)
|
return c.JSON(room)
|
||||||
@@ -250,15 +258,15 @@ func (h *Handler) RegisterServer(c *fiber.Ctx) error {
|
|||||||
MaxRooms int `json:"maxRooms"`
|
MaxRooms int `json:"maxRooms"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.ServerName == "" || req.InstanceID == "" {
|
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)
|
sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusConflict, "서버 등록에 실패했습니다", err)
|
return bossError(fiber.StatusConflict, "서버 등록에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||||
@@ -284,14 +292,14 @@ func (h *Handler) Heartbeat(c *fiber.Ctx) error {
|
|||||||
InstanceID string `json:"instanceId"`
|
InstanceID string `json:"instanceId"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.InstanceID == "" {
|
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 {
|
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"})
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
@@ -314,14 +322,14 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error {
|
|||||||
SessionName string `json:"sessionName"`
|
SessionName string `json:"sessionName"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if req.SessionName == "" {
|
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 {
|
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})
|
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 {
|
func (h *Handler) GetServerStatus(c *fiber.Ctx) error {
|
||||||
serverName := c.Query("serverName")
|
serverName := c.Query("serverName")
|
||||||
if serverName == "" {
|
if serverName == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName은 필수입니다"})
|
return apperror.BadRequest("serverName은 필수입니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
server, slots, err := h.svc.GetServerStatus(serverName)
|
server, slots, err := h.svc.GetServerStatus(serverName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bossError(c, fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
|
return bossError(fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
|
|||||||
@@ -75,3 +75,18 @@ type RoomSlot struct {
|
|||||||
InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"`
|
InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"`
|
||||||
LastHeartbeat *time.Time `json:"lastHeartbeat"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error
|
||||||
return slots, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
112
internal/bossraid/reward_worker.go
Normal file
112
internal/bossraid/reward_worker.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,17 +208,21 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
|
|||||||
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
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))
|
resultRewards = make([]RewardResult, 0, len(rewards))
|
||||||
hasRewardFailure := false
|
hasRewardFailure := false
|
||||||
if s.rewardGrant != nil {
|
if s.rewardGrant != nil {
|
||||||
for _, r := range rewards {
|
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}
|
result := RewardResult{Username: r.Username, Success: grantErr == nil}
|
||||||
if grantErr != nil {
|
if grantErr != nil {
|
||||||
result.Error = grantErr.Error()
|
result.Error = grantErr.Error()
|
||||||
log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr)
|
log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr)
|
||||||
hasRewardFailure = true
|
hasRewardFailure = true
|
||||||
|
// 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함
|
||||||
|
s.saveRewardFailure(sessionName, r, grantErr)
|
||||||
}
|
}
|
||||||
resultRewards = append(resultRewards, result)
|
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 {
|
if s.expGrant != nil {
|
||||||
for _, r := range rewards {
|
for _, r := range rewards {
|
||||||
if r.Experience > 0 {
|
if r.Experience > 0 {
|
||||||
if expErr := s.expGrant(r.Username, r.Experience); expErr != nil {
|
expErr := s.grantExpWithRetry(r.Username, r.Experience)
|
||||||
log.Printf("경험치 지급 실패: %s: %v", r.Username, expErr)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,3 +147,80 @@ func (c *Client) SendTx(tx any) (*SendTxResult, error) {
|
|||||||
err := c.Call("sendTx", tx, &result)
|
err := c.Call("sendTx", tx, &result)
|
||||||
return &result, err
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
333
internal/chain/client_test.go
Normal file
333
internal/chain/client_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package chain
|
package chain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/tolelom/tolchain/core"
|
"github.com/tolelom/tolchain/core"
|
||||||
@@ -22,7 +26,7 @@ func NewHandler(svc *Service) *Handler {
|
|||||||
func getUserID(c *fiber.Ctx) (uint, error) {
|
func getUserID(c *fiber.Ctx) (uint, error) {
|
||||||
uid, ok := c.Locals("userID").(uint)
|
uid, ok := c.Locals("userID").(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다")
|
return 0, apperror.ErrUnauthorized
|
||||||
}
|
}
|
||||||
return uid, nil
|
return uid, nil
|
||||||
}
|
}
|
||||||
@@ -45,9 +49,47 @@ func validID(s string) bool {
|
|||||||
return s != "" && len(s) <= maxIDLength
|
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)
|
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 ----
|
// ---- Query Handlers ----
|
||||||
@@ -69,7 +111,7 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
w, err := h.svc.GetWallet(userID)
|
w, err := h.svc.GetWallet(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
|
return apperror.NotFound("지갑을 찾을 수 없습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"address": w.Address,
|
"address": w.Address,
|
||||||
@@ -94,7 +136,7 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
result, err := h.svc.GetBalance(userID)
|
result, err := h.svc.GetBalance(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
|
return chainError("잔액 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -119,7 +161,7 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
|
|||||||
offset, limit := parsePagination(c)
|
offset, limit := parsePagination(c)
|
||||||
result, err := h.svc.GetAssets(userID, offset, limit)
|
result, err := h.svc.GetAssets(userID, offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
return chainError("에셋 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
@@ -140,11 +182,11 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) GetAsset(c *fiber.Ctx) error {
|
func (h *Handler) GetAsset(c *fiber.Ctx) error {
|
||||||
assetID := c.Params("id")
|
assetID := c.Params("id")
|
||||||
if !validID(assetID) {
|
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)
|
result, err := h.svc.GetAsset(assetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
return chainError("에셋 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
@@ -167,7 +209,7 @@ func (h *Handler) GetInventory(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
result, err := h.svc.GetInventory(userID)
|
result, err := h.svc.GetInventory(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
|
return chainError("인벤토리 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
@@ -188,7 +230,7 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
|
|||||||
offset, limit := parsePagination(c)
|
offset, limit := parsePagination(c)
|
||||||
result, err := h.svc.GetMarketListings(offset, limit)
|
result, err := h.svc.GetMarketListings(offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
|
return chainError("마켓 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
@@ -208,11 +250,11 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
|
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
|
||||||
listingID := c.Params("id")
|
listingID := c.Params("id")
|
||||||
if !validID(listingID) {
|
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)
|
result, err := h.svc.GetListing(listingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
|
return chainError("마켓 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
@@ -244,14 +286,14 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
|
|||||||
Amount uint64 `json:"amount"`
|
Amount uint64 `json:"amount"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
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)
|
result, err := h.svc.Transfer(userID, req.To, req.Amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err)
|
return chainError("전송에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -280,14 +322,14 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
|
|||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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) {
|
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)
|
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err)
|
return chainError("에셋 전송에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -316,14 +358,14 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
|
|||||||
Price uint64 `json:"price"`
|
Price uint64 `json:"price"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
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)
|
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err)
|
return chainError("마켓 등록에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -351,14 +393,14 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
|
|||||||
ListingID string `json:"listingId"`
|
ListingID string `json:"listingId"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if !validID(req.ListingID) {
|
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)
|
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err)
|
return chainError("마켓 구매에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -386,14 +428,14 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
|
|||||||
ListingID string `json:"listingId"`
|
ListingID string `json:"listingId"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if !validID(req.ListingID) {
|
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)
|
result, err := h.svc.CancelListing(userID, req.ListingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err)
|
return chainError("마켓 취소에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -422,14 +464,14 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
|
|||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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) {
|
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)
|
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err)
|
return chainError("장착에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -457,14 +499,14 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
|
|||||||
AssetID string `json:"assetId"`
|
AssetID string `json:"assetId"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if !validID(req.AssetID) {
|
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)
|
result, err := h.svc.UnequipItem(userID, req.AssetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err)
|
return chainError("장착 해제에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -493,14 +535,14 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
|
|||||||
Properties map[string]any `json:"properties"`
|
Properties map[string]any `json:"properties"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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) {
|
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)
|
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
|
return chainError("에셋 발행에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusCreated).JSON(result)
|
return c.Status(fiber.StatusCreated).JSON(result)
|
||||||
}
|
}
|
||||||
@@ -527,14 +569,14 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
|
|||||||
Assets []core.MintAssetPayload `json:"assets"`
|
Assets []core.MintAssetPayload `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if !validID(req.RecipientPubKey) {
|
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)
|
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
|
return chainError("보상 지급에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusCreated).JSON(result)
|
return c.Status(fiber.StatusCreated).JSON(result)
|
||||||
}
|
}
|
||||||
@@ -562,14 +604,14 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
|
|||||||
Tradeable bool `json:"tradeable"`
|
Tradeable bool `json:"tradeable"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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) {
|
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)
|
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err)
|
return chainError("템플릿 등록에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusCreated).JSON(result)
|
return c.Status(fiber.StatusCreated).JSON(result)
|
||||||
}
|
}
|
||||||
@@ -596,14 +638,14 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
|
|||||||
Assets []core.MintAssetPayload `json:"assets"`
|
Assets []core.MintAssetPayload `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return apperror.ErrBadRequest
|
||||||
}
|
}
|
||||||
if !validID(req.Username) {
|
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)
|
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
|
return chainError("보상 지급에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusCreated).JSON(result)
|
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"`
|
Properties map[string]any `json:"properties"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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) {
|
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)
|
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
|
return chainError("에셋 발행에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusCreated).JSON(result)
|
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 {
|
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
|
||||||
username := c.Query("username")
|
username := c.Query("username")
|
||||||
if !validID(username) {
|
if !validID(username) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
return apperror.BadRequest("username은 필수입니다")
|
||||||
}
|
}
|
||||||
result, err := h.svc.GetBalanceByUsername(username)
|
result, err := h.svc.GetBalanceByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
|
return chainError("잔액 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
@@ -679,12 +721,12 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
|
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
|
||||||
username := c.Query("username")
|
username := c.Query("username")
|
||||||
if !validID(username) {
|
if !validID(username) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
return apperror.BadRequest("username은 필수입니다")
|
||||||
}
|
}
|
||||||
offset, limit := parsePagination(c)
|
offset, limit := parsePagination(c)
|
||||||
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
|
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
return chainError("에셋 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
@@ -704,11 +746,11 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
|
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
|
||||||
username := c.Query("username")
|
username := c.Query("username")
|
||||||
if !validID(username) {
|
if !validID(username) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
return apperror.BadRequest("username은 필수입니다")
|
||||||
}
|
}
|
||||||
result, err := h.svc.GetInventoryByUsername(username)
|
result, err := h.svc.GetInventoryByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
|
return chainError("인벤토리 조회에 실패했습니다", err)
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Send(result)
|
return c.Send(result)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tolelom/tolchain/core"
|
"github.com/tolelom/tolchain/core"
|
||||||
tocrypto "github.com/tolelom/tolchain/crypto"
|
tocrypto "github.com/tolelom/tolchain/crypto"
|
||||||
@@ -174,6 +175,17 @@ func (s *Service) getNonce(address string) (uint64, error) {
|
|||||||
return bal.Nonce, nil
|
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 ----
|
// ---- Query Methods ----
|
||||||
|
|
||||||
func (s *Service) GetBalance(userID uint) (*BalanceResult, error) {
|
func (s *Service) GetBalance(userID uint) (*BalanceResult, error) {
|
||||||
@@ -220,7 +232,9 @@ func (s *Service) getUserMu(userID uint) *sync.Mutex {
|
|||||||
|
|
||||||
// ---- User Transaction Methods ----
|
// ---- 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 := s.getUserMu(userID)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
@@ -232,125 +246,53 @@ func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tx, err := w.Transfer(s.chainID, to, amount, nonce, 0)
|
tx, err := buildFn(w, nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build tx failed: %w", err)
|
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) {
|
func (s *Service) Transfer(userID uint, to string, amount uint64) (*TxStatusResult, error) {
|
||||||
mu := s.getUserMu(userID)
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
mu.Lock()
|
return w.Transfer(s.chainID, to, amount, nonce, 0)
|
||||||
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) ListOnMarket(userID uint, assetID string, price uint64) (*SendTxResult, error) {
|
func (s *Service) TransferAsset(userID uint, assetID, to string) (*TxStatusResult, error) {
|
||||||
mu := s.getUserMu(userID)
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
mu.Lock()
|
return w.TransferAsset(s.chainID, assetID, to, nonce, 0)
|
||||||
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) BuyFromMarket(userID uint, listingID string) (*SendTxResult, error) {
|
func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*TxStatusResult, error) {
|
||||||
mu := s.getUserMu(userID)
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
mu.Lock()
|
return w.ListMarket(s.chainID, assetID, price, nonce, 0)
|
||||||
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) CancelListing(userID uint, listingID string) (*SendTxResult, error) {
|
func (s *Service) BuyFromMarket(userID uint, listingID string) (*TxStatusResult, error) {
|
||||||
mu := s.getUserMu(userID)
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
mu.Lock()
|
return w.BuyMarket(s.chainID, listingID, nonce, 0)
|
||||||
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) EquipItem(userID uint, assetID, slot string) (*SendTxResult, error) {
|
func (s *Service) CancelListing(userID uint, listingID string) (*TxStatusResult, error) {
|
||||||
mu := s.getUserMu(userID)
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
mu.Lock()
|
return w.CancelListing(s.chainID, listingID, nonce, 0)
|
||||||
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) UnequipItem(userID uint, assetID string) (*SendTxResult, error) {
|
func (s *Service) EquipItem(userID uint, assetID, slot string) (*TxStatusResult, error) {
|
||||||
mu := s.getUserMu(userID)
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
mu.Lock()
|
return w.EquipItem(s.chainID, assetID, slot, nonce, 0)
|
||||||
defer mu.Unlock()
|
})
|
||||||
w, pubKey, err := s.loadUserWallet(userID)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
func (s *Service) UnequipItem(userID uint, assetID string) (*TxStatusResult, error) {
|
||||||
}
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
||||||
nonce, err := s.getNonce(pubKey)
|
return w.UnequipItem(s.chainID, assetID, nonce, 0)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Operator Transaction Methods ----
|
// ---- Operator Transaction Methods ----
|
||||||
@@ -369,7 +311,9 @@ func (s *Service) getOperatorNonce() (uint64, error) {
|
|||||||
return s.getNonce(s.operatorWallet.PubKey())
|
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()
|
s.operatorMu.Lock()
|
||||||
defer s.operatorMu.Unlock()
|
defer s.operatorMu.Unlock()
|
||||||
if err := s.ensureOperator(); err != nil {
|
if err := s.ensureOperator(); err != nil {
|
||||||
@@ -379,50 +323,34 @@ func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tx, err := s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
|
tx, err := buildFn(nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build tx failed: %w", err)
|
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) {
|
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*TxStatusResult, error) {
|
||||||
s.operatorMu.Lock()
|
return s.operatorTx(func(nonce uint64) (any, error) {
|
||||||
defer s.operatorMu.Unlock()
|
return s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
|
||||||
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) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*SendTxResult, error) {
|
func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
|
||||||
s.operatorMu.Lock()
|
return s.operatorTx(func(nonce uint64) (any, error) {
|
||||||
defer s.operatorMu.Unlock()
|
return s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0)
|
||||||
if err := s.ensureOperator(); err != nil {
|
})
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
nonce, err := s.getOperatorNonce()
|
func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*TxStatusResult, error) {
|
||||||
if err != nil {
|
return s.operatorTx(func(nonce uint64) (any, error) {
|
||||||
return nil, err
|
return s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0)
|
||||||
}
|
})
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Username-based Methods (for game server) ----
|
// ---- 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)
|
pubKey, err := s.resolveUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -430,7 +358,7 @@ func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, ass
|
|||||||
return s.GrantReward(pubKey, tokenAmount, assets)
|
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)
|
pubKey, err := s.resolveUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -232,3 +232,40 @@ func TestResolveUsername_NoResolver(t *testing.T) {
|
|||||||
t.Error("resolveUsername should fail when userResolver is nil")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ func NewHandler(svc *Service, baseURL string) *Handler {
|
|||||||
func (h *Handler) GetInfo(c *fiber.Ctx) error {
|
func (h *Handler) GetInfo(c *fiber.Ctx) error {
|
||||||
info, err := h.svc.GetInfo()
|
info, err := h.svc.GetInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "다운로드 정보가 없습니다"})
|
return apperror.NotFound("다운로드 정보가 없습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(info)
|
return c.JSON(info)
|
||||||
}
|
}
|
||||||
@@ -54,17 +56,17 @@ func (h *Handler) Upload(c *fiber.Ctx) error {
|
|||||||
// 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용
|
// 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용
|
||||||
filename = filepath.Base(filename)
|
filename = filepath.Base(filename)
|
||||||
if !strings.HasSuffix(strings.ToLower(filename), ".zip") {
|
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 {
|
if len(filename) > 200 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "파일명이 너무 깁니다"})
|
return apperror.BadRequest("파일명이 너무 깁니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
body := c.Request().BodyStream()
|
body := c.Request().BodyStream()
|
||||||
info, err := h.svc.Upload(filename, body, h.baseURL)
|
info, err := h.svc.Upload(filename, body, h.baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("game upload failed: %v", err)
|
log.Printf("game upload failed: %v", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "게임 파일 업로드에 실패했습니다"})
|
return apperror.Internal("게임 파일 업로드에 실패했습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(info)
|
return c.JSON(info)
|
||||||
}
|
}
|
||||||
@@ -80,7 +82,7 @@ func (h *Handler) Upload(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) ServeFile(c *fiber.Ctx) error {
|
func (h *Handler) ServeFile(c *fiber.Ctx) error {
|
||||||
path := h.svc.GameFilePath()
|
path := h.svc.GameFilePath()
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"})
|
return apperror.NotFound("파일이 없습니다")
|
||||||
}
|
}
|
||||||
info, _ := h.svc.GetInfo()
|
info, _ := h.svc.GetInfo()
|
||||||
filename := "game.zip"
|
filename := "game.zip"
|
||||||
@@ -108,7 +110,7 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
|
|||||||
info, err := h.svc.UploadLauncher(body, h.baseURL)
|
info, err := h.svc.UploadLauncher(body, h.baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("launcher upload failed: %v", err)
|
log.Printf("launcher upload failed: %v", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "런처 업로드에 실패했습니다"})
|
return apperror.Internal("런처 업로드에 실패했습니다")
|
||||||
}
|
}
|
||||||
return c.JSON(info)
|
return c.JSON(info)
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) ServeLauncher(c *fiber.Ctx) error {
|
func (h *Handler) ServeLauncher(c *fiber.Ctx) error {
|
||||||
path := h.svc.LauncherFilePath()
|
path := h.svc.LauncherFilePath()
|
||||||
if _, err := os.Stat(path); err != nil {
|
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"`)
|
c.Set("Content-Disposition", `attachment; filename="launcher.exe"`)
|
||||||
return c.SendFile(path)
|
return c.SendFile(path)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,12 +31,12 @@ func NewHandler(svc *Service) *Handler {
|
|||||||
func (h *Handler) GetProfile(c *fiber.Ctx) error {
|
func (h *Handler) GetProfile(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(uint)
|
userID, ok := c.Locals("userID").(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
|
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := h.svc.GetProfile(userID)
|
profile, err := h.svc.GetProfile(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
|
return apperror.NotFound(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(profileWithNextExp(profile))
|
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 {
|
func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(uint)
|
userID, ok := c.Locals("userID").(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
|
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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)
|
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||||
if req.Nickname != "" {
|
if req.Nickname != "" {
|
||||||
nicknameRunes := []rune(req.Nickname)
|
nicknameRunes := []rune(req.Nickname)
|
||||||
if len(nicknameRunes) < 2 || len(nicknameRunes) > 30 {
|
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 {
|
for _, r := range nicknameRunes {
|
||||||
if unicode.IsControl(r) {
|
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)
|
profile, err := h.svc.UpdateProfile(userID, req.Nickname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("프로필 수정 실패 (userID=%d): %v", userID, err)
|
log.Printf("프로필 수정 실패 (userID=%d): %v", userID, err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"})
|
return apperror.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(profile)
|
return c.JSON(profile)
|
||||||
@@ -102,12 +104,12 @@ func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
|
|||||||
func (h *Handler) InternalGetProfile(c *fiber.Ctx) error {
|
func (h *Handler) InternalGetProfile(c *fiber.Ctx) error {
|
||||||
username := c.Query("username")
|
username := c.Query("username")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"})
|
return apperror.BadRequest("username 파라미터가 필요합니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := h.svc.GetProfileByUsername(username)
|
profile, err := h.svc.GetProfileByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
|
return apperror.NotFound(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(profileWithNextExp(profile))
|
return c.JSON(profileWithNextExp(profile))
|
||||||
@@ -157,18 +159,18 @@ func profileWithNextExp(p *PlayerProfile) fiber.Map {
|
|||||||
func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error {
|
func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error {
|
||||||
username := c.Query("username")
|
username := c.Query("username")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"})
|
return apperror.BadRequest("username 파라미터가 필요합니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req GameDataRequest
|
var req GameDataRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
if err := h.svc.SaveGameDataByUsername(username, &req); err != nil {
|
||||||
// Username from internal API (ServerAuth protected) — low risk of injection
|
// Username from internal API (ServerAuth protected) — low risk of injection
|
||||||
log.Printf("게임 데이터 저장 실패 (username=%s): %v", username, err)
|
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": "게임 데이터가 저장되었습니다"})
|
return c.JSON(fiber.Map{"message": "게임 데이터가 저장되었습니다"})
|
||||||
|
|||||||
542
internal/player/service_test.go
Normal file
542
internal/player/service_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
main.go
23
main.go
@@ -18,6 +18,7 @@ import (
|
|||||||
_ "a301_server/docs" // swagger docs
|
_ "a301_server/docs" // swagger docs
|
||||||
|
|
||||||
"github.com/tolelom/tolchain/core"
|
"github.com/tolelom/tolchain/core"
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
"a301_server/pkg/config"
|
"a301_server/pkg/config"
|
||||||
"a301_server/pkg/database"
|
"a301_server/pkg/database"
|
||||||
"a301_server/pkg/metrics"
|
"a301_server/pkg/metrics"
|
||||||
@@ -56,7 +57,7 @@ func main() {
|
|||||||
log.Println("MySQL 연결 성공")
|
log.Println("MySQL 연결 성공")
|
||||||
|
|
||||||
// AutoMigrate
|
// 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)
|
log.Fatalf("AutoMigrate 실패: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ func main() {
|
|||||||
return c.IP()
|
return c.IP()
|
||||||
},
|
},
|
||||||
LimitReached: func(c *fiber.Ctx) error {
|
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()
|
return c.IP()
|
||||||
},
|
},
|
||||||
LimitReached: func(c *fiber.Ctx) error {
|
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()
|
return "chain_ip:" + c.IP()
|
||||||
},
|
},
|
||||||
LimitReached: func(c *fiber.Ctx) error {
|
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
|
// Graceful shutdown
|
||||||
go func() {
|
go func() {
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
log.Printf("수신된 시그널: %v — 서버 종료 중...", sig)
|
log.Printf("수신된 시그널: %v — 서버 종료 중...", sig)
|
||||||
|
rewardWorker.Stop()
|
||||||
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
|
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
|
||||||
log.Printf("서버 종료 실패: %v", err)
|
log.Printf("서버 종료 실패: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package apperror
|
|||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// AppError is a structured application error with an HTTP status code.
|
// AppError is a structured application error with an HTTP status code.
|
||||||
|
// JSON response format: {"error": "<code>", "message": "<human-readable message>"}
|
||||||
type AppError struct {
|
type AppError struct {
|
||||||
Code string `json:"error"`
|
Code string `json:"error"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -23,11 +24,36 @@ func Wrap(code string, message string, status int, cause error) *AppError {
|
|||||||
|
|
||||||
// Common errors
|
// Common errors
|
||||||
var (
|
var (
|
||||||
|
ErrBadRequest = &AppError{Code: "bad_request", Message: "잘못된 요청입니다", Status: 400}
|
||||||
ErrUnauthorized = &AppError{Code: "unauthorized", Message: "인증이 필요합니다", Status: 401}
|
ErrUnauthorized = &AppError{Code: "unauthorized", Message: "인증이 필요합니다", Status: 401}
|
||||||
ErrForbidden = &AppError{Code: "forbidden", Message: "권한이 없습니다", Status: 403}
|
ErrForbidden = &AppError{Code: "forbidden", Message: "권한이 없습니다", Status: 403}
|
||||||
ErrNotFound = &AppError{Code: "not_found", Message: "리소스를 찾을 수 없습니다", Status: 404}
|
ErrNotFound = &AppError{Code: "not_found", Message: "리소스를 찾을 수 없습니다", Status: 404}
|
||||||
ErrConflict = &AppError{Code: "conflict", Message: "이미 존재합니다", Status: 409}
|
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}
|
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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
"a301_server/pkg/config"
|
"a301_server/pkg/config"
|
||||||
"a301_server/pkg/database"
|
"a301_server/pkg/database"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
func Auth(c *fiber.Ctx) error {
|
func Auth(c *fiber.Ctx) error {
|
||||||
header := c.Get("Authorization")
|
header := c.Get("Authorization")
|
||||||
if !strings.HasPrefix(header, "Bearer ") {
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증이 필요합니다"})
|
return apperror.ErrUnauthorized
|
||||||
}
|
}
|
||||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||||
|
|
||||||
@@ -27,24 +28,24 @@ func Auth(c *fiber.Ctx) error {
|
|||||||
return []byte(config.C.JWTSecret), nil
|
return []byte(config.C.JWTSecret), nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
return apperror.Unauthorized("유효하지 않은 토큰입니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
return apperror.Unauthorized("유효하지 않은 토큰입니다")
|
||||||
}
|
}
|
||||||
userIDFloat, ok := claims["user_id"].(float64)
|
userIDFloat, ok := claims["user_id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
return apperror.Unauthorized("유효하지 않은 토큰입니다")
|
||||||
}
|
}
|
||||||
username, ok := claims["username"].(string)
|
username, ok := claims["username"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
return apperror.Unauthorized("유효하지 않은 토큰입니다")
|
||||||
}
|
}
|
||||||
role, ok := claims["role"].(string)
|
role, ok := claims["role"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
return apperror.Unauthorized("유효하지 않은 토큰입니다")
|
||||||
}
|
}
|
||||||
userID := uint(userIDFloat)
|
userID := uint(userIDFloat)
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ func Auth(c *fiber.Ctx) error {
|
|||||||
key := fmt.Sprintf("session:%d", userID)
|
key := fmt.Sprintf("session:%d", userID)
|
||||||
stored, err := database.RDB.Get(ctx, key).Result()
|
stored, err := database.RDB.Get(ctx, key).Result()
|
||||||
if err != nil || stored != tokenStr {
|
if err != nil || stored != tokenStr {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "만료되었거나 로그아웃된 세션입니다"})
|
return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("userID", userID)
|
c.Locals("userID", userID)
|
||||||
@@ -65,7 +66,7 @@ func Auth(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func AdminOnly(c *fiber.Ctx) error {
|
func AdminOnly(c *fiber.Ctx) error {
|
||||||
if c.Locals("role") != "admin" {
|
if c.Locals("role") != "admin" {
|
||||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "관리자 권한이 필요합니다"})
|
return apperror.ErrForbidden
|
||||||
}
|
}
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ func ServerAuth(c *fiber.Ctx) error {
|
|||||||
expected := config.C.InternalAPIKey
|
expected := config.C.InternalAPIKey
|
||||||
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 {
|
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 {
|
||||||
log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path())
|
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()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"a301_server/pkg/apperror"
|
||||||
"a301_server/pkg/database"
|
"a301_server/pkg/database"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -23,9 +24,7 @@ type cachedResponse struct {
|
|||||||
// then delegates to Idempotency for cache/replay logic.
|
// then delegates to Idempotency for cache/replay logic.
|
||||||
func IdempotencyRequired(c *fiber.Ctx) error {
|
func IdempotencyRequired(c *fiber.Ctx) error {
|
||||||
if c.Get("Idempotency-Key") == "" {
|
if c.Get("Idempotency-Key") == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다")
|
||||||
"error": "Idempotency-Key 헤더가 필요합니다",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return Idempotency(c)
|
return Idempotency(c)
|
||||||
}
|
}
|
||||||
@@ -38,7 +37,7 @@ func Idempotency(c *fiber.Ctx) error {
|
|||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
if len(key) > 256 {
|
if len(key) > 256 {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Idempotency-Key가 너무 깁니다"})
|
return apperror.BadRequest("Idempotency-Key가 너무 깁니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
// userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지
|
// userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지
|
||||||
@@ -66,10 +65,10 @@ func Idempotency(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
cached, err := database.RDB.Get(getCtx, redisKey).Bytes()
|
cached, err := database.RDB.Get(getCtx, redisKey).Bytes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"})
|
return apperror.Conflict("요청이 처리 중입니다")
|
||||||
}
|
}
|
||||||
if string(cached) == "processing" {
|
if string(cached) == "processing" {
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"})
|
return apperror.Conflict("요청이 처리 중입니다")
|
||||||
}
|
}
|
||||||
var cr cachedResponse
|
var cr cachedResponse
|
||||||
if json.Unmarshal(cached, &cr) == nil {
|
if json.Unmarshal(cached, &cr) == nil {
|
||||||
@@ -77,7 +76,7 @@ func Idempotency(c *fiber.Ctx) error {
|
|||||||
c.Set("X-Idempotent-Replay", "true")
|
c.Set("X-Idempotent-Replay", "true")
|
||||||
return c.Status(cr.StatusCode).Send(cr.Body)
|
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
|
// We claimed the key — process the request
|
||||||
|
|||||||
Reference in New Issue
Block a user