Compare commits

...

9 Commits

Author SHA1 Message Date
feb8ec96ad feat: 체인 클라이언트 멀티노드 페일오버 (SPOF 해결)
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 21s
Server CI/CD / deploy (push) Successful in 56s
CHAIN_NODE_URLS 환경변수(쉼표 구분)로 복수 노드 지정 가능.
Client.Call()이 네트워크/HTTP 오류 시 다음 노드로 자동 전환.
RPC 레벨 오류(트랜잭션 실패 등)는 즉시 반환 (페일오버 미적용).
기존 CHAIN_NODE_URL 단일 설정은 하위 호환 유지.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:31:46 +09:00
e187a20e28 ci: Gitea 환경으로 전환 (git.tolelom.xyz 레지스트리, tolchain GitHub 체크아웃)
- Docker registry: ghcr.io → git.tolelom.xyz
- 로그인: GITEA_TOKEN 사용
- tolchain 체크아웃: vars.TOLCHAIN_GITHUB_REPO 로 GitHub에서 가져오기

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:25:16 +09:00
38da7ce57a ci: vet + 커버리지 리포트 + Docker GHCR 빌드/푸시 + SSH 배포 추가
- test job: go vet + go build + go test (coverage.out 아티팩트 업로드)
- docker job: main 머지 시 GHCR 이미지 빌드/푸시 (tolchain 의존성 처리)
- deploy job: SSH로 docker compose pull api && up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:21:13 +09:00
fa03673e9c refactor: main.go 서버 초기화 로직을 internal/server/server.go로 분리
Fiber 앱 설정, 미들웨어, rate limiter를 server 패키지로 추출.
main.go는 DB 연결, DI, 서버 시작, graceful shutdown만 담당.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 17:11:13 +09:00
0dfa744c16 feat: DB DI 전환 + download 하위 호환성 + race condition 수정
- middleware(Auth, Idempotency)를 클로저 팩토리 패턴으로 DI 전환
- database.DB/RDB 전역 변수 제거, ConnectMySQL/Redis 값 반환으로 변경
- download API X-API-Version 헤더 + 하위 호환성 규칙 문서화
- SaveGameData PlayTimeDelta 원자적 UPDATE (race condition 해소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:58:36 +09:00
f4d862b47f 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>
2026-03-18 16:42:03 +09:00
8da2bdab12 ci: GitHub Actions 워크플로우 추가
Go 빌드 + 테스트 자동화 (push/PR on main)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:56:21 +09:00
b16eb6cc7a feat: 에러 처리 표준화 + BossRaid 낙관적 잠금
에러 표준화:
- pkg/apperror — AppError 타입, 7개 sentinel error
- pkg/middleware/error_handler — Fiber ErrorHandler 통합
- 핸들러에서 AppError 반환 시 구조화된 JSON 자동 응답

BossRaid Race Condition:
- 상태 전이 4곳 낙관적 잠금 (UPDATE WHERE status=?)
- TransitionRoomStatus/TransitionRoomStatusMulti 메서드 추가
- ErrStatusConflict sentinel error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:48:28 +09:00
844a5b264b feat: 보안 수정 + Prometheus 메트릭 + 단위 테스트 추가
보안:
- Zip Bomb 방어 (io.LimitReader 100MB)
- Redis Del 에러 로깅 (auth, idempotency)
- 로그인 실패 로그에서 username 제거
- os.Remove 에러 로깅

모니터링:
- Prometheus 메트릭 미들웨어 + /metrics 엔드포인트
- http_requests_total, http_request_duration_seconds 등 4개 메트릭

테스트:
- download (11), chain (10), bossraid (20) = 41개 단위 테스트

기타:
- DB 모델 GORM 인덱스 태그 추가
- launcherHash 필드 + hashFileToHex() 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:37:42 +09:00
36 changed files with 3245 additions and 1187 deletions

96
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# ── 1. 빌드 + 정적 분석 + 테스트 ───────────────────────────────────────────
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# tolchain은 GitHub에 위치 — vars.TOLCHAIN_GITHUB_REPO 에 "owner/tolchain" 형태로 설정
- name: Checkout tolchain from GitHub
uses: actions/checkout@v4
with:
repository: ${{ vars.TOLCHAIN_GITHUB_REPO }}
path: ../tolchain
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test (with coverage)
run: go test ./... -coverprofile=coverage.out -coverpkg=./...
- name: Coverage report
run: go tool cover -func=coverage.out | tail -1
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
# ── 2. Docker 빌드 & Gitea 레지스트리 푸시 (main 머지 시만) ───────────────
docker:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout a301_server
uses: actions/checkout@v4
with:
path: a301_server
# tolchain 없이는 Dockerfile이 빌드되지 않으므로 같은 레벨에 체크아웃
- name: Checkout tolchain from GitHub
uses: actions/checkout@v4
with:
repository: ${{ vars.TOLCHAIN_GITHUB_REPO }}
path: tolchain
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.tolelom.xyz
username: ${{ github.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
file: ./a301_server/Dockerfile
push: true
tags: git.tolelom.xyz/${{ github.repository_owner }}/a301-server:latest
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# ── 3. 서버 배포 ──────────────────────────────────────────────────────────
deploy:
needs: docker
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
export PATH=$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.docker/bin
cd ~/server
docker compose pull api
docker compose up -d api

View File

@@ -5,6 +5,8 @@ import (
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
@@ -37,7 +39,7 @@ func (h *Handler) GetAll(c *fiber.Ctx) error {
}
list, err := h.svc.GetAll(offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항을 불러오지 못했습니다"})
return apperror.Internal("공지사항을 불러오지 못했습니다")
}
return c.JSON(list)
}
@@ -62,17 +64,17 @@ func (h *Handler) Create(c *fiber.Ctx) error {
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Content == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목과 내용을 입력해주세요"})
return apperror.BadRequest("제목과 내용을 입력해주세요")
}
if len(body.Title) > 256 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"})
return apperror.BadRequest("제목은 256자 이하여야 합니다")
}
if len(body.Content) > 10000 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "내용은 10000자 이하여야 합니다"})
return apperror.BadRequest("내용은 10000자 이하여야 합니다")
}
a, err := h.svc.Create(body.Title, body.Content)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항 생성에 실패했습니다"})
return apperror.Internal("공지사항 생성에 실패했습니다")
}
return c.Status(fiber.StatusCreated).JSON(a)
}
@@ -96,31 +98,31 @@ func (h *Handler) Create(c *fiber.Ctx) error {
func (h *Handler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"})
return apperror.BadRequest("유효하지 않은 공지사항 ID입니다")
}
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if body.Title == "" && body.Content == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"})
return apperror.BadRequest("수정할 내용을 입력해주세요")
}
if len(body.Title) > 256 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"})
return apperror.BadRequest("제목은 256자 이하여야 합니다")
}
if len(body.Content) > 10000 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "내용은 10000자 이하여야 합니다"})
return apperror.BadRequest("내용은 10000자 이하여야 합니다")
}
a, err := h.svc.Update(uint(id), body.Title, body.Content)
if err != nil {
if strings.Contains(err.Error(), "찾을 수 없습니다") {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
return apperror.NotFound(err.Error())
}
log.Printf("공지사항 수정 실패 (id=%d): %v", id, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"})
return apperror.ErrInternal
}
return c.JSON(a)
}
@@ -141,13 +143,13 @@ func (h *Handler) Update(c *fiber.Ctx) error {
func (h *Handler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 공지사항 ID입니다"})
return apperror.BadRequest("유효하지 않은 공지사항 ID입니다")
}
if err := h.svc.Delete(uint(id)); err != nil {
if strings.Contains(err.Error(), "찾을 수 없습니다") {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
return apperror.NotFound(err.Error())
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "삭제에 실패했습니다"})
return apperror.Internal("삭제에 실패했습니다")
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -8,7 +8,7 @@ import (
type Announcement struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Title string `json:"title" gorm:"not null"`

View File

@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
@@ -38,26 +40,26 @@ func (h *Handler) Register(c *fiber.Ctx) error {
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Username == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
return apperror.BadRequest("아이디와 비밀번호를 입력해주세요")
}
if !usernameRe.MatchString(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다"})
return apperror.BadRequest("아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다")
}
if len(req.Password) < 6 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 6자 이상이어야 합니다"})
return apperror.BadRequest("비밀번호는 6자 이상이어야 합니다")
}
if len(req.Password) > 72 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 72자 이하여야 합니다"})
return apperror.BadRequest("비밀번호는 72자 이하여야 합니다")
}
if err := h.svc.Register(req.Username, req.Password); err != nil {
if strings.Contains(err.Error(), "이미 사용 중") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
return apperror.Conflict(err.Error())
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "회원가입에 실패했습니다"})
return apperror.Internal("회원가입에 실패했습니다")
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "회원가입이 완료되었습니다"})
}
@@ -79,23 +81,23 @@ func (h *Handler) Login(c *fiber.Ctx) error {
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Username == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
return apperror.BadRequest("아이디와 비밀번호를 입력해주세요")
}
if len(req.Username) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
}
if len(req.Password) > 72 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
}
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
if err != nil {
log.Printf("Login failed (username=%s): %v", req.Username, err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
log.Printf("Login failed: %v", err)
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
}
c.Cookie(&fiber.Cookie{
@@ -137,13 +139,13 @@ func (h *Handler) Refresh(c *fiber.Ctx) error {
}
}
if refreshTokenStr == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refreshToken이 필요합니다"})
return apperror.BadRequest("refreshToken이 필요합니다")
}
newAccessToken, newRefreshToken, err := h.svc.Refresh(refreshTokenStr)
if err != nil {
log.Printf("Refresh failed: %v", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "토큰 갱신에 실패했습니다"})
return apperror.Unauthorized("토큰 갱신에 실패했습니다")
}
c.Cookie(&fiber.Cookie{
@@ -173,10 +175,11 @@ func (h *Handler) Refresh(c *fiber.Ctx) error {
func (h *Handler) Logout(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
if err := h.svc.Logout(userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"})
log.Printf("Logout failed for user %d: %v", userID, err)
return apperror.Internal("로그아웃 처리 중 오류가 발생했습니다")
}
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
@@ -214,7 +217,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
}
users, err := h.svc.GetAllUsers(offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 목록을 불러오지 못했습니다"})
return apperror.Internal("유저 목록을 불러오지 못했습니다")
}
return c.JSON(users)
}
@@ -237,17 +240,17 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
func (h *Handler) UpdateRole(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"})
return apperror.BadRequest("유효하지 않은 유저 ID입니다")
}
var body struct {
Role string `json:"role"`
}
if err := c.BodyParser(&body); err != nil || (body.Role != "admin" && body.Role != "user") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "role은 admin 또는 user여야 합니다"})
return apperror.BadRequest("role은 admin 또는 user여야 합니다")
}
uid := uint(id)
if err := h.svc.UpdateRole(uid, Role(body.Role)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "권한 변경에 실패했습니다"})
return apperror.Internal("권한 변경에 실패했습니다")
}
// 역할 변경 시 기존 세션 무효화 (새 권한으로 재로그인 유도)
_ = h.svc.Logout(uid)
@@ -271,12 +274,12 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error {
Token string `json:"token"`
}
if err := c.BodyParser(&req); err != nil || req.Token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token 필드가 필요합니다"})
return apperror.BadRequest("token 필드가 필요합니다")
}
username, err := h.svc.VerifyToken(req.Token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
return apperror.Unauthorized(err.Error())
}
return c.JSON(fiber.Map{
@@ -295,7 +298,7 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error {
func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error {
loginURL, err := h.svc.GetSSAFYLoginURL()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SSAFY 로그인 URL 생성에 실패했습니다"})
return apperror.Internal("SSAFY 로그인 URL 생성에 실패했습니다")
}
return c.JSON(fiber.Map{"url": loginURL})
}
@@ -317,16 +320,16 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
State string `json:"state"`
}
if err := c.BodyParser(&req); err != nil || req.Code == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "인가 코드가 필요합니다"})
return apperror.BadRequest("인가 코드가 필요합니다")
}
if req.State == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "state 파라미터가 필요합니다"})
return apperror.BadRequest("state 파라미터가 필요합니다")
}
accessToken, refreshToken, user, err := h.svc.SSAFYLogin(req.Code, req.State)
if err != nil {
log.Printf("SSAFY login failed: %v", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "SSAFY 로그인에 실패했습니다"})
return apperror.Unauthorized("SSAFY 로그인에 실패했습니다")
}
c.Cookie(&fiber.Cookie{
@@ -358,11 +361,11 @@ func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
ticket, err := h.svc.CreateLaunchTicket(userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "티켓 발급에 실패했습니다"})
return apperror.Internal("티켓 발급에 실패했습니다")
}
return c.JSON(fiber.Map{"ticket": ticket})
}
@@ -383,12 +386,12 @@ func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error {
Ticket string `json:"ticket"`
}
if err := c.BodyParser(&req); err != nil || req.Ticket == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ticket 필드가 필요합니다"})
return apperror.BadRequest("ticket 필드가 필요합니다")
}
token, err := h.svc.RedeemLaunchTicket(req.Ticket)
if err != nil {
log.Printf("RedeemLaunchTicket failed: %v", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않거나 만료된 티켓입니다"})
return apperror.Unauthorized("유효하지 않거나 만료된 티켓입니다")
}
return c.JSON(fiber.Map{"token": token})
}
@@ -408,10 +411,10 @@ func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error {
func (h *Handler) DeleteUser(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 유저 ID입니다"})
return apperror.BadRequest("유효하지 않은 유저 ID입니다")
}
if err := h.svc.DeleteUser(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 삭제에 실패했습니다"})
return apperror.Internal("유저 삭제에 실패했습니다")
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -20,7 +20,7 @@ type User struct {
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"`
PasswordHash string `json:"-" gorm:"not null"`
Role Role `json:"role" gorm:"default:'user'"`
Role Role `json:"role" gorm:"type:varchar(20);index;default:'user'"`
SsafyID *string `json:"ssafyId,omitempty" gorm:"type:varchar(100);uniqueIndex"`
}

View File

@@ -202,7 +202,9 @@ func (s *Service) DeleteUser(id uint) error {
defer delCancel()
sessionKey := fmt.Sprintf("session:%d", id)
refreshKey := fmt.Sprintf("refresh:%d", id)
s.rdb.Del(delCtx, sessionKey, refreshKey)
if err := s.rdb.Del(delCtx, sessionKey, refreshKey).Err(); err != nil {
log.Printf("WARNING: failed to delete Redis sessions for user %d: %v", id, err)
}
// TODO: Clean up wallet and profile data via cross-service calls
// (walletCreator/profileCreator are creation-only; deletion callbacks are not yet wired up)

View File

@@ -3,6 +3,8 @@ package bossraid
import (
"log"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
@@ -14,9 +16,18 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func bossError(c *fiber.Ctx, status int, userMsg string, err error) error {
func bossError(status int, userMsg string, err error) *apperror.AppError {
log.Printf("bossraid error: %s: %v", userMsg, err)
return c.Status(status).JSON(fiber.Map{"error": userMsg})
code := "internal_error"
switch status {
case 400:
code = "bad_request"
case 404:
code = "not_found"
case 409:
code = "conflict"
}
return apperror.New(code, userMsg, status)
}
// RequestEntry godoc
@@ -37,20 +48,20 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error {
BossID int `json:"bossId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if len(req.Usernames) == 0 || req.BossID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usernames와 bossId는 필수입니다"})
return apperror.BadRequest("usernames와 bossId는 필수입니다")
}
for _, u := range req.Usernames {
if len(u) == 0 || len(u) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 username입니다"})
return apperror.BadRequest("유효하지 않은 username입니다")
}
}
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
if err != nil {
return bossError(c, fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
return bossError(fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
@@ -79,15 +90,15 @@ func (h *Handler) StartRaid(c *fiber.Ctx) error {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
return apperror.BadRequest("sessionName은 필수입니다")
}
room, err := h.svc.StartRaid(req.SessionName)
if err != nil {
return bossError(c, fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
return bossError(fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
}
return c.JSON(fiber.Map{
@@ -115,15 +126,15 @@ func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
Rewards []PlayerReward `json:"rewards"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
return apperror.BadRequest("sessionName은 필수입니다")
}
room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards)
if err != nil {
return bossError(c, fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
return bossError(fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
}
return c.JSON(fiber.Map{
@@ -150,15 +161,15 @@ func (h *Handler) FailRaid(c *fiber.Ctx) error {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
return apperror.BadRequest("sessionName은 필수입니다")
}
room, err := h.svc.FailRaid(req.SessionName)
if err != nil {
return bossError(c, fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
return bossError(fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
}
return c.JSON(fiber.Map{
@@ -185,18 +196,15 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
EntryToken string `json:"entryToken"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.EntryToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "entryToken은 필수입니다"})
return apperror.BadRequest("entryToken은 필수입니다")
}
username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"valid": false,
"error": err.Error(),
})
return apperror.Unauthorized(err.Error())
}
return c.JSON(fiber.Map{
@@ -220,12 +228,12 @@ func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
func (h *Handler) GetRoom(c *fiber.Ctx) error {
sessionName := c.Query("sessionName")
if sessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
return apperror.BadRequest("sessionName은 필수입니다")
}
room, err := h.svc.GetRoom(sessionName)
if err != nil {
return bossError(c, fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
return bossError(fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
}
return c.JSON(room)
@@ -250,15 +258,15 @@ func (h *Handler) RegisterServer(c *fiber.Ctx) error {
MaxRooms int `json:"maxRooms"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.ServerName == "" || req.InstanceID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName과 instanceId는 필수입니다"})
return apperror.BadRequest("serverName과 instanceId는 필수입니다")
}
sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms)
if err != nil {
return bossError(c, fiber.StatusConflict, "서버 등록에 실패했습니다", err)
return bossError(fiber.StatusConflict, "서버 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
@@ -284,14 +292,14 @@ func (h *Handler) Heartbeat(c *fiber.Ctx) error {
InstanceID string `json:"instanceId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.InstanceID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "instanceId는 필수입니다"})
return apperror.BadRequest("instanceId는 필수입니다")
}
if err := h.svc.Heartbeat(req.InstanceID); err != nil {
return bossError(c, fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err)
return bossError(fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err)
}
return c.JSON(fiber.Map{"status": "ok"})
@@ -314,14 +322,14 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
return apperror.BadRequest("sessionName은 필수입니다")
}
if err := h.svc.ResetRoom(req.SessionName); err != nil {
return bossError(c, fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err)
return bossError(fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err)
}
return c.JSON(fiber.Map{"status": "ok", "sessionName": req.SessionName})
@@ -341,12 +349,12 @@ func (h *Handler) ResetRoom(c *fiber.Ctx) error {
func (h *Handler) GetServerStatus(c *fiber.Ctx) error {
serverName := c.Query("serverName")
if serverName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "serverName은 필수입니다"})
return apperror.BadRequest("serverName은 필수입니다")
}
server, slots, err := h.svc.GetServerStatus(serverName)
if err != nil {
return bossError(c, fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
return bossError(fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
}
return c.JSON(fiber.Map{

View File

@@ -1,11 +1,15 @@
package bossraid
import (
"errors"
"time"
"gorm.io/gorm"
)
// ErrStatusConflict indicates that a room's status was already changed by another request.
var ErrStatusConflict = errors.New("방 상태가 이미 변경되었습니다")
type RoomStatus string
const (
@@ -19,7 +23,7 @@ const (
// BossRoom represents a boss raid session room.
type BossRoom struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
@@ -63,11 +67,26 @@ type RoomSlot struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;not null"`
SlotIndex int `json:"slotIndex" gorm:"not null"`
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;uniqueIndex:idx_server_slot;not null"`
SlotIndex int `json:"slotIndex" gorm:"uniqueIndex:idx_server_slot;not null"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
Status SlotStatus `json:"status" gorm:"type:varchar(20);index;default:idle;not null"`
BossRoomID *uint `json:"bossRoomId" gorm:"index"`
InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"`
LastHeartbeat *time.Time `json:"lastHeartbeat"`
}
// RewardFailure records a failed reward grant for later retry.
// A record is "pending" when ResolvedAt is nil and RetryCount < maxRetries (10).
type RewardFailure struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);index;not null"`
Username string `json:"username" gorm:"type:varchar(100);index;not null"`
TokenAmount uint64 `json:"tokenAmount" gorm:"not null"`
Assets string `json:"assets" gorm:"type:text"`
Experience int `json:"experience" gorm:"default:0;not null"`
Error string `json:"error" gorm:"type:text"`
RetryCount int `json:"retryCount" gorm:"default:0;not null"`
ResolvedAt *time.Time `json:"resolvedAt" gorm:"index"`
}

View File

@@ -237,9 +237,95 @@ func (r *Repository) UpdateRoomStatus(sessionName string, status RoomStatus) err
return result.Error
}
// TransitionRoomStatus atomically updates a room's status only if it currently matches expectedStatus.
// Returns ErrStatusConflict if the row was not in the expected state (optimistic locking).
func (r *Repository) TransitionRoomStatus(sessionName string, expectedStatus RoomStatus, newStatus RoomStatus, extras map[string]interface{}) error {
updates := map[string]interface{}{"status": newStatus}
for k, v := range extras {
updates[k] = v
}
result := r.db.Model(&BossRoom{}).
Where("session_name = ? AND status = ?", sessionName, expectedStatus).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrStatusConflict
}
return nil
}
// TransitionRoomStatusMulti atomically updates a room's status only if it currently matches one of the expected statuses.
// Returns ErrStatusConflict if the row was not in any of the expected states.
func (r *Repository) TransitionRoomStatusMulti(sessionName string, expectedStatuses []RoomStatus, newStatus RoomStatus, extras map[string]interface{}) error {
updates := map[string]interface{}{"status": newStatus}
for k, v := range extras {
updates[k] = v
}
result := r.db.Model(&BossRoom{}).
Where("session_name = ? AND status IN ?", sessionName, expectedStatuses).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrStatusConflict
}
return nil
}
// TransitionSlotStatus atomically updates a room slot's status only if it currently matches expectedStatus.
func (r *Repository) TransitionSlotStatus(sessionName string, expectedStatus SlotStatus, newStatus SlotStatus) error {
result := r.db.Model(&RoomSlot{}).
Where("session_name = ? AND status = ?", sessionName, expectedStatus).
Update("status", newStatus)
if result.Error != nil {
return result.Error
}
// Slot transition failures are non-fatal — log but don't block
return nil
}
// GetRoomSlotsByServer returns all room slots for a given server.
func (r *Repository) GetRoomSlotsByServer(serverID uint) ([]RoomSlot, error) {
var slots []RoomSlot
err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error
return slots, err
}
// --- RewardFailure ---
// SaveRewardFailure inserts a new reward failure record.
func (r *Repository) SaveRewardFailure(rf *RewardFailure) error {
return r.db.Create(rf).Error
}
// GetPendingRewardFailures returns unresolved failures that haven't exceeded 10 retries.
func (r *Repository) GetPendingRewardFailures(limit int) ([]RewardFailure, error) {
var failures []RewardFailure
err := r.db.
Where("resolved_at IS NULL AND retry_count < 10").
Order("created_at ASC").
Limit(limit).
Find(&failures).Error
return failures, err
}
// ResolveRewardFailure marks a reward failure as resolved by setting ResolvedAt.
func (r *Repository) ResolveRewardFailure(id uint) error {
now := time.Now()
return r.db.Model(&RewardFailure{}).
Where("id = ?", id).
Update("resolved_at", now).Error
}
// IncrementRetryCount increments the retry count and updates the error message.
func (r *Repository) IncrementRetryCount(id uint, errMsg string) error {
return r.db.Model(&RewardFailure{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"retry_count": gorm.Expr("retry_count + 1"),
"error": errMsg,
}).Error
}

View 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)
}
}

View File

@@ -127,40 +127,27 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
}
// StartRaid marks a room as in_progress and updates the slot status.
// Uses row-level locking to prevent concurrent state transitions.
// Uses optimistic locking (WHERE status = 'waiting') to prevent concurrent state transitions.
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
var resultRoom *BossRoom
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting {
return fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusInProgress
room.StartedAt = &now
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Update slot status to in_progress
slot, err := txRepo.FindRoomSlotBySession(sessionName)
if err == nil {
slot.Status = SlotInProgress
txRepo.UpdateRoomSlot(slot)
}
resultRoom = room
return nil
err := s.repo.TransitionRoomStatus(sessionName, StatusWaiting, StatusInProgress, map[string]interface{}{
"started_at": now,
})
if err != nil {
return nil, err
if err == ErrStatusConflict {
return nil, fmt.Errorf("시작할 수 없는 상태입니다 (이미 변경됨)")
}
return resultRoom, nil
if err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Update slot status to in_progress (non-fatal if fails)
s.repo.TransitionSlotStatus(sessionName, SlotWaiting, SlotInProgress)
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
return room, nil
}
// PlayerReward describes the reward for a single player.
@@ -179,24 +166,19 @@ type RewardResult struct {
}
// CompleteRaid marks a room as completed and grants rewards via blockchain.
// Uses a database transaction with row-level locking to prevent double-completion.
// Uses optimistic locking (WHERE status = 'in_progress') to prevent double-completion.
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
var resultRoom *BossRoom
var resultRewards []RewardResult
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
// Validate reward recipients are room players before transitioning
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusInProgress {
return fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
// Validate reward recipients are room players
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
@@ -204,54 +186,67 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
}
for _, r := range rewards {
if !playerSet[r.Username] {
return fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
}
}
// Mark room completed
// Atomically transition status: in_progress → completed
now := time.Now()
room.Status = StatusCompleted
room.CompletedAt = &now
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
resultRoom = room
return nil
err = s.repo.TransitionRoomStatus(sessionName, StatusInProgress, StatusCompleted, map[string]interface{}{
"completed_at": now,
})
if err == ErrStatusConflict {
return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다 (이미 변경됨)")
}
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Grant rewards outside the transaction to avoid holding the lock during RPC calls
// Re-fetch the updated room
resultRoom, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
// Grant rewards outside the transaction to avoid holding the lock during RPC calls.
// Each reward is attempted up to 3 times with exponential backoff before being
// recorded as a RewardFailure for background retry.
resultRewards = make([]RewardResult, 0, len(rewards))
hasRewardFailure := false
if s.rewardGrant != nil {
for _, r := range rewards {
grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets)
grantErr := s.grantWithRetry(r.Username, r.TokenAmount, r.Assets)
result := RewardResult{Username: r.Username, Success: grantErr == nil}
if grantErr != nil {
result.Error = grantErr.Error()
log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr)
log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr)
hasRewardFailure = true
// 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함
s.saveRewardFailure(sessionName, r, grantErr)
}
resultRewards = append(resultRewards, result)
}
}
// 보상 실패가 있으면 상태를 reward_failed로 업데이트
// 보상 실패가 있으면 상태를 reward_failed로 업데이트 (completed → reward_failed)
if hasRewardFailure {
if err := s.repo.UpdateRoomStatus(sessionName, StatusRewardFailed); err != nil {
if err := s.repo.TransitionRoomStatus(sessionName, StatusCompleted, StatusRewardFailed, nil); err != nil {
log.Printf("보상 실패 상태 업데이트 실패: %s: %v", sessionName, err)
}
}
// Grant experience to players
// Grant experience to players (with retry)
if s.expGrant != nil {
for _, r := range rewards {
if r.Experience > 0 {
if expErr := s.expGrant(r.Username, r.Experience); expErr != nil {
log.Printf("경험치 지급 실패: %s: %v", r.Username, expErr)
expErr := s.grantExpWithRetry(r.Username, r.Experience)
if expErr != nil {
log.Printf("경험치 지급 실패 (재시도 소진): %s: %v", r.Username, expErr)
// 경험치 실패도 RewardFailure에 기록 (토큰/에셋 없이 경험치만)
s.saveRewardFailure(sessionName, PlayerReward{
Username: r.Username,
Experience: r.Experience,
}, expErr)
}
}
}
@@ -266,30 +261,19 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
}
// FailRaid marks a room as failed and resets the slot.
// Uses row-level locking to prevent concurrent state transitions.
// Uses optimistic locking (WHERE status IN ('waiting','in_progress')) to prevent concurrent state transitions.
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
var resultRoom *BossRoom
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting && room.Status != StatusInProgress {
return fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusFailed
room.CompletedAt = &now
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
err := s.repo.TransitionRoomStatusMulti(sessionName,
[]RoomStatus{StatusWaiting, StatusInProgress},
StatusFailed,
map[string]interface{}{"completed_at": now},
)
if err == ErrStatusConflict {
return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다 (이미 변경됨)")
}
resultRoom = room
return nil
})
if err != nil {
return nil, err
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Reset slot to idle so it can accept new raids
@@ -297,7 +281,11 @@ func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
log.Printf("슬롯 리셋 실패 (fail): %s: %v", sessionName, err)
}
return resultRoom, nil
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
return room, nil
}
// GetRoom returns a room by session name.
@@ -495,3 +483,62 @@ func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSl
}
return server, slots, nil
}
// --- Reward retry helpers ---
const immediateRetries = 3
// grantWithRetry attempts the reward grant up to 3 times with backoff (1s, 2s).
func (s *Service) grantWithRetry(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
delays := []time.Duration{1 * time.Second, 2 * time.Second}
var lastErr error
for attempt := 0; attempt < immediateRetries; attempt++ {
lastErr = s.rewardGrant(username, tokenAmount, assets)
if lastErr == nil {
return nil
}
if attempt < len(delays) {
log.Printf("보상 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr)
time.Sleep(delays[attempt])
}
}
return lastErr
}
// grantExpWithRetry attempts the experience grant up to 3 times with backoff (1s, 2s).
func (s *Service) grantExpWithRetry(username string, exp int) error {
delays := []time.Duration{1 * time.Second, 2 * time.Second}
var lastErr error
for attempt := 0; attempt < immediateRetries; attempt++ {
lastErr = s.expGrant(username, exp)
if lastErr == nil {
return nil
}
if attempt < len(delays) {
log.Printf("경험치 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr)
time.Sleep(delays[attempt])
}
}
return lastErr
}
// saveRewardFailure records a failed reward in the DB for background retry.
func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error) {
assets := "[]"
if len(r.Assets) > 0 {
if data, err := json.Marshal(r.Assets); err == nil {
assets = string(data)
}
}
rf := &RewardFailure{
SessionName: sessionName,
Username: r.Username,
TokenAmount: r.TokenAmount,
Assets: assets,
Experience: r.Experience,
Error: grantErr.Error(),
}
if err := s.repo.SaveRewardFailure(rf); err != nil {
log.Printf("보상 실패 기록 저장 실패: %s/%s: %v", sessionName, r.Username, err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,21 +34,55 @@ func (e *rpcError) Error() string {
}
// Client is a JSON-RPC 2.0 client for the TOL Chain node.
// It supports multiple node URLs for failover: on a network/HTTP error the
// client automatically retries against the next URL in the list.
// RPC-level errors (transaction failures, etc.) are returned immediately
// without failover since they indicate a logical error, not node unavailability.
type Client struct {
nodeURL string
nodeURLs []string
http *http.Client
idSeq atomic.Int64
next atomic.Uint64 // round-robin index
}
func NewClient(nodeURL string) *Client {
// NewClient creates a client for one or more chain node URLs.
// When multiple URLs are provided, failed requests fall over to the next URL.
func NewClient(nodeURLs ...string) *Client {
if len(nodeURLs) == 0 {
panic("chain.NewClient: at least one node URL is required")
}
return &Client{
nodeURL: nodeURL,
nodeURLs: nodeURLs,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// Call invokes a JSON-RPC method and unmarshals the result into out.
// On network or HTTP errors it tries each node URL once before giving up.
func (c *Client) Call(method string, params any, out any) error {
n := len(c.nodeURLs)
start := int(c.next.Load() % uint64(n))
var lastErr error
for i := 0; i < n; i++ {
url := c.nodeURLs[(start+i)%n]
err := c.callNode(url, method, params, out)
if err == nil {
return nil
}
// RPC-level error (e.g. tx execution failure): return immediately,
// retrying on another node would give the same result.
if _, isRPC := err.(*rpcError); isRPC {
return err
}
// Network / HTTP error: mark this node as degraded and try the next.
lastErr = err
c.next.Add(1)
}
return fmt.Errorf("all chain nodes unreachable: %w", lastErr)
}
func (c *Client) callNode(nodeURL, method string, params any, out any) error {
reqBody := rpcRequest{
JSONRPC: "2.0",
ID: c.idSeq.Add(1),
@@ -60,14 +94,14 @@ func (c *Client) Call(method string, params any, out any) error {
return fmt.Errorf("marshal RPC request: %w", err)
}
resp, err := c.http.Post(c.nodeURL, "application/json", bytes.NewReader(data))
resp, err := c.http.Post(nodeURL, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("RPC network error: %w", err)
return fmt.Errorf("RPC network error (%s): %w", nodeURL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("RPC HTTP error: status %d", resp.StatusCode)
return fmt.Errorf("RPC HTTP error (%s): status %d", nodeURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
@@ -147,3 +181,80 @@ func (c *Client) SendTx(tx any) (*SendTxResult, error) {
err := c.Call("sendTx", tx, &result)
return &result, err
}
// TxStatusResult mirrors the indexer.TxResult from the TOL Chain node.
type TxStatusResult struct {
TxID string `json:"tx_id"`
BlockHeight int64 `json:"block_height"`
Success bool `json:"success"`
Error string `json:"error"`
}
// GetTxStatus queries the execution result of a transaction.
// Returns nil result (no error) if the transaction has not been included in a block yet.
func (c *Client) GetTxStatus(txID string) (*TxStatusResult, error) {
var result *TxStatusResult
err := c.Call("getTxStatus", map[string]string{"tx_id": txID}, &result)
if err != nil {
return nil, err
}
return result, nil
}
// TxError is returned when a transaction was included in a block but execution failed.
type TxError struct {
TxID string
Message string
}
func (e *TxError) Error() string {
return fmt.Sprintf("transaction %s failed: %s", e.TxID, e.Message)
}
// DefaultTxTimeout is the default timeout for WaitForTx. PoA block intervals
// are typically a few seconds, so 15s provides ample margin.
const DefaultTxTimeout = 15 * time.Second
// SendTxAndWait sends a transaction and waits for block confirmation.
// It combines SendTx + WaitForTx for the common fire-and-confirm pattern.
func (c *Client) SendTxAndWait(tx any, timeout time.Duration) (*TxStatusResult, error) {
sendResult, err := c.SendTx(tx)
if err != nil {
return nil, fmt.Errorf("send tx: %w", err)
}
return c.WaitForTx(sendResult.TxID, timeout)
}
// WaitForTx polls getTxStatus until the transaction is included in a block or
// the timeout is reached. It returns the confirmed TxStatusResult on success,
// a TxError if the transaction executed but failed, or a timeout error.
func (c *Client) WaitForTx(txID string, timeout time.Duration) (*TxStatusResult, error) {
deadline := time.Now().Add(timeout)
interval := 200 * time.Millisecond
for {
result, err := c.GetTxStatus(txID)
if err != nil {
return nil, fmt.Errorf("getTxStatus: %w", err)
}
if result != nil {
if !result.Success {
return result, &TxError{TxID: txID, Message: result.Error}
}
return result, nil
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("transaction %s not confirmed within %s", txID, timeout)
}
time.Sleep(interval)
// Increase interval up to 1s to reduce polling pressure.
if interval < time.Second {
interval = interval * 3 / 2
if interval > time.Second {
interval = time.Second
}
}
}
}

View 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)
}
}

View File

@@ -1,8 +1,12 @@
package chain
import (
"errors"
"log"
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/tolelom/tolchain/core"
@@ -22,7 +26,7 @@ func NewHandler(svc *Service) *Handler {
func getUserID(c *fiber.Ctx) (uint, error) {
uid, ok := c.Locals("userID").(uint)
if !ok {
return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다")
return 0, apperror.ErrUnauthorized
}
return uid, nil
}
@@ -45,9 +49,47 @@ func validID(s string) bool {
return s != "" && len(s) <= maxIDLength
}
func chainError(c *fiber.Ctx, status int, userMsg string, err error) error {
// chainError classifies chain errors into appropriate HTTP responses.
// TxError (on-chain execution failure) maps to 422 with the chain's error detail.
// Other errors (network, timeout, build failures) remain 500.
func chainError(userMsg string, err error) *apperror.AppError {
log.Printf("chain error: %s: %v", userMsg, err)
return c.Status(status).JSON(fiber.Map{"error": userMsg})
var txErr *TxError
if errors.As(err, &txErr) {
msg := classifyTxError(txErr.Message)
return apperror.New("tx_failed", msg, 422)
}
return apperror.Internal(userMsg)
}
// classifyTxError translates raw chain error messages into user-friendly Korean messages.
func classifyTxError(chainMsg string) string {
lower := strings.ToLower(chainMsg)
switch {
case strings.Contains(lower, "insufficient balance"):
return "잔액이 부족합니다"
case strings.Contains(lower, "unauthorized"):
return "권한이 없습니다"
case strings.Contains(lower, "already listed"):
return "이미 마켓에 등록된 아이템입니다"
case strings.Contains(lower, "already exists"):
return "이미 존재합니다"
case strings.Contains(lower, "not found"):
return "리소스를 찾을 수 없습니다"
case strings.Contains(lower, "not tradeable"):
return "거래할 수 없는 아이템입니다"
case strings.Contains(lower, "equipped"):
return "장착 중인 아이템입니다"
case strings.Contains(lower, "not active"):
return "활성 상태가 아닌 매물입니다"
case strings.Contains(lower, "not open"):
return "진행 중이 아닌 세션입니다"
case strings.Contains(lower, "invalid nonce"):
return "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요"
default:
return "블록체인 트랜잭션이 실패했습니다"
}
}
// ---- Query Handlers ----
@@ -69,7 +111,7 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
}
w, err := h.svc.GetWallet(userID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
return apperror.NotFound("지갑을 찾을 수 없습니다")
}
return c.JSON(fiber.Map{
"address": w.Address,
@@ -94,7 +136,7 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error {
}
result, err := h.svc.GetBalance(userID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
return chainError("잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -119,7 +161,7 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
offset, limit := parsePagination(c)
result, err := h.svc.GetAssets(userID, offset, limit)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -140,11 +182,11 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
func (h *Handler) GetAsset(c *fiber.Ctx) error {
assetID := c.Params("id")
if !validID(assetID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 asset id가 필요합니다"})
return apperror.BadRequest("유효한 asset id가 필요합니다")
}
result, err := h.svc.GetAsset(assetID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -167,7 +209,7 @@ func (h *Handler) GetInventory(c *fiber.Ctx) error {
}
result, err := h.svc.GetInventory(userID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
return chainError("인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -188,7 +230,7 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
offset, limit := parsePagination(c)
result, err := h.svc.GetMarketListings(offset, limit)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
return chainError("마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -208,11 +250,11 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
listingID := c.Params("id")
if !validID(listingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 listing id가 필요합니다"})
return apperror.BadRequest("유효한 listing id가 필요합니다")
}
result, err := h.svc.GetListing(listingID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
return chainError("마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -244,14 +286,14 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
Amount uint64 `json:"amount"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.To) || req.Amount == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"})
return apperror.BadRequest("to와 amount는 필수입니다")
}
result, err := h.svc.Transfer(userID, req.To, req.Amount)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err)
return chainError("전송에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -280,14 +322,14 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
To string `json:"to"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || !validID(req.To) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"})
return apperror.BadRequest("assetId와 to는 필수입니다")
}
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err)
return chainError("에셋 전송에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -316,14 +358,14 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
Price uint64 `json:"price"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || req.Price == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"})
return apperror.BadRequest("assetId와 price는 필수입니다")
}
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err)
return chainError("마켓 등록에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -351,14 +393,14 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.ListingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
return apperror.BadRequest("listingId는 필수입니다")
}
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err)
return chainError("마켓 구매에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -386,14 +428,14 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.ListingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
return apperror.BadRequest("listingId는 필수입니다")
}
result, err := h.svc.CancelListing(userID, req.ListingID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err)
return chainError("마켓 취소에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -422,14 +464,14 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
Slot string `json:"slot"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || !validID(req.Slot) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"})
return apperror.BadRequest("assetId와 slot은 필수입니다")
}
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err)
return chainError("장착에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -457,14 +499,14 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
AssetID string `json:"assetId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.AssetID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"})
return apperror.BadRequest("assetId는 필수입니다")
}
result, err := h.svc.UnequipItem(userID, req.AssetID)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err)
return chainError("장착 해제에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -493,14 +535,14 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.TemplateID) || !validID(req.OwnerPubKey) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"})
return apperror.BadRequest("templateId와 ownerPubKey는 필수입니다")
}
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
return chainError("에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -527,14 +569,14 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.RecipientPubKey) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"})
return apperror.BadRequest("recipientPubKey는 필수입니다")
}
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
return chainError("보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -562,14 +604,14 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
Tradeable bool `json:"tradeable"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.ID) || !validID(req.Name) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"})
return apperror.BadRequest("id와 name은 필수입니다")
}
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err)
return chainError("템플릿 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -596,14 +638,14 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
return chainError("보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -628,14 +670,14 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if !validID(req.TemplateID) || !validID(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"})
return apperror.BadRequest("templateId와 username은 필수입니다")
}
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
return chainError("에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -654,11 +696,11 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GetBalanceByUsername(username)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
return chainError("잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -679,12 +721,12 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
offset, limit := parsePagination(c)
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -704,11 +746,11 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GetInventoryByUsername(username)
if err != nil {
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
return chainError("인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)

View File

@@ -10,6 +10,7 @@ import (
"io"
"log"
"sync"
"time"
"github.com/tolelom/tolchain/core"
tocrypto "github.com/tolelom/tolchain/crypto"
@@ -174,6 +175,17 @@ func (s *Service) getNonce(address string) (uint64, error) {
return bal.Nonce, nil
}
// txConfirmTimeout is the maximum time to wait for a transaction to be
// included in a block. PoA block intervals are typically a few seconds,
// so 15s provides ample margin.
const txConfirmTimeout = 15 * time.Second
// submitTx sends a signed transaction and waits for block confirmation.
// Returns the confirmed status or an error (including TxError for on-chain failures).
func (s *Service) submitTx(tx any) (*TxStatusResult, error) {
return s.client.SendTxAndWait(tx, txConfirmTimeout)
}
// ---- Query Methods ----
func (s *Service) GetBalance(userID uint) (*BalanceResult, error) {
@@ -220,7 +232,9 @@ func (s *Service) getUserMu(userID uint) *sync.Mutex {
// ---- User Transaction Methods ----
func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult, error) {
// userTx handles the common boilerplate for user transactions:
// acquire per-user mutex → load wallet → get nonce → build tx → submit.
func (s *Service) userTx(userID uint, buildFn func(w *wallet.Wallet, nonce uint64) (any, error)) (*TxStatusResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
@@ -232,125 +246,53 @@ func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult
if err != nil {
return nil, err
}
tx, err := w.Transfer(s.chainID, to, amount, nonce, 0)
tx, err := buildFn(w, nonce)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
return s.submitTx(tx)
}
func (s *Service) TransferAsset(userID uint, assetID, to string) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.TransferAsset(s.chainID, assetID, to, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) Transfer(userID uint, to string, amount uint64) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.Transfer(s.chainID, to, amount, nonce, 0)
})
}
func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.ListMarket(s.chainID, assetID, price, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) TransferAsset(userID uint, assetID, to string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.TransferAsset(s.chainID, assetID, to, nonce, 0)
})
}
func (s *Service) BuyFromMarket(userID uint, listingID string) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.BuyMarket(s.chainID, listingID, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.ListMarket(s.chainID, assetID, price, nonce, 0)
})
}
func (s *Service) CancelListing(userID uint, listingID string) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.CancelListing(s.chainID, listingID, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) BuyFromMarket(userID uint, listingID string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.BuyMarket(s.chainID, listingID, nonce, 0)
})
}
func (s *Service) EquipItem(userID uint, assetID, slot string) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.EquipItem(s.chainID, assetID, slot, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) CancelListing(userID uint, listingID string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.CancelListing(s.chainID, listingID, nonce, 0)
})
}
func (s *Service) UnequipItem(userID uint, assetID string) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
func (s *Service) EquipItem(userID uint, assetID, slot string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.EquipItem(s.chainID, assetID, slot, nonce, 0)
})
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.UnequipItem(s.chainID, assetID, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) UnequipItem(userID uint, assetID string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.UnequipItem(s.chainID, assetID, nonce, 0)
})
}
// ---- Operator Transaction Methods ----
@@ -369,7 +311,9 @@ func (s *Service) getOperatorNonce() (uint64, error) {
return s.getNonce(s.operatorWallet.PubKey())
}
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*SendTxResult, error) {
// operatorTx handles the common boilerplate for operator transactions:
// acquire operator mutex → ensure operator → get nonce → build tx → submit.
func (s *Service) operatorTx(buildFn func(nonce uint64) (any, error)) (*TxStatusResult, error) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
if err := s.ensureOperator(); err != nil {
@@ -379,50 +323,34 @@ func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[strin
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
tx, err := buildFn(nonce)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
return s.submitTx(tx)
}
func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*SendTxResult, error) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
if err := s.ensureOperator(); err != nil {
return nil, err
}
nonce, err := s.getOperatorNonce()
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*TxStatusResult, error) {
return s.operatorTx(func(nonce uint64) (any, error) {
return s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
})
}
func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*SendTxResult, error) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
if err := s.ensureOperator(); err != nil {
return nil, err
func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
return s.operatorTx(func(nonce uint64) (any, error) {
return s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0)
})
}
nonce, err := s.getOperatorNonce()
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*TxStatusResult, error) {
return s.operatorTx(func(nonce uint64) (any, error) {
return s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0)
})
}
// ---- Username-based Methods (for game server) ----
func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*SendTxResult, error) {
func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
@@ -430,7 +358,7 @@ func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, ass
return s.GrantReward(pubKey, tokenAmount, assets)
}
func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*SendTxResult, error) {
func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*TxStatusResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err

View File

@@ -0,0 +1,271 @@
package chain
import (
"encoding/hex"
"testing"
tocrypto "github.com/tolelom/tolchain/crypto"
)
// testEncKey returns a valid 32-byte AES-256 key for testing.
func testEncKey() []byte {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return key
}
// newTestService creates a minimal Service with only the encryption key set.
// No DB, Redis, or chain client — only suitable for testing pure crypto functions.
func newTestService() *Service {
return &Service{
encKeyBytes: testEncKey(),
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
svc := newTestService()
// Generate a real ed25519 private key
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
// Encrypt
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
if cipherHex == "" || nonceHex == "" {
t.Fatal("encryptPrivKey returned empty strings")
}
// Verify ciphertext is valid hex
if _, err := hex.DecodeString(cipherHex); err != nil {
t.Errorf("cipherHex is not valid hex: %v", err)
}
if _, err := hex.DecodeString(nonceHex); err != nil {
t.Errorf("nonceHex is not valid hex: %v", err)
}
// Decrypt
decrypted, err := svc.decryptPrivKey(cipherHex, nonceHex)
if err != nil {
t.Fatalf("decryptPrivKey failed: %v", err)
}
// Compare
if hex.EncodeToString(decrypted) != hex.EncodeToString(privKey) {
t.Error("decrypted key does not match original")
}
}
func TestEncryptDecrypt_DifferentKeysProduceDifferentCiphertext(t *testing.T) {
svc := newTestService()
privKey1, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair 1: %v", err)
}
privKey2, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair 2: %v", err)
}
cipher1, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey1))
if err != nil {
t.Fatalf("encryptPrivKey 1 failed: %v", err)
}
cipher2, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey2))
if err != nil {
t.Fatalf("encryptPrivKey 2 failed: %v", err)
}
if cipher1 == cipher2 {
t.Error("different private keys should produce different ciphertexts")
}
}
func TestEncryptSameKey_DifferentNonces(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipher1, nonce1, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey 1 failed: %v", err)
}
cipher2, nonce2, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey 2 failed: %v", err)
}
// Each encryption should use a different random nonce
if nonce1 == nonce2 {
t.Error("encrypting the same key twice should use different nonces")
}
// So ciphertext should also differ (AES-GCM is nonce-dependent)
if cipher1 == cipher2 {
t.Error("encrypting the same key with different nonces should produce different ciphertexts")
}
}
func TestDecryptWithWrongKey(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
// Create a service with a different encryption key
wrongKey := make([]byte, 32)
for i := range wrongKey {
wrongKey[i] = byte(255 - i)
}
wrongSvc := &Service{encKeyBytes: wrongKey}
_, err = wrongSvc.decryptPrivKey(cipherHex, nonceHex)
if err == nil {
t.Error("decryptPrivKey with wrong key should fail")
}
}
func TestDecryptWithInvalidHex(t *testing.T) {
svc := newTestService()
_, err := svc.decryptPrivKey("not-hex", "also-not-hex")
if err == nil {
t.Error("decryptPrivKey with invalid hex should fail")
}
}
func TestDecryptWithTamperedCiphertext(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
// Tamper with the ciphertext by flipping a byte
cipherBytes, _ := hex.DecodeString(cipherHex)
cipherBytes[0] ^= 0xFF
tamperedHex := hex.EncodeToString(cipherBytes)
_, err = svc.decryptPrivKey(tamperedHex, nonceHex)
if err == nil {
t.Error("decryptPrivKey with tampered ciphertext should fail")
}
}
func TestNewService_InvalidEncryptionKey(t *testing.T) {
tests := []struct {
name string
encKey string
}{
{"too short", "aabbccdd"},
{"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
{"empty", ""},
{"odd length", "aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewService(nil, nil, "test-chain", "", tt.encKey)
if err == nil {
t.Error("NewService should fail with invalid encryption key")
}
})
}
}
func TestNewService_ValidEncryptionKey(t *testing.T) {
// 64 hex chars = 32 bytes
validKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
svc, err := NewService(nil, nil, "test-chain", "", validKey)
if err != nil {
t.Fatalf("NewService with valid key should succeed: %v", err)
}
if svc == nil {
t.Fatal("NewService returned nil service")
}
if svc.chainID != "test-chain" {
t.Errorf("chainID = %q, want %q", svc.chainID, "test-chain")
}
// No operator key provided, so operatorWallet should be nil
if svc.operatorWallet != nil {
t.Error("operatorWallet should be nil when no operator key is provided")
}
}
func TestEnsureOperator_NilWallet(t *testing.T) {
svc := newTestService()
err := svc.ensureOperator()
if err == nil {
t.Error("ensureOperator should fail when operatorWallet is nil")
}
}
func TestResolveUsername_NoResolver(t *testing.T) {
svc := newTestService()
_, err := svc.resolveUsername("testuser")
if err == 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)
}
}

View File

@@ -7,9 +7,24 @@ import (
"path/filepath"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
// Download API 하위 호환성 규칙:
// - 기존 필드 삭제 금지 (런처 바이너리가 필드에 의존)
// - 기존 필드 타입 변경 금지
// - 기존 필드명(JSON key) 변경 금지
// - 신규 필드 추가만 허용 (기존 런처는 unknown 필드를 무시)
// - 스키마 변경 시 downloadAPIVersion 값을 올릴 것
//
// 현재 /api/download/info 응답 필드 (v1):
// id, createdAt, updatedAt, url, version, fileName, fileSize,
// fileHash, launcherUrl, launcherSize, launcherHash
const downloadAPIVersion = "1"
type Handler struct {
svc *Service
baseURL string
@@ -30,8 +45,9 @@ func NewHandler(svc *Service, baseURL string) *Handler {
func (h *Handler) GetInfo(c *fiber.Ctx) error {
info, err := h.svc.GetInfo()
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "다운로드 정보가 없습니다"})
return apperror.NotFound("다운로드 정보가 없습니다")
}
c.Set("X-API-Version", downloadAPIVersion)
return c.JSON(info)
}
@@ -54,17 +70,17 @@ func (h *Handler) Upload(c *fiber.Ctx) error {
// 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용
filename = filepath.Base(filename)
if !strings.HasSuffix(strings.ToLower(filename), ".zip") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zip 파일만 업로드 가능합니다"})
return apperror.BadRequest("zip 파일만 업로드 가능합니다")
}
if len(filename) > 200 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "파일명이 너무 깁니다"})
return apperror.BadRequest("파일명이 너무 깁니다")
}
body := c.Request().BodyStream()
info, err := h.svc.Upload(filename, body, h.baseURL)
if err != nil {
log.Printf("game upload failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "게임 파일 업로드에 실패했습니다"})
return apperror.Internal("게임 파일 업로드에 실패했습니다")
}
return c.JSON(info)
}
@@ -80,13 +96,14 @@ func (h *Handler) Upload(c *fiber.Ctx) error {
func (h *Handler) ServeFile(c *fiber.Ctx) error {
path := h.svc.GameFilePath()
if _, err := os.Stat(path); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"})
return apperror.NotFound("파일이 없습니다")
}
info, _ := h.svc.GetInfo()
filename := "game.zip"
if info != nil && info.FileName != "" {
filename = info.FileName
}
c.Set("X-API-Version", downloadAPIVersion)
c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
return c.SendFile(path)
}
@@ -108,7 +125,7 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
info, err := h.svc.UploadLauncher(body, h.baseURL)
if err != nil {
log.Printf("launcher upload failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "런처 업로드에 실패했습니다"})
return apperror.Internal("런처 업로드에 실패했습니다")
}
return c.JSON(info)
}
@@ -124,8 +141,9 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
func (h *Handler) ServeLauncher(c *fiber.Ctx) error {
path := h.svc.LauncherFilePath()
if _, err := os.Stat(path); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"})
return apperror.NotFound("파일이 없습니다")
}
c.Set("X-API-Version", downloadAPIVersion)
c.Set("Content-Disposition", `attachment; filename="launcher.exe"`)
return c.SendFile(path)
}

View File

@@ -22,4 +22,5 @@ type Info struct {
// LauncherSize is a human-readable string (e.g., "25.3 MB") for display purposes.
// Programmatic size tracking uses os.Stat on the actual file.
LauncherSize string `json:"launcherSize" gorm:"not null;default:''"`
LauncherHash string `json:"launcherHash" gorm:"not null;default:''"`
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
@@ -55,12 +56,16 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
err = closeErr
}
if err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
@@ -69,12 +74,15 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
launcherSize = fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
}
launcherHash := hashFileToHex(finalPath)
info, err := s.repo.GetLatest()
if err != nil {
info = &Info{}
}
info.LauncherURL = baseURL + "/api/download/launcher"
info.LauncherSize = launcherSize
info.LauncherHash = launcherHash
return info, s.repo.Save(info)
}
@@ -97,12 +105,16 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
err = closeErr
}
if err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
@@ -123,7 +135,9 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
fileHash := hashGameExeFromZip(finalPath)
if fileHash == "" {
os.Remove(finalPath)
if removeErr := os.Remove(finalPath); removeErr != nil {
log.Printf("WARNING: failed to remove file %s: %v", finalPath, removeErr)
}
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
}
@@ -139,8 +153,21 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
return info, s.repo.Save(info)
}
// NOTE: No size limit on decompressed entry. This is admin-only so
// the risk is minimal. For defense-in-depth, consider io.LimitReader.
func hashFileToHex(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
const maxExeSize = 100 * 1024 * 1024 // 100MB — Zip Bomb 방어
func hashGameExeFromZip(zipPath string) string {
r, err := zip.OpenReader(zipPath)
if err != nil {
@@ -155,7 +182,7 @@ func hashGameExeFromZip(zipPath string) string {
return ""
}
h := sha256.New()
_, err = io.Copy(h, rc)
_, err = io.Copy(h, io.LimitReader(rc, maxExeSize))
rc.Close()
if err != nil {
return ""

View File

@@ -0,0 +1,198 @@
package download
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestHashFileToHex_KnownContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "testfile.bin")
content := []byte("hello world")
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256(content)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex = %q, want %q", got, want)
}
}
func TestHashFileToHex_EmptyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.bin")
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256([]byte{})
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex (empty) = %q, want %q", got, want)
}
}
func TestHashFileToHex_NonExistentFile(t *testing.T) {
got := hashFileToHex("/nonexistent/path/file.bin")
if got != "" {
t.Errorf("hashFileToHex (nonexistent) = %q, want empty string", got)
}
}
// createTestZip creates a zip file at zipPath containing the given files.
// files is a map of filename -> content.
func createTestZip(t *testing.T, zipPath string, files map[string][]byte) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
for name, data := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("failed to create zip entry %s: %v", name, err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("failed to write zip entry %s: %v", name, err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
}
func TestHashGameExeFromZip_WithA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("fake A301.exe binary content for testing")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/A301.exe": exeContent,
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("case insensitive test")
createTestZip(t, zipPath, map[string][]byte{
"build/a301.EXE": exeContent,
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip (case insensitive) = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_NoA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/other.exe": []byte("not A301"),
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (no A301.exe) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_EmptyZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "empty.zip")
createTestZip(t, zipPath, map[string][]byte{})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (empty zip) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_InvalidZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "notazip.zip")
if err := os.WriteFile(zipPath, []byte("this is not a zip file"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (invalid zip) = %q, want empty string", got)
}
}
func TestVersionRegex(t *testing.T) {
tests := []struct {
input string
want string
}{
{"game_v1.2.3.zip", "v1.2.3"},
{"game_v2.0.zip", "v2.0"},
{"game_v10.20.30.zip", "v10.20.30"},
{"game.zip", ""},
{"noversion", ""},
}
for _, tt := range tests {
got := versionRe.FindString(tt.input)
if got != tt.want {
t.Errorf("versionRe.FindString(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestGameFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.GameFilePath()
// filepath.Join normalizes separators per OS
want := filepath.Join("/data/game", "game.zip")
if got != want {
t.Errorf("GameFilePath() = %q, want %q", got, want)
}
}
func TestLauncherFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.LauncherFilePath()
want := filepath.Join("/data/game", "launcher.exe")
if got != want {
t.Errorf("LauncherFilePath() = %q, want %q", got, want)
}
}

View File

@@ -5,6 +5,8 @@ import (
"strings"
"unicode"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
@@ -29,12 +31,12 @@ func NewHandler(svc *Service) *Handler {
func (h *Handler) GetProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
profile, err := h.svc.GetProfile(userID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
return apperror.NotFound(err.Error())
}
return c.JSON(profileWithNextExp(profile))
@@ -56,25 +58,25 @@ func (h *Handler) GetProfile(c *fiber.Ctx) error {
func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
var req struct {
Nickname string `json:"nickname"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
req.Nickname = strings.TrimSpace(req.Nickname)
if req.Nickname != "" {
nicknameRunes := []rune(req.Nickname)
if len(nicknameRunes) < 2 || len(nicknameRunes) > 30 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "닉네임은 2~30자여야 합니다"})
return apperror.BadRequest("닉네임은 2~30자여야 합니다")
}
for _, r := range nicknameRunes {
if unicode.IsControl(r) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "닉네임에 허용되지 않는 문자가 포함되어 있습니다"})
return apperror.BadRequest("닉네임에 허용되지 않는 문자가 포함되어 있습니다")
}
}
}
@@ -82,7 +84,7 @@ func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
profile, err := h.svc.UpdateProfile(userID, req.Nickname)
if err != nil {
log.Printf("프로필 수정 실패 (userID=%d): %v", userID, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"})
return apperror.ErrInternal
}
return c.JSON(profile)
@@ -102,12 +104,12 @@ func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
func (h *Handler) InternalGetProfile(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"})
return apperror.BadRequest("username 파라미터가 필요합니다")
}
profile, err := h.svc.GetProfileByUsername(username)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
return apperror.NotFound(err.Error())
}
return c.JSON(profileWithNextExp(profile))
@@ -157,18 +159,18 @@ func profileWithNextExp(p *PlayerProfile) fiber.Map {
func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"})
return apperror.BadRequest("username 파라미터가 필요합니다")
}
var req GameDataRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
if err := h.svc.SaveGameDataByUsername(username, &req); err != nil {
// Username from internal API (ServerAuth protected) — low risk of injection
log.Printf("게임 데이터 저장 실패 (username=%s): %v", username, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "서버 오류가 발생했습니다"})
return apperror.ErrInternal
}
return c.JSON(fiber.Map{"message": "게임 데이터가 저장되었습니다"})

View File

@@ -141,12 +141,8 @@ func (s *Service) SaveGameData(userID uint, data *GameDataRequest) error {
updates["last_rot_y"] = *data.LastRotY
}
if data.PlayTimeDelta != nil {
// 플레이 시간은 delta로 누적
profile, err := s.repo.FindByUserID(userID)
if err != nil {
return fmt.Errorf("프로필이 존재하지 않습니다")
}
updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta
// 원자적 SQL 업데이트로 동시 요청 시 race condition 방지
updates["total_play_time"] = gorm.Expr("total_play_time + ?", *data.PlayTimeDelta)
}
if len(updates) == 0 {

View File

@@ -0,0 +1,543 @@
// 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 {
// Mirror the real service: atomic increment via delta value.
// The mock UpdateStats handles this by adding to the existing value.
updates["total_play_time_delta"] = *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)
case "total_play_time_delta":
// Simulates SQL: total_play_time = total_play_time + delta
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")
}
}

110
internal/server/server.go Normal file
View File

@@ -0,0 +1,110 @@
package server
import (
"strconv"
"time"
"a301_server/pkg/apperror"
"a301_server/pkg/metrics"
"a301_server/pkg/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// New creates a configured Fiber app with all global middleware applied.
func New() *fiber.App {
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
ErrorHandler: middleware.ErrorHandler,
})
app.Use(middleware.RequestID)
app.Use(middleware.Metrics)
app.Get("/metrics", metrics.Handler)
app.Use(logger.New(logger.Config{
Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n",
TimeFormat: "2006-01-02T15:04:05Z07:00",
}))
app.Use(middleware.SecurityHeaders)
app.Use(cors.New(cors.Config{
AllowOrigins: "https://a301.tolelom.xyz",
AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key",
AllowMethods: "GET, POST, PUT, PATCH, DELETE",
AllowCredentials: true,
}))
return app
}
// AuthLimiter returns a rate limiter for auth endpoints (10 req/min per IP).
func AuthLimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return apperror.ErrRateLimited
},
})
}
// APILimiter returns a rate limiter for general API endpoints (60 req/min per IP).
func APILimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 60,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return apperror.ErrRateLimited
},
})
}
// ChainUserLimiter returns a rate limiter for chain transactions (20 req/min per user).
func ChainUserLimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 20,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
if uid, ok := c.Locals("userID").(uint); ok {
return "chain_user:" + strconv.FormatUint(uint64(uid), 10)
}
return "chain_ip:" + c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return apperror.ErrRateLimited
},
})
}
// HealthCheck returns a handler that reports server liveness.
func HealthCheck() fiber.Handler {
return func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
}
}
// ReadyCheck returns a handler that verifies DB and Redis connectivity.
func ReadyCheck(db *gorm.DB, rdb *redis.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
sqlDB, err := db.DB()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db pool"})
}
if err := sqlDB.Ping(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db"})
}
if err := rdb.Ping(c.Context()).Err(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "redis"})
}
return c.JSON(fiber.Map{"status": "ok"})
}
}

161
main.go
View File

@@ -4,7 +4,6 @@ import (
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
@@ -14,6 +13,7 @@ import (
"a301_server/internal/chain"
"a301_server/internal/download"
"a301_server/internal/player"
"a301_server/internal/server"
_ "a301_server/docs" // swagger docs
@@ -22,11 +22,6 @@ import (
"a301_server/pkg/database"
"a301_server/pkg/middleware"
"a301_server/routes"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
)
// @title One of the Plans API
@@ -49,77 +44,68 @@ func main() {
config.Load()
config.WarnInsecureDefaults()
if err := database.ConnectMySQL(); err != nil {
db, err := database.ConnectMySQL()
if err != nil {
log.Fatalf("MySQL 연결 실패: %v", err)
}
log.Println("MySQL 연결 성공")
// AutoMigrate
if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &player.PlayerProfile{}); err != nil {
if err := 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)
}
if err := database.ConnectRedis(); err != nil {
rdb, err := database.ConnectRedis()
if err != nil {
log.Fatalf("Redis 연결 실패: %v", err)
}
log.Println("Redis 연결 성공")
// 의존성 주입
authRepo := auth.NewRepository(database.DB)
authSvc := auth.NewService(authRepo, database.RDB)
// ── 의존성 주입 ──────────────────────────────────────────────────
authRepo := auth.NewRepository(db)
authSvc := auth.NewService(authRepo, rdb)
authHandler := auth.NewHandler(authSvc)
// Chain (blockchain integration)
chainClient := chain.NewClient(config.C.ChainNodeURL)
chainRepo := chain.NewRepository(database.DB)
chainClient := chain.NewClient(config.C.ChainNodeURLs...)
chainRepo := chain.NewRepository(db)
chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey)
if err != nil {
log.Fatalf("chain service init failed: %v", err)
}
chainHandler := chain.NewHandler(chainSvc)
// username → userID 변환 (게임 서버 내부 API용)
chainSvc.SetUserResolver(func(username string) (uint, error) {
userResolver := func(username string) (uint, error) {
user, err := authRepo.FindByUsername(username)
if err != nil {
return 0, err
}
return user.ID, nil
})
}
chainSvc.SetUserResolver(userResolver)
// 회원가입 시 블록체인 월렛 자동 생성
authSvc.SetWalletCreator(func(userID uint) error {
_, err := chainSvc.CreateWallet(userID)
return err
})
// Player Profile
playerRepo := player.NewRepository(database.DB)
playerRepo := player.NewRepository(db)
playerSvc := player.NewService(playerRepo)
playerSvc.SetUserResolver(func(username string) (uint, error) {
user, err := authRepo.FindByUsername(username)
if err != nil {
return 0, err
}
return user.ID, nil
})
playerSvc.SetUserResolver(userResolver)
playerHandler := player.NewHandler(playerSvc)
// 회원가입 시 플레이어 프로필 자동 생성
authSvc.SetProfileCreator(func(userID uint) error {
return playerSvc.CreateProfile(userID)
})
// 초기 admin 계정 생성 (콜백 등록 후 실행)
if err := authSvc.EnsureAdmin(config.C.AdminUsername, config.C.AdminPassword); err != nil {
log.Printf("admin 계정 생성 실패: %v", err)
} else {
log.Printf("admin 계정 확인 완료: %s", config.C.AdminUsername)
}
// Boss Raid
brRepo := bossraid.NewRepository(database.DB)
brSvc := bossraid.NewService(brRepo, database.RDB)
brRepo := bossraid.NewRepository(db)
brSvc := bossraid.NewService(brRepo, rdb)
brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err
@@ -133,91 +119,28 @@ func main() {
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
}
annRepo := announcement.NewRepository(database.DB)
annRepo := announcement.NewRepository(db)
annSvc := announcement.NewService(annRepo)
annHandler := announcement.NewHandler(annSvc)
dlRepo := download.NewRepository(database.DB)
dlRepo := download.NewRepository(db)
dlSvc := download.NewService(dlRepo, config.C.GameDir)
dlHandler := download.NewHandler(dlSvc, config.C.BaseURL)
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
})
app.Use(middleware.RequestID)
app.Use(logger.New(logger.Config{
Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n",
TimeFormat: "2006-01-02T15:04:05Z07:00",
}))
app.Use(middleware.SecurityHeaders)
app.Use(cors.New(cors.Config{
AllowOrigins: "https://a301.tolelom.xyz",
AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key",
AllowMethods: "GET, POST, PUT, PATCH, DELETE",
AllowCredentials: true,
}))
// ── 서버 + 라우트 설정 ───────────────────────────────────────────
// Rate limiting: 인증 관련 엔드포인트 (로그인/회원가입/리프레시)
authLimiter := limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
},
})
app := server.New()
// Rate limiting: 일반 API
apiLimiter := limiter.New(limiter.Config{
Max: 60,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
},
})
authMw := middleware.Auth(rdb, config.C.JWTSecret)
serverAuthMw := middleware.ServerAuth(config.C.InternalAPIKey)
idempotencyReqMw := middleware.IdempotencyRequired(rdb)
// Health check handlers
healthCheck := func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
}
readyCheck := func(c *fiber.Ctx) error {
sqlDB, err := database.DB.DB()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db pool"})
}
if err := sqlDB.Ping(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db"})
}
if err := database.RDB.Ping(c.Context()).Err(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "redis"})
}
return c.JSON(fiber.Map{"status": "ok"})
}
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler,
server.AuthLimiter(), server.APILimiter(), server.HealthCheck(), server.ReadyCheck(db, rdb),
server.ChainUserLimiter(), authMw, serverAuthMw, idempotencyReqMw)
// Rate limiting: 체인 트랜잭션 (유저별 분당 20회)
chainUserLimiter := limiter.New(limiter.Config{
Max: 20,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
if uid, ok := c.Locals("userID").(uint); ok {
return "chain_user:" + strconv.FormatUint(uint64(uid), 10)
}
return "chain_ip:" + c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "트랜잭션 요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
},
})
// ── 백그라운드 워커 ──────────────────────────────────────────────
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler, authLimiter, apiLimiter, healthCheck, readyCheck, chainUserLimiter)
// Background: stale dedicated server detection
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
@@ -226,25 +149,37 @@ func main() {
}
}()
// Graceful shutdown
rewardWorker := bossraid.NewRewardWorker(
brRepo,
func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err
},
func(username string, exp int) error {
return playerSvc.GrantExperienceByUsername(username, exp)
},
)
rewardWorker.Start()
// ── Graceful shutdown ────────────────────────────────────────────
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("수신된 시그널: %v — 서버 종료 중...", sig)
rewardWorker.Stop()
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
log.Printf("서버 종료 실패: %v", err)
}
// Redis 연결 정리
if database.RDB != nil {
if err := database.RDB.Close(); err != nil {
if rdb != nil {
if err := rdb.Close(); err != nil {
log.Printf("Redis 종료 실패: %v", err)
} else {
log.Println("Redis 연결 종료 완료")
}
}
// MySQL 연결 정리
if sqlDB, err := database.DB.DB(); err == nil {
if sqlDB, err := db.DB(); err == nil {
if err := sqlDB.Close(); err != nil {
log.Printf("MySQL 종료 실패: %v", err)
} else {

59
pkg/apperror/apperror.go Normal file
View File

@@ -0,0 +1,59 @@
package apperror
import "fmt"
// AppError is a structured application error with an HTTP status code.
// JSON response format: {"error": "<code>", "message": "<human-readable message>"}
type AppError struct {
Code string `json:"error"`
Message string `json:"message"`
Status int `json:"-"`
}
func (e *AppError) Error() string { return e.Message }
// New creates a new AppError.
func New(code string, message string, status int) *AppError {
return &AppError{Code: code, Message: message, Status: status}
}
// Wrap creates a new AppError that wraps a cause error.
func Wrap(code string, message string, status int, cause error) *AppError {
return &AppError{Code: code, Message: fmt.Sprintf("%s: %v", message, cause), Status: status}
}
// Common errors
var (
ErrBadRequest = &AppError{Code: "bad_request", Message: "잘못된 요청입니다", Status: 400}
ErrUnauthorized = &AppError{Code: "unauthorized", Message: "인증이 필요합니다", Status: 401}
ErrForbidden = &AppError{Code: "forbidden", Message: "권한이 없습니다", Status: 403}
ErrNotFound = &AppError{Code: "not_found", Message: "리소스를 찾을 수 없습니다", Status: 404}
ErrConflict = &AppError{Code: "conflict", Message: "이미 존재합니다", Status: 409}
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}
}

View File

@@ -4,6 +4,7 @@ import (
"log"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
@@ -26,7 +27,10 @@ type Config struct {
GameDir string
// Chain integration
// ChainNodeURL은 단일 노드 설정용 (하위 호환).
// ChainNodeURLs는 CHAIN_NODE_URLS(쉼표 구분) 또는 ChainNodeURL에서 파생.
ChainNodeURL string
ChainNodeURLs []string
ChainID string
OperatorKeyHex string
WalletEncryptionKey string
@@ -74,6 +78,18 @@ func Load() {
SSAFYClientSecret: getEnv("SSAFY_CLIENT_SECRET", ""),
SSAFYRedirectURI: getEnv("SSAFY_REDIRECT_URI", ""),
}
// CHAIN_NODE_URLS (쉼표 구분) 우선, 없으면 CHAIN_NODE_URL 단일값 사용
if raw := getEnv("CHAIN_NODE_URLS", ""); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
C.ChainNodeURLs = append(C.ChainNodeURLs, u)
}
}
}
if len(C.ChainNodeURLs) == 0 {
C.ChainNodeURLs = []string{C.ChainNodeURL}
}
}
// WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults.

View File

@@ -9,28 +9,23 @@ import (
"gorm.io/gorm"
)
// TODO: Consider injecting DB as a dependency instead of using a package-level global
// to improve testability. Currently, middleware directly accesses this global.
var DB *gorm.DB
func ConnectMySQL() error {
func ConnectMySQL() (*gorm.DB, error) {
c := config.C
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("sql.DB 획득 실패: %w", err)
return nil, fmt.Errorf("sql.DB 획득 실패: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
DB = db
return nil
return db, nil
}

View File

@@ -7,14 +7,13 @@ import (
"github.com/redis/go-redis/v9"
)
// TODO: Consider injecting RDB as a dependency instead of using a package-level global
// to improve testability. Currently, middleware directly accesses this global.
var RDB *redis.Client
func ConnectRedis() error {
RDB = redis.NewClient(&redis.Options{
func ConnectRedis() (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: config.C.RedisAddr,
Password: config.C.RedisPassword,
})
return RDB.Ping(context.Background()).Err()
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, err
}
return rdb, nil
}

54
pkg/metrics/metrics.go Normal file
View File

@@ -0,0 +1,54 @@
package metrics
import (
"io"
"net/http"
"net/http/httptest"
"github.com/gofiber/fiber/v2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
HTTPRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
[]string{"method", "path", "status"},
)
HTTPRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration"},
[]string{"method", "path"},
)
DBConnectionsActive = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "db_connections_active", Help: "Active DB connections"},
)
RedisConnectionsActive = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "redis_connections_active", Help: "Active Redis connections"},
)
)
func init() {
prometheus.MustRegister(HTTPRequestsTotal, HTTPRequestDuration, DBConnectionsActive, RedisConnectionsActive)
}
// Handler returns a Fiber handler that serves the Prometheus metrics endpoint.
// It wraps promhttp.Handler() without requiring the gofiber/adaptor package.
func Handler(c *fiber.Ctx) error {
handler := promhttp.Handler()
req, err := http.NewRequest(http.MethodGet, "/metrics", nil)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
result := rec.Result()
defer result.Body.Close()
c.Set("Content-Type", result.Header.Get("Content-Type"))
c.Status(result.StatusCode)
body, err := io.ReadAll(result.Body)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
return c.Send(body)
}

View File

@@ -7,16 +7,19 @@ import (
"log"
"strings"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
func Auth(c *fiber.Ctx) error {
// Auth returns a middleware that validates JWT tokens and checks Redis sessions.
func Auth(rdb *redis.Client, jwtSecret string) fiber.Handler {
secretBytes := []byte(jwtSecret)
return func(c *fiber.Ctx) error {
header := c.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증이 필요합니다"})
return apperror.ErrUnauthorized
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
@@ -24,27 +27,27 @@ func Auth(c *fiber.Ctx) error {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(config.C.JWTSecret), nil
return secretBytes, nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
userIDFloat, ok := claims["user_id"].(float64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
username, ok := claims["username"].(string)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
role, ok := claims["role"].(string)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
userID := uint(userIDFloat)
@@ -52,9 +55,9 @@ func Auth(c *fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), redisTimeout)
defer cancel()
key := fmt.Sprintf("session:%d", userID)
stored, err := database.RDB.Get(ctx, key).Result()
stored, err := rdb.Get(ctx, key).Result()
if err != nil || stored != tokenStr {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "만료되었거나 로그아웃된 세션입니다"})
return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다")
}
c.Locals("userID", userID)
@@ -62,22 +65,25 @@ func Auth(c *fiber.Ctx) error {
c.Locals("role", role)
return c.Next()
}
}
func AdminOnly(c *fiber.Ctx) error {
if c.Locals("role") != "admin" {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "관리자 권한이 필요합니다"})
return apperror.ErrForbidden
}
return c.Next()
}
// ServerAuth validates X-API-Key header for server-to-server communication.
// ServerAuth returns a middleware that validates X-API-Key header for server-to-server communication.
// Uses constant-time comparison to prevent timing attacks.
func ServerAuth(c *fiber.Ctx) error {
func ServerAuth(apiKey string) fiber.Handler {
expectedBytes := []byte(apiKey)
return func(c *fiber.Ctx) error {
key := c.Get("X-API-Key")
expected := config.C.InternalAPIKey
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 {
if key == "" || len(expectedBytes) == 0 || subtle.ConstantTimeCompare([]byte(key), expectedBytes) != 1 {
log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"})
return apperror.Unauthorized("유효하지 않은 API 키입니다")
}
return c.Next()
}
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"errors"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
// ErrorHandler is a Fiber error handler that returns structured JSON for AppError.
func ErrorHandler(c *fiber.Ctx, err error) error {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
return c.Status(appErr.Status).JSON(appErr)
}
// Default Fiber error handling
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return c.Status(fiberErr.Code).JSON(fiber.Map{
"error": "server_error",
"message": fiberErr.Message,
})
}
return c.Status(500).JSON(fiber.Map{
"error": "internal_error",
"message": "서버 오류가 발생했습니다",
})
}

View File

@@ -7,8 +7,9 @@ import (
"log"
"time"
"a301_server/pkg/database"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
const idempotencyTTL = 10 * time.Minute
@@ -19,26 +20,28 @@ type cachedResponse struct {
Body json.RawMessage `json:"b"`
}
// IdempotencyRequired rejects requests without an Idempotency-Key header,
// then delegates to Idempotency for cache/replay logic.
func IdempotencyRequired(c *fiber.Ctx) error {
// IdempotencyRequired returns a middleware that rejects requests without an Idempotency-Key header,
// then delegates to idempotency cache/replay logic.
func IdempotencyRequired(rdb *redis.Client) fiber.Handler {
idempotency := Idempotency(rdb)
return func(c *fiber.Ctx) error {
if c.Get("Idempotency-Key") == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Idempotency-Key 헤더가 필요합니다",
})
return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다")
}
return idempotency(c)
}
return Idempotency(c)
}
// Idempotency checks the Idempotency-Key header to prevent duplicate transactions.
// Idempotency returns a middleware that checks the Idempotency-Key header to prevent duplicate transactions.
// If the same key is seen again within the TTL, the cached response is returned.
func Idempotency(c *fiber.Ctx) error {
func Idempotency(rdb *redis.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
key := c.Get("Idempotency-Key")
if key == "" {
return c.Next()
}
if len(key) > 256 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Idempotency-Key가 너무 깁니다"})
return apperror.BadRequest("Idempotency-Key가 너무 깁니다")
}
// userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지
@@ -52,7 +55,7 @@ func Idempotency(c *fiber.Ctx) error {
defer cancel()
// Atomically claim the key using SET NX (only succeeds if key doesn't exist)
set, err := database.RDB.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
set, err := rdb.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — let the request through rather than blocking
log.Printf("WARNING: idempotency SetNX failed (key=%s): %v", key, err)
@@ -64,12 +67,12 @@ func Idempotency(c *fiber.Ctx) error {
getCtx, getCancel := context.WithTimeout(context.Background(), redisTimeout)
defer getCancel()
cached, err := database.RDB.Get(getCtx, redisKey).Bytes()
cached, err := rdb.Get(getCtx, redisKey).Bytes()
if err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"})
return apperror.Conflict("요청이 처리 중입니다")
}
if string(cached) == "processing" {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"})
return apperror.Conflict("요청이 처리 중입니다")
}
var cr cachedResponse
if json.Unmarshal(cached, &cr) == nil {
@@ -77,7 +80,7 @@ func Idempotency(c *fiber.Ctx) error {
c.Set("X-Idempotent-Replay", "true")
return c.Status(cr.StatusCode).Send(cr.Body)
}
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "요청이 처리 중입니다"})
return apperror.Conflict("요청이 처리 중입니다")
}
// We claimed the key — process the request
@@ -85,7 +88,9 @@ func Idempotency(c *fiber.Ctx) error {
// Processing failed — remove the key so it can be retried
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
database.RDB.Del(delCtx, redisKey)
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
return err
}
@@ -96,7 +101,7 @@ func Idempotency(c *fiber.Ctx) error {
if data, err := json.Marshal(cr); err == nil {
writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout)
defer writeCancel()
if err := database.RDB.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil {
if err := rdb.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil {
log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err)
}
}
@@ -104,8 +109,11 @@ func Idempotency(c *fiber.Ctx) error {
// Non-success — allow retry by removing the key
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
database.RDB.Del(delCtx, redisKey)
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
}
return nil
}
}

25
pkg/middleware/metrics.go Normal file
View File

@@ -0,0 +1,25 @@
package middleware
import (
"strconv"
"time"
"a301_server/pkg/metrics"
"github.com/gofiber/fiber/v2"
)
// Metrics records HTTP request count and duration as Prometheus metrics.
func Metrics(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
duration := time.Since(start).Seconds()
status := strconv.Itoa(c.Response().StatusCode())
path := c.Route().Path // use route pattern to avoid cardinality explosion
method := c.Method()
metrics.HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
metrics.HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
return err
}

View File

@@ -25,6 +25,9 @@ func Register(
healthCheck fiber.Handler,
readyCheck fiber.Handler,
chainUserLimiter fiber.Handler,
authMw fiber.Handler,
serverAuthMw fiber.Handler,
idempotencyReqMw fiber.Handler,
) {
// Swagger UI
app.Get("/swagger/*", swagger.HandlerDefault)
@@ -38,13 +41,13 @@ func Register(
// ── Internal API (Rate Limit 제외, API Key 인증만) ──────────────
// 반드시 /api 그룹보다 먼저 등록해야 apiLimiter를 우회함
internalApi := app.Group("/api/internal", apiBodyLimit, middleware.ServerAuth)
internalApi := app.Group("/api/internal", apiBodyLimit, serverAuthMw)
// Internal - Boss Raid
br := internalApi.Group("/bossraid")
br.Post("/entry", brH.RequestEntry)
br.Post("/start", brH.StartRaid)
br.Post("/complete", middleware.IdempotencyRequired, brH.CompleteRaid)
br.Post("/complete", idempotencyReqMw, brH.CompleteRaid)
br.Post("/fail", brH.FailRaid)
br.Get("/room", brH.GetRoom)
br.Post("/validate-entry", brH.ValidateEntryToken)
@@ -64,8 +67,8 @@ func Register(
// Internal - Chain
internalChain := internalApi.Group("/chain")
internalChain.Post("/reward", middleware.IdempotencyRequired, chainH.InternalGrantReward)
internalChain.Post("/mint", middleware.IdempotencyRequired, chainH.InternalMintAsset)
internalChain.Post("/reward", idempotencyReqMw, chainH.InternalGrantReward)
internalChain.Post("/mint", idempotencyReqMw, chainH.InternalMintAsset)
internalChain.Get("/balance", chainH.InternalGetBalance)
internalChain.Get("/assets", chainH.InternalGetAssets)
internalChain.Get("/inventory", chainH.InternalGetInventory)
@@ -78,15 +81,15 @@ func Register(
a.Post("/register", authLimiter, authH.Register)
a.Post("/login", authLimiter, authH.Login)
a.Post("/refresh", authLimiter, authH.Refresh)
a.Post("/logout", middleware.Auth, authH.Logout)
a.Post("/logout", authMw, authH.Logout)
// /verify moved to internal API (ServerAuth) — see internal section below
a.Get("/ssafy/login", authH.SSAFYLoginURL)
a.Post("/ssafy/callback", authLimiter, authH.SSAFYCallback)
a.Post("/launch-ticket", middleware.Auth, authH.CreateLaunchTicket)
a.Post("/launch-ticket", authMw, authH.CreateLaunchTicket)
a.Post("/redeem-ticket", authLimiter, authH.RedeemLaunchTicket)
// Users (admin only)
u := api.Group("/users", middleware.Auth, middleware.AdminOnly)
u := api.Group("/users", authMw, middleware.AdminOnly)
u.Get("/", authH.GetAllUsers)
u.Patch("/:id/role", authH.UpdateRole)
u.Delete("/:id", authH.DeleteUser)
@@ -94,20 +97,20 @@ func Register(
// Announcements
ann := api.Group("/announcements")
ann.Get("/", annH.GetAll)
ann.Post("/", middleware.Auth, middleware.AdminOnly, annH.Create)
ann.Put("/:id", middleware.Auth, middleware.AdminOnly, annH.Update)
ann.Delete("/:id", middleware.Auth, middleware.AdminOnly, annH.Delete)
ann.Post("/", authMw, middleware.AdminOnly, annH.Create)
ann.Put("/:id", authMw, middleware.AdminOnly, annH.Update)
ann.Delete("/:id", authMw, middleware.AdminOnly, annH.Delete)
// Download
dl := api.Group("/download")
dl.Get("/info", dlH.GetInfo)
dl.Get("/file", dlH.ServeFile)
dl.Get("/launcher", dlH.ServeLauncher)
dl.Post("/upload/game", middleware.Auth, middleware.AdminOnly, dlH.Upload)
dl.Post("/upload/launcher", middleware.Auth, middleware.AdminOnly, dlH.UploadLauncher)
dl.Post("/upload/game", authMw, middleware.AdminOnly, dlH.Upload)
dl.Post("/upload/launcher", authMw, middleware.AdminOnly, dlH.UploadLauncher)
// Chain - Queries (authenticated)
ch := api.Group("/chain", middleware.Auth)
ch := api.Group("/chain", authMw)
ch.Get("/wallet", chainH.GetWalletInfo)
ch.Get("/balance", chainH.GetBalance)
ch.Get("/assets", chainH.GetAssets)
@@ -117,22 +120,22 @@ func Register(
ch.Get("/market/:id", chainH.GetMarketListing)
// Chain - User Transactions (authenticated, per-user rate limited, idempotency-protected)
ch.Post("/transfer", chainUserLimiter, middleware.IdempotencyRequired, chainH.Transfer)
ch.Post("/asset/transfer", chainUserLimiter, middleware.IdempotencyRequired, chainH.TransferAsset)
ch.Post("/market/list", chainUserLimiter, middleware.IdempotencyRequired, chainH.ListOnMarket)
ch.Post("/market/buy", chainUserLimiter, middleware.IdempotencyRequired, chainH.BuyFromMarket)
ch.Post("/market/cancel", chainUserLimiter, middleware.IdempotencyRequired, chainH.CancelListing)
ch.Post("/inventory/equip", chainUserLimiter, middleware.IdempotencyRequired, chainH.EquipItem)
ch.Post("/inventory/unequip", chainUserLimiter, middleware.IdempotencyRequired, chainH.UnequipItem)
ch.Post("/transfer", chainUserLimiter, idempotencyReqMw, chainH.Transfer)
ch.Post("/asset/transfer", chainUserLimiter, idempotencyReqMw, chainH.TransferAsset)
ch.Post("/market/list", chainUserLimiter, idempotencyReqMw, chainH.ListOnMarket)
ch.Post("/market/buy", chainUserLimiter, idempotencyReqMw, chainH.BuyFromMarket)
ch.Post("/market/cancel", chainUserLimiter, idempotencyReqMw, chainH.CancelListing)
ch.Post("/inventory/equip", chainUserLimiter, idempotencyReqMw, chainH.EquipItem)
ch.Post("/inventory/unequip", chainUserLimiter, idempotencyReqMw, chainH.UnequipItem)
// Chain - Admin Transactions (admin only, idempotency-protected)
chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly)
chainAdmin.Post("/mint", middleware.IdempotencyRequired, chainH.MintAsset)
chainAdmin.Post("/reward", middleware.IdempotencyRequired, chainH.GrantReward)
chainAdmin.Post("/template", middleware.IdempotencyRequired, chainH.RegisterTemplate)
chainAdmin := api.Group("/chain/admin", authMw, middleware.AdminOnly)
chainAdmin.Post("/mint", idempotencyReqMw, chainH.MintAsset)
chainAdmin.Post("/reward", idempotencyReqMw, chainH.GrantReward)
chainAdmin.Post("/template", idempotencyReqMw, chainH.RegisterTemplate)
// Player Profile (authenticated)
p := api.Group("/player", middleware.Auth)
p := api.Group("/player", authMw)
p.Get("/profile", playerH.GetProfile)
p.Put("/profile", playerH.UpdateProfile)