diff --git a/internal/announcement/handler.go b/internal/announcement/handler.go index 4047b61..dcf9522 100644 --- a/internal/announcement/handler.go +++ b/internal/announcement/handler.go @@ -66,10 +66,10 @@ func (h *Handler) Create(c *fiber.Ctx) error { if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Content == "" { return apperror.BadRequest("제목과 내용을 입력해주세요") } - if len(body.Title) > 256 { + if len([]rune(body.Title)) > 256 { return apperror.BadRequest("제목은 256자 이하여야 합니다") } - if len(body.Content) > 10000 { + if len([]rune(body.Content)) > 10000 { return apperror.BadRequest("내용은 10000자 이하여야 합니다") } a, err := h.svc.Create(body.Title, body.Content) @@ -110,10 +110,10 @@ func (h *Handler) Update(c *fiber.Ctx) error { if body.Title == "" && body.Content == "" { return apperror.BadRequest("수정할 내용을 입력해주세요") } - if len(body.Title) > 256 { + if len([]rune(body.Title)) > 256 { return apperror.BadRequest("제목은 256자 이하여야 합니다") } - if len(body.Content) > 10000 { + if len([]rune(body.Content)) > 10000 { return apperror.BadRequest("내용은 10000자 이하여야 합니다") } a, err := h.svc.Update(uint(id), body.Title, body.Content) diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 92a327d..9a28755 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "log" "regexp" "strconv" @@ -56,8 +57,8 @@ func (h *Handler) Register(c *fiber.Ctx) error { return apperror.BadRequest("비밀번호는 72자 이하여야 합니다") } if err := h.svc.Register(req.Username, req.Password); err != nil { - if strings.Contains(err.Error(), "이미 사용 중") { - return apperror.Conflict(err.Error()) + if errors.Is(err, apperror.ErrDuplicateUsername) { + return apperror.Conflict("이미 사용 중인 아이디입니다") } return apperror.Internal("회원가입에 실패했습니다") } @@ -249,6 +250,11 @@ func (h *Handler) UpdateRole(c *fiber.Ctx) error { return apperror.BadRequest("role은 admin 또는 user여야 합니다") } uid := uint(id) + // 자기 자신의 admin 권한 강등 방지 + callerID, _ := c.Locals("userID").(uint) + if uid == callerID && body.Role != "admin" { + return apperror.BadRequest("자신의 관리자 권한을 제거할 수 없습니다") + } if err := h.svc.UpdateRole(uid, Role(body.Role)); err != nil { return apperror.Internal("권한 변경에 실패했습니다") } diff --git a/internal/auth/service.go b/internal/auth/service.go index eb8c349..bd80d00 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" + "a301_server/pkg/apperror" "a301_server/pkg/config" "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" @@ -263,9 +264,6 @@ func (s *Service) RedeemLaunchTicket(ticket string) (string, error) { } func (s *Service) Register(username, password string) error { - if _, err := s.repo.FindByUsername(username); err == nil { - return fmt.Errorf("이미 사용 중인 아이디입니다") - } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("비밀번호 처리에 실패했습니다") @@ -274,6 +272,9 @@ func (s *Service) Register(username, password string) error { return s.repo.Transaction(func(txRepo *Repository) error { user := &User{Username: username, PasswordHash: string(hash), Role: RoleUser} if err := txRepo.Create(user); err != nil { + if apperror.IsDuplicateEntry(err) { + return apperror.ErrDuplicateUsername + } return err } if s.walletCreator != nil { diff --git a/internal/bossraid/handler.go b/internal/bossraid/handler.go index 8e20324..c452f71 100644 --- a/internal/bossraid/handler.go +++ b/internal/bossraid/handler.go @@ -2,6 +2,7 @@ package bossraid import ( "log" + "strings" "a301_server/pkg/apperror" @@ -61,7 +62,11 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error { room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID) if err != nil { - return bossError(fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err) + status := fiber.StatusConflict + if strings.Contains(err.Error(), "이용 가능한") { + status = fiber.StatusServiceUnavailable + } + return bossError(status, "보스 레이드 입장에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index bc696d3..3d76a5c 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -406,6 +406,14 @@ func (s *Service) RequestEntryWithTokens(usernames []string, bossID int) (*BossR tokens, err := s.GenerateEntryTokens(room.SessionName, usernames) if err != nil { + // 토큰 생성 실패 시 방/슬롯 롤백 + log.Printf("입장 토큰 생성 실패, 방/슬롯 롤백: session=%s: %v", room.SessionName, err) + if delErr := s.repo.DeleteRoomBySessionName(room.SessionName); delErr != nil { + log.Printf("롤백 중 방 삭제 실패: %v", delErr) + } + if resetErr := s.repo.ResetRoomSlot(room.SessionName); resetErr != nil { + log.Printf("롤백 중 슬롯 리셋 실패: %v", resetErr) + } return nil, nil, fmt.Errorf("입장 토큰 생성 실패: %w", err) } diff --git a/internal/chain/handler.go b/internal/chain/handler.go index 083302a..fd27708 100644 --- a/internal/chain/handler.go +++ b/internal/chain/handler.go @@ -11,6 +11,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/tolelom/tolchain/core" + "gorm.io/gorm" ) const maxLimit = 200 @@ -116,7 +117,10 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error { } w, err := h.svc.GetWallet(userID) if err != nil { - return apperror.NotFound("지갑을 찾을 수 없습니다") + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperror.NotFound("지갑을 찾을 수 없습니다") + } + return apperror.Internal("지갑 조회에 실패했습니다") } return c.JSON(fiber.Map{ "address": w.Address, @@ -579,6 +583,9 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error { if !validID(req.RecipientPubKey) { return apperror.BadRequest("recipientPubKey는 필수입니다") } + if req.TokenAmount == 0 && len(req.Assets) == 0 { + return apperror.BadRequest("tokenAmount 또는 assets가 필요합니다") + } result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets) if err != nil { return chainError("보상 지급에 실패했습니다", err) @@ -644,12 +651,12 @@ func (h *Handler) ExportWallet(c *fiber.Ctx) error { } var req exportRequest if err := c.BodyParser(&req); err != nil || req.Password == "" { - return c.Status(400).JSON(fiber.Map{"error": "password is required"}) + return apperror.BadRequest("password는 필수입니다") } slog.Warn("wallet export requested", "userID", userID, "ip", c.IP()) privKeyHex, err := h.svc.ExportPrivKey(userID, req.Password) if err != nil { - return c.Status(401).JSON(fiber.Map{"error": "invalid password"}) + return apperror.Unauthorized("비밀번호가 올바르지 않습니다") } return c.JSON(fiber.Map{"privateKey": privKeyHex}) } diff --git a/internal/chain/service.go b/internal/chain/service.go index 609fb1f..04fa659 100644 --- a/internal/chain/service.go +++ b/internal/chain/service.go @@ -14,6 +14,8 @@ import ( "sync" "time" + "a301_server/pkg/apperror" + "github.com/tolelom/tolchain/core" tocrypto "github.com/tolelom/tolchain/crypto" "github.com/tolelom/tolchain/wallet" @@ -69,12 +71,17 @@ func (s *Service) resolveUsername(username string) (string, error) { uw, err := s.repo.FindByUserID(userID) if err != nil { // 지갑이 없으면 자동 생성 시도 - uw, err = s.CreateWallet(userID) - if err != nil { - // unique constraint 위반 — 다른 고루틴이 먼저 생성 완료 - uw, err = s.repo.FindByUserID(userID) - if err != nil { - return "", fmt.Errorf("wallet auto-creation failed: %w", err) + var createErr error + uw, createErr = s.CreateWallet(userID) + if createErr != nil { + if apperror.IsDuplicateEntry(createErr) { + // unique constraint 위반 — 다른 고루틴이 먼저 생성 완료 + uw, err = s.repo.FindByUserID(userID) + if err != nil { + return "", fmt.Errorf("wallet auto-creation failed: %w", err) + } + } else { + return "", fmt.Errorf("wallet auto-creation failed: %w", createErr) } } else { log.Printf("INFO: auto-created wallet for userID=%d (username=%s)", userID, username) diff --git a/internal/download/service.go b/internal/download/service.go index c33688c..1ceced9 100644 --- a/internal/download/service.go +++ b/internal/download/service.go @@ -215,12 +215,17 @@ func hashGameExeFromZip(zipPath string) string { if err != nil { return "" } + lr := io.LimitReader(rc, maxExeSize+1) h := sha256.New() - _, err = io.Copy(h, io.LimitReader(rc, maxExeSize)) + n, err := io.Copy(h, lr) rc.Close() if err != nil { return "" } + if n > maxExeSize { + log.Printf("WARNING: A301.exe exceeds %dMB, hash may be inaccurate", maxExeSize/1024/1024) + return "" + } return hex.EncodeToString(h.Sum(nil)) } } diff --git a/internal/player/service.go b/internal/player/service.go index 15a4099..6fc16a7 100644 --- a/internal/player/service.go +++ b/internal/player/service.go @@ -8,8 +8,8 @@ import ( // validateGameData checks that game data fields are within acceptable ranges. func validateGameData(data *GameDataRequest) error { - if data.Level != nil && (*data.Level < 1 || *data.Level > 999) { - return fmt.Errorf("레벨은 1~999 범위여야 합니다") + if data.Level != nil && (*data.Level < 1 || *data.Level > MaxLevel) { + return fmt.Errorf("레벨은 1~%d 범위여야 합니다", MaxLevel) } if data.Experience != nil && *data.Experience < 0 { return fmt.Errorf("경험치는 0 이상이어야 합니다") diff --git a/internal/server/server.go b/internal/server/server.go index c310cab..5591859 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -40,6 +40,7 @@ func New() *fiber.App { AllowOrigins: config.C.CORSAllowOrigins, AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key, X-Requested-With", AllowMethods: "GET, POST, PUT, PATCH, DELETE", + ExposeHeaders: "X-Request-ID, X-Idempotent-Replay", AllowCredentials: true, })) return app diff --git a/main.go b/main.go index b4f2c35..8703908 100644 --- a/main.go +++ b/main.go @@ -183,29 +183,36 @@ func main() { // ── Graceful shutdown ──────────────────────────────────────────── go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - sig := <-sigCh - log.Printf("수신된 시그널: %v — 서버 종료 중...", sig) - rewardWorker.Stop() - if err := app.ShutdownWithTimeout(10 * time.Second); err != nil { - log.Printf("서버 종료 실패: %v", err) - } - if rdb != nil { - if err := rdb.Close(); err != nil { - log.Printf("Redis 종료 실패: %v", err) - } else { - log.Println("Redis 연결 종료 완료") - } - } - if sqlDB, err := db.DB(); err == nil { - if err := sqlDB.Close(); err != nil { - log.Printf("MySQL 종료 실패: %v", err) - } else { - log.Println("MySQL 연결 종료 완료") - } + if err := app.Listen(":" + config.C.AppPort); err != nil { + log.Printf("서버 Listen 종료: %v", err) } }() - log.Fatal(app.Listen(":" + config.C.AppPort)) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + log.Printf("수신된 시그널: %v — 서버 종료 중...", sig) + + rewardWorker.Stop() + + if err := app.ShutdownWithTimeout(10 * time.Second); err != nil { + log.Printf("서버 종료 실패: %v", err) + } + + if rdb != nil { + if err := rdb.Close(); err != nil { + log.Printf("Redis 종료 실패: %v", err) + } else { + log.Println("Redis 연결 종료 완료") + } + } + if sqlDB, err := db.DB(); err == nil { + if err := sqlDB.Close(); err != nil { + log.Printf("MySQL 종료 실패: %v", err) + } else { + log.Println("MySQL 연결 종료 완료") + } + } + + log.Println("서버 종료 완료") } diff --git a/pkg/apperror/apperror.go b/pkg/apperror/apperror.go index 10e5d1e..4eb38d8 100644 --- a/pkg/apperror/apperror.go +++ b/pkg/apperror/apperror.go @@ -1,6 +1,12 @@ package apperror -import "fmt" +import ( + "errors" + "fmt" + "strings" + + "github.com/go-sql-driver/mysql" +) // AppError is a structured application error with an HTTP status code. // JSON response format: {"error": "", "message": ""} @@ -57,3 +63,15 @@ func Conflict(message string) *AppError { func Internal(message string) *AppError { return &AppError{Code: "internal_error", Message: message, Status: 500} } + +// ErrDuplicateUsername is returned when a username already exists. +var ErrDuplicateUsername = fmt.Errorf("이미 사용 중인 아이디입니다") + +// IsDuplicateEntry checks if a GORM error is a MySQL duplicate key violation (error 1062). +func IsDuplicateEntry(err error) bool { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) { + return mysqlErr.Number == 1062 + } + return strings.Contains(err.Error(), "Duplicate entry") || strings.Contains(err.Error(), "UNIQUE constraint") +} diff --git a/pkg/database/mysql.go b/pkg/database/mysql.go index 6e09383..1a60e74 100644 --- a/pkg/database/mysql.go +++ b/pkg/database/mysql.go @@ -11,7 +11,7 @@ import ( func ConnectMySQL() (*gorm.DB, error) { c := config.C - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=UTC", c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName, ) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go index f4b7bf9..97c6225 100644 --- a/pkg/middleware/idempotency.go +++ b/pkg/middleware/idempotency.go @@ -49,7 +49,7 @@ func Idempotency(rdb *redis.Client) fiber.Handler { if uid, ok := c.Locals("userID").(uint); ok { redisKey += fmt.Sprintf("u%d:", uid) } - redisKey += key + redisKey += c.Method() + ":" + c.Route().Path + ":" + key ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) defer cancel()