diff --git a/CLAUDE.md b/CLAUDE.md index e9ce97d..eb0c94b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ # CLAUDE.md +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + ## Commands ```bash @@ -10,47 +12,102 @@ docker build -t a301-server . # Docker 빌드 ## Tech Stack -- **Go** + **Fiber v2** (StreamRequestBody: true — 대용량 업로드용) +- **Go** + **Fiber v2** (StreamRequestBody: true — 대용량 업로드용, Body Limit 4GB) - **GORM** + **MySQL** (AutoMigrate 사용) -- **Redis** — JWT 블랙리스트 -- **JWT** — `golang-jwt/jwt v5` +- **Redis** — JWT 세션 저장(`session:{userID}`, `refresh:{userID}`) + 멱등성 캐시 +- **JWT** — `golang-jwt/jwt v5`, Access + Refresh 토큰 로테이션 ## Project Purpose "One of the plans" 게임 플랫폼 백엔드. -인증 / 공지사항 / 게임 파일(game.zip + launcher.exe) 업로드·서빙 담당. +인증 / 공지사항 / 게임 파일 업로드·서빙 / 블록체인(TOL Chain) 연동 담당. ## Project Structure ``` internal/ -├── auth/ # User 모델, JWT 발급·검증, 블랙리스트(Redis) +├── auth/ # User 모델, JWT 발급·검증, 세션 관리(Redis) ├── announcement/ # Announcement CRUD -└── download/ # Info 조회, 파일 업로드(스트리밍), 파일 서빙 +├── download/ # Info 조회, 파일 업로드(스트리밍), 파일 서빙 +└── chain/ # 블록체인 지갑·거래·마켓·인벤토리 (5파일: handler, service, repository, model, client) pkg/ ├── config/ # 환경변수 → Config 구조체 ├── database/ # ConnectMySQL(), ConnectRedis() -└── middleware/ # Auth (JWT 검증), AdminOnly +└── middleware/ # Auth, AdminOnly, ServerAuth, Idempotency routes/routes.go # 모든 라우트 등록 ``` ## Key Patterns -- **계층 구조**: `Handler → Service → Repository`. 각 도메인 폴더에 4파일(handler, service, repository, model). +- **계층 구조**: `Handler → Service → Repository`. 각 도메인 폴더에 4~5파일. - **파일 업로드**: `Fiber StreamRequestBody: true` + `io.Copy`로 raw body를 직접 디스크에 스트리밍. 메모리에 파일 올리지 않음. - **SHA256 자동 추출**: 게임 zip 업로드 시 zip 내 `A301.exe`를 스트리밍으로 읽어 해시 계산. -- **CORS**: `AllowMethods`에 `PATCH` 포함 필수 (유저 권한 변경 엔드포인트). +- **CORS**: `AllowMethods`에 `PATCH` 포함 필수 (유저 권한 변경 엔드포인트). `AllowOrigins`: `https://a301.tolelom.xyz`. - **초기 admin 계정**: 서버 시작 시 `EnsureAdmin()`으로 존재 확인 후 없으면 생성. +- **DI 패턴**: `main.go`에서 생성자 함수로 Repo → Service → Handler 주입. +- **콜백 연결**: `authSvc.SetWalletCreator()` — 회원가입 시 자동 지갑 생성. `chainSvc.SetUserResolver()` — username → userID 변환. + +## Middleware + +- **Auth**: `Authorization: Bearer ` 검증 + Redis 세션 확인. `c.Locals("userID", "username", "role")` 설정. +- **AdminOnly**: `role == "admin"` 확인, 아니면 403. +- **ServerAuth**: `X-API-Key` 헤더 검증. 게임 서버 → API 서버 내부 통신용. +- **Idempotency**: `Idempotency-Key` 헤더로 중복 요청 방지. Redis 캐시 TTL 10분. 블록체인 트랜잭션 이중 지출 방지용. + +## JWT 토큰 로테이션 + +- **Access Token**: `JWT_SECRET`으로 서명, 기본 24시간 만료, Redis `session:{userID}`에 저장. +- **Refresh Token**: `REFRESH_SECRET`으로 서명, 7일 만료, Redis `refresh:{userID}`에 저장. +- **Refresh 시**: 이전 토큰 무효화 + 새 Access/Refresh 쌍 발급 (로테이션). +- **Logout**: Redis에서 session + refresh 키 모두 삭제. + +## Rate Limiting + +- Auth 엔드포인트: IP당 10 req/min +- 일반 API: IP당 60 req/min + +## 블록체인 연동 (internal/chain/) + +TOL Chain 노드와 JSON-RPC 2.0 통신. + +- **UserWallet 모델**: ed25519 키페어 생성, 개인키는 AES-256-GCM 암호화 후 DB 저장. +- **client.go**: Chain 노드 RPC 호출 (10초 타임아웃). +- **service.go**: 지갑 생성/암호화, 트랜잭션 서명, 마켓/인벤토리 로직. +- **내부 API**: 게임 서버가 username 기반으로 보상 지급 (`/api/internal/chain/*`). + +## Routes + +**인증**: `POST /api/auth/{register,login,refresh,logout,verify}` (register/login/refresh는 rate limit) + +**유저 관리 (admin)**: `GET /api/users/`, `PATCH /api/users/:id/role`, `DELETE /api/users/:id` + +**공지사항**: `GET /api/announcements/`, `POST|PUT|DELETE /api/announcements/:id` (CUD는 admin) + +**다운로드**: `GET /api/download/{info,file,launcher}`, `POST /api/download/upload/{game,launcher}` (upload는 admin) + +**체인 조회 (JWT)**: `GET /api/chain/{wallet,balance,assets,asset/:id,inventory,market,market/:id}` + +**체인 트랜잭션 (JWT + Idempotency)**: `POST /api/chain/{transfer,asset/transfer,market/list,market/buy,market/cancel,inventory/equip,inventory/unequip}` + +**체인 관리자 (JWT + Admin + Idempotency)**: `POST /api/chain/admin/{mint,reward,template}` + +**내부 API (X-API-Key + Idempotency)**: `POST /api/internal/chain/{reward,mint}`, `GET /api/internal/chain/{balance,assets,inventory}` (username 쿼리 파라미터) ## Environment Variables -필수 운영 설정: ``` +APP_PORT=8080 +DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME +REDIS_ADDR, REDIS_PASSWORD +JWT_SECRET, REFRESH_SECRET, JWT_EXPIRY_HOURS(기본24) +ADMIN_USERNAME, ADMIN_PASSWORD BASE_URL=https://a301.api.tolelom.xyz GAME_DIR=/data/game -JWT_SECRET=<강력한 랜덤값> -ADMIN_USERNAME=admin -ADMIN_PASSWORD=<강력한 비밀번호> +CHAIN_NODE_URL=http://localhost:8545 +CHAIN_ID=tolchain-dev +OPERATOR_KEY_HEX # 오퍼레이터 개인키 (블록체인 트랜잭션 서명용) +WALLET_ENCRYPTION_KEY # 64자 hex = 32바이트 AES-256 키 (지갑 암호화) +INTERNAL_API_KEY # 게임 서버 인증용 API 키 ``` ## File Storage diff --git a/internal/announcement/handler.go b/internal/announcement/handler.go index 2895df6..4694245 100644 --- a/internal/announcement/handler.go +++ b/internal/announcement/handler.go @@ -1,6 +1,11 @@ package announcement -import "github.com/gofiber/fiber/v2" +import ( + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) type Handler struct { svc *Service @@ -34,6 +39,10 @@ func (h *Handler) Create(c *fiber.Ctx) error { } func (h *Handler) Update(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"}) + } var body struct { Title string `json:"title"` Content string `json:"content"` @@ -44,15 +53,25 @@ func (h *Handler) Update(c *fiber.Ctx) error { if body.Title == "" && body.Content == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"}) } - a, err := h.svc.Update(c.Params("id"), body.Title, body.Content) + a, err := h.svc.Update(uint(id), body.Title, body.Content) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + if strings.Contains(err.Error(), "찾을 수 없습니다") { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(a) } func (h *Handler) Delete(c *fiber.Ctx) error { - if err := h.svc.Delete(c.Params("id")); err != nil { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"}) + } + if err := h.svc.Delete(uint(id)); err != nil { + if strings.Contains(err.Error(), "찾을 수 없습니다") { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "삭제에 실패했습니다"}) } return c.SendStatus(fiber.StatusNoContent) diff --git a/internal/announcement/repository.go b/internal/announcement/repository.go index 1e6248a..f3030ce 100644 --- a/internal/announcement/repository.go +++ b/internal/announcement/repository.go @@ -16,10 +16,12 @@ func (r *Repository) FindAll() ([]Announcement, error) { return list, err } -func (r *Repository) FindByID(id string) (*Announcement, error) { +func (r *Repository) FindByID(id uint) (*Announcement, error) { var a Announcement - err := r.db.First(&a, id).Error - return &a, err + if err := r.db.First(&a, id).Error; err != nil { + return nil, err + } + return &a, nil } func (r *Repository) Create(a *Announcement) error { @@ -30,6 +32,6 @@ func (r *Repository) Save(a *Announcement) error { return r.db.Save(a).Error } -func (r *Repository) Delete(id string) error { +func (r *Repository) Delete(id uint) error { return r.db.Delete(&Announcement{}, id).Error } diff --git a/internal/announcement/service.go b/internal/announcement/service.go index 9428a87..7c968a1 100644 --- a/internal/announcement/service.go +++ b/internal/announcement/service.go @@ -19,7 +19,7 @@ func (s *Service) Create(title, content string) (*Announcement, error) { return a, s.repo.Create(a) } -func (s *Service) Update(id, title, content string) (*Announcement, error) { +func (s *Service) Update(id uint, title, content string) (*Announcement, error) { a, err := s.repo.FindByID(id) if err != nil { return nil, fmt.Errorf("공지사항을 찾을 수 없습니다") @@ -33,6 +33,9 @@ func (s *Service) Update(id, title, content string) (*Announcement, error) { return a, s.repo.Save(a) } -func (s *Service) Delete(id string) error { +func (s *Service) Delete(id uint) error { + if _, err := s.repo.FindByID(id); err != nil { + return fmt.Errorf("공지사항을 찾을 수 없습니다") + } return s.repo.Delete(id) } diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 3fe23ca..20aac01 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -1,6 +1,7 @@ package auth import ( + "strconv" "strings" "github.com/gofiber/fiber/v2" @@ -81,7 +82,10 @@ func (h *Handler) Refresh(c *fiber.Ctx) error { } func (h *Handler) Logout(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, ok := c.Locals("userID").(uint) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"}) + } if err := h.svc.Logout(userID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"}) } @@ -97,13 +101,17 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error { } func (h *Handler) UpdateRole(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"}) + } var body struct { Role string `json:"role"` } if err := c.BodyParser(&body); err != nil || (body.Role != "admin" && body.Role != "user") { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "role은 admin 또는 user여야 합니다"}) } - if err := h.svc.UpdateRole(c.Params("id"), Role(body.Role)); err != nil { + if err := h.svc.UpdateRole(uint(id), Role(body.Role)); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "권한 변경에 실패했습니다"}) } return c.JSON(fiber.Map{"message": "권한이 변경되었습니다"}) @@ -154,7 +162,11 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error { } func (h *Handler) DeleteUser(c *fiber.Ctx) error { - if err := h.svc.DeleteUser(c.Params("id")); err != nil { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"}) + } + if err := h.svc.DeleteUser(uint(id)); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 삭제에 실패했습니다"}) } return c.SendStatus(fiber.StatusNoContent) diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 85c999c..89eb405 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -12,8 +12,10 @@ func NewRepository(db *gorm.DB) *Repository { func (r *Repository) FindByUsername(username string) (*User, error) { var user User - err := r.db.Where("username = ?", username).First(&user).Error - return &user, err + if err := r.db.Where("username = ?", username).First(&user).Error; err != nil { + return nil, err + } + return &user, nil } func (r *Repository) Create(user *User) error { @@ -26,22 +28,26 @@ func (r *Repository) FindAll() ([]User, error) { return users, err } -func (r *Repository) FindByID(id string) (*User, error) { +func (r *Repository) FindByID(id uint) (*User, error) { var user User - err := r.db.First(&user, id).Error - return &user, err + if err := r.db.First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil } -func (r *Repository) UpdateRole(id string, role Role) error { +func (r *Repository) UpdateRole(id uint, role Role) error { return r.db.Model(&User{}).Where("id = ?", id).Update("role", role).Error } -func (r *Repository) Delete(id string) error { +func (r *Repository) Delete(id uint) error { return r.db.Delete(&User{}, id).Error } func (r *Repository) FindBySsafyID(ssafyID string) (*User, error) { var user User - err := r.db.Where("ssafy_id = ?", ssafyID).First(&user).Error - return &user, err + if err := r.db.Where("ssafy_id = ?", ssafyID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil } diff --git a/internal/auth/service.go b/internal/auth/service.go index 33fdf65..e8ace8e 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -165,11 +165,11 @@ func (s *Service) GetAllUsers() ([]User, error) { return s.repo.FindAll() } -func (s *Service) UpdateRole(id string, role Role) error { +func (s *Service) UpdateRole(id uint, role Role) error { return s.repo.UpdateRole(id, role) } -func (s *Service) DeleteUser(id string) error { +func (s *Service) DeleteUser(id uint) error { return s.repo.Delete(id) } @@ -192,7 +192,9 @@ func (s *Service) Register(username, password string) error { if s.walletCreator != nil { if err := s.walletCreator(user.ID); err != nil { log.Printf("wallet creation failed for user %d: %v — rolling back", user.ID, err) - s.repo.Delete(fmt.Sprintf("%d", user.ID)) + if delErr := s.repo.Delete(user.ID); delErr != nil { + log.Printf("WARNING: rollback delete also failed for user %d: %v", user.ID, delErr) + } return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요") } } @@ -304,7 +306,9 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use if s.walletCreator != nil { if err := s.walletCreator(user.ID); err != nil { log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", user.ID, err) - s.repo.Delete(fmt.Sprintf("%d", user.ID)) + if delErr := s.repo.Delete(user.ID); delErr != nil { + log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", user.ID, delErr) + } return "", "", nil, fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요") } } diff --git a/internal/chain/handler.go b/internal/chain/handler.go index d7670f3..5b2de37 100644 --- a/internal/chain/handler.go +++ b/internal/chain/handler.go @@ -1,12 +1,15 @@ package chain import ( + "log" "strconv" "github.com/gofiber/fiber/v2" "github.com/tolelom/tolchain/core" ) +const maxLimit = 200 + type Handler struct { svc *Service } @@ -15,10 +18,40 @@ func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } +func getUserID(c *fiber.Ctx) (uint, error) { + uid, ok := c.Locals("userID").(uint) + if !ok { + return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다") + } + return uid, nil +} + +func parsePagination(c *fiber.Ctx) (int, int) { + offset, _ := strconv.Atoi(c.Query("offset", "0")) + limit, _ := strconv.Atoi(c.Query("limit", "50")) + if offset < 0 { + offset = 0 + } + if limit <= 0 { + limit = 50 + } else if limit > maxLimit { + limit = maxLimit + } + return offset, limit +} + +func chainError(c *fiber.Ctx, status int, userMsg string, err error) error { + log.Printf("chain error: %s: %v", userMsg, err) + return c.Status(status).JSON(fiber.Map{"error": userMsg}) +} + // ---- Query Handlers ---- func (h *Handler) GetWalletInfo(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } w, err := h.svc.GetWallet(userID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"}) @@ -30,21 +63,26 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error { } func (h *Handler) GetBalance(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } result, err := h.svc.GetBalance(userID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) GetAssets(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) - offset, _ := strconv.Atoi(c.Query("offset", "0")) - limit, _ := strconv.Atoi(c.Query("limit", "50")) + userID, err := getUserID(c) + if err != nil { + return err + } + offset, limit := parsePagination(c) result, err := h.svc.GetAssets(userID, offset, limit) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -57,28 +95,30 @@ func (h *Handler) GetAsset(c *fiber.Ctx) error { } result, err := h.svc.GetAsset(assetID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) } func (h *Handler) GetInventory(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } result, err := h.svc.GetInventory(userID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) } func (h *Handler) GetMarketListings(c *fiber.Ctx) error { - offset, _ := strconv.Atoi(c.Query("offset", "0")) - limit, _ := strconv.Atoi(c.Query("limit", "50")) + offset, limit := parsePagination(c) result, err := h.svc.GetMarketListings(offset, limit) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -91,7 +131,7 @@ func (h *Handler) GetMarketListing(c *fiber.Ctx) error { } result, err := h.svc.GetListing(listingID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -100,7 +140,10 @@ func (h *Handler) GetMarketListing(c *fiber.Ctx) error { // ---- User Transaction Handlers ---- func (h *Handler) Transfer(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { To string `json:"to"` Amount uint64 `json:"amount"` @@ -113,13 +156,16 @@ func (h *Handler) Transfer(c *fiber.Ctx) error { } result, err := h.svc.Transfer(userID, req.To, req.Amount) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) TransferAsset(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { AssetID string `json:"assetId"` To string `json:"to"` @@ -132,13 +178,16 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error { } result, err := h.svc.TransferAsset(userID, req.AssetID, req.To) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) ListOnMarket(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { AssetID string `json:"assetId"` Price uint64 `json:"price"` @@ -151,13 +200,16 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error { } result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) BuyFromMarket(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { ListingID string `json:"listingId"` } @@ -169,13 +221,16 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error { } result, err := h.svc.BuyFromMarket(userID, req.ListingID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) CancelListing(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { ListingID string `json:"listingId"` } @@ -187,13 +242,16 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error { } result, err := h.svc.CancelListing(userID, req.ListingID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) EquipItem(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { AssetID string `json:"assetId"` Slot string `json:"slot"` @@ -206,13 +264,16 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error { } result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err) } return c.JSON(result) } func (h *Handler) UnequipItem(c *fiber.Ctx) error { - userID := c.Locals("userID").(uint) + userID, err := getUserID(c) + if err != nil { + return err + } var req struct { AssetID string `json:"assetId"` } @@ -224,7 +285,7 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error { } result, err := h.svc.UnequipItem(userID, req.AssetID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err) } return c.JSON(result) } @@ -245,7 +306,7 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error { } result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -264,7 +325,7 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error { } result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -284,7 +345,7 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error { } result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -306,7 +367,7 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error { } result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -326,7 +387,7 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error { } result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err) } return c.Status(fiber.StatusCreated).JSON(result) } @@ -339,7 +400,7 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error { } result, err := h.svc.GetBalanceByUsername(username) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err) } return c.JSON(result) } @@ -350,11 +411,10 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error { if username == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) } - offset, _ := strconv.Atoi(c.Query("offset", "0")) - limit, _ := strconv.Atoi(c.Query("limit", "50")) + offset, limit := parsePagination(c) result, err := h.svc.GetAssetsByUsername(username, offset, limit) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) @@ -368,7 +428,7 @@ func (h *Handler) InternalGetInventory(c *fiber.Ctx) error { } result, err := h.svc.GetInventoryByUsername(username) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err) } c.Set("Content-Type", "application/json") return c.Send(result) diff --git a/internal/chain/repository.go b/internal/chain/repository.go index deaceb8..b6b4a1e 100644 --- a/internal/chain/repository.go +++ b/internal/chain/repository.go @@ -16,12 +16,16 @@ func (r *Repository) Create(w *UserWallet) error { func (r *Repository) FindByUserID(userID uint) (*UserWallet, error) { var w UserWallet - err := r.db.Where("user_id = ?", userID).First(&w).Error - return &w, err + if err := r.db.Where("user_id = ?", userID).First(&w).Error; err != nil { + return nil, err + } + return &w, nil } func (r *Repository) FindByPubKeyHex(pubKeyHex string) (*UserWallet, error) { var w UserWallet - err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error - return &w, err + if err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error; err != nil { + return nil, err + } + return &w, nil } diff --git a/internal/download/service.go b/internal/download/service.go index cadab61..781b535 100644 --- a/internal/download/service.go +++ b/internal/download/service.go @@ -49,7 +49,9 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error) } n, err := io.Copy(f, body) - f.Close() + if closeErr := f.Close(); closeErr != nil && err == nil { + err = closeErr + } if err != nil { os.Remove(tmpPath) return nil, fmt.Errorf("파일 저장 실패: %w", err) @@ -89,7 +91,9 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info } n, err := io.Copy(f, body) - f.Close() + if closeErr := f.Close(); closeErr != nil && err == nil { + err = closeErr + } if err != nil { os.Remove(tmpPath) return nil, fmt.Errorf("파일 저장 실패: %w", err) diff --git a/main.go b/main.go index 76743ad..b058436 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,7 @@ func main() { app.Use(logger.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "https://a301.tolelom.xyz", - AllowHeaders: "Origin, Content-Type, Authorization", + AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key", AllowMethods: "GET, POST, PUT, PATCH, DELETE", })) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index cce2409..4625773 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -47,8 +47,10 @@ func Auth(c *fiber.Ctx) error { userID := uint(userIDFloat) // Redis 세션 확인 + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() key := fmt.Sprintf("session:%d", userID) - stored, err := database.RDB.Get(context.Background(), key).Result() + stored, err := database.RDB.Get(ctx, key).Result() if err != nil || stored != tokenStr { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "만료되었거나 로그아웃된 세션입니다"}) } diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go index d404622..93d5fcb 100644 --- a/pkg/middleware/idempotency.go +++ b/pkg/middleware/idempotency.go @@ -3,6 +3,8 @@ package middleware import ( "context" "encoding/json" + "fmt" + "log" "time" "a301_server/pkg/database" @@ -10,6 +12,7 @@ import ( ) const idempotencyTTL = 10 * time.Minute +const redisTimeout = 5 * time.Second type cachedResponse struct { StatusCode int `json:"s"` @@ -24,8 +27,15 @@ func Idempotency(c *fiber.Ctx) error { return c.Next() } - redisKey := "idempotency:" + key - ctx := context.Background() + // userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지 + redisKey := "idempotency:" + if uid, ok := c.Locals("userID").(uint); ok { + redisKey += fmt.Sprintf("u%d:", uid) + } + redisKey += key + + ctx, cancel := context.WithTimeout(context.Background(), redisTimeout) + defer cancel() // Check if this key was already processed cached, err := database.RDB.Get(ctx, redisKey).Bytes() @@ -48,7 +58,9 @@ func Idempotency(c *fiber.Ctx) error { if status >= 200 && status < 300 { cr := cachedResponse{StatusCode: status, Body: c.Response().Body()} if data, err := json.Marshal(cr); err == nil { - database.RDB.Set(ctx, redisKey, data, idempotencyTTL) + if err := database.RDB.Set(ctx, redisKey, data, idempotencyTTL).Err(); err != nil { + log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err) + } } } diff --git a/routes/routes.go b/routes/routes.go index 2026027..e153c4b 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -26,7 +26,7 @@ func Register( a.Post("/login", authLimiter, authH.Login) a.Post("/refresh", authLimiter, authH.Refresh) a.Post("/logout", middleware.Auth, authH.Logout) - a.Post("/verify", authH.VerifyToken) + a.Post("/verify", authLimiter, authH.VerifyToken) a.Get("/ssafy/login", authH.SSAFYLoginURL) a.Post("/ssafy/callback", authLimiter, authH.SSAFYCallback)