diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index d9ab6ef..4f25d38 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,8 +6,26 @@ on: - main jobs: + lint-and-build: + runs-on: ubuntu-latest + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v4 + + - name: Go 설치 + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: go vet 검증 + run: go vet ./... + + - name: 빌드 검증 + run: go build -o /dev/null . + deploy: runs-on: ubuntu-latest + needs: lint-and-build steps: - name: 서버에 배포 uses: appleboy/ssh-action@v1 @@ -25,7 +43,5 @@ jobs: git clone https://github.com/tolelom/tolchain.git tolchain docker build --no-cache -t a301-server:latest -f a301_server/Dockerfile . cd ~/server - docker compose stop a301-server - docker compose rm -f a301-server - docker compose up -d a301-server + docker compose up -d --no-deps --force-recreate a301-server rm -rf /tmp/a301-build diff --git a/Dockerfile b/Dockerfile index 360494c..b6e0fe5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,11 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server . # Stage 2: Run FROM alpine:latest -RUN apk --no-cache add tzdata ca-certificates +RUN apk --no-cache add tzdata ca-certificates curl RUN mkdir -p /data/game WORKDIR /app COPY --from=builder /build/a301_server/server . EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 CMD ["./server"] diff --git a/internal/announcement/handler.go b/internal/announcement/handler.go index 4694245..5bccbcf 100644 --- a/internal/announcement/handler.go +++ b/internal/announcement/handler.go @@ -31,6 +31,12 @@ func (h *Handler) Create(c *fiber.Ctx) error { if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Content == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목과 내용을 입력해주세요"}) } + if len(body.Title) > 256 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목은 256자 이하여야 합니다"}) + } + if len(body.Content) > 10000 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "내용은 10000자 이하여야 합니다"}) + } a, err := h.svc.Create(body.Title, body.Content) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항 생성에 실패했습니다"}) diff --git a/internal/auth/handler.go b/internal/auth/handler.go index a4a807e..153e18e 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -41,7 +41,7 @@ func (h *Handler) Register(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 72자 이하여야 합니다"}) } if err := h.svc.Register(req.Username, req.Password); err != nil { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "회원가입에 실패했습니다"}) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "회원가입이 완료되었습니다"}) } diff --git a/internal/auth/service.go b/internal/auth/service.go index 1a3f23f..63efda3 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -33,9 +33,10 @@ type Claims struct { } type Service struct { - repo *Repository - rdb *redis.Client - walletCreator func(userID uint) error + repo *Repository + rdb *redis.Client + walletCreator func(userID uint) error + profileCreator func(userID uint) error } func NewService(repo *Repository, rdb *redis.Client) *Service { @@ -46,6 +47,10 @@ func (s *Service) SetWalletCreator(fn func(userID uint) error) { s.walletCreator = fn } +func (s *Service) SetProfileCreator(fn func(userID uint) error) { + s.profileCreator = fn +} + func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) { user, err = s.repo.FindByUsername(username) if err != nil { @@ -205,6 +210,11 @@ func (s *Service) Register(username, password string) error { return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요") } } + if s.profileCreator != nil { + if err := s.profileCreator(user.ID); err != nil { + log.Printf("profile creation failed for user %d: %v", user.ID, err) + } + } return nil } @@ -347,6 +357,11 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use return "", "", nil, fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요") } } + if s.profileCreator != nil { + if err := s.profileCreator(newUserID); err != nil { + log.Printf("profile creation failed for SSAFY user %d: %v", newUserID, err) + } + } } accessToken, err = s.issueAccessToken(user) @@ -420,5 +435,10 @@ func (s *Service) EnsureAdmin(username, password string) error { log.Printf("WARNING: admin wallet creation failed for user %d: %v", user.ID, err) } } + if s.profileCreator != nil { + if err := s.profileCreator(user.ID); err != nil { + log.Printf("WARNING: admin profile creation failed for user %d: %v", user.ID, err) + } + } return nil } diff --git a/internal/bossraid/handler.go b/internal/bossraid/handler.go index dd468be..b99c98c 100644 --- a/internal/bossraid/handler.go +++ b/internal/bossraid/handler.go @@ -129,6 +129,114 @@ func (h *Handler) FailRaid(c *fiber.Ctx) error { }) } +// RequestEntryAuth handles POST /api/bossraid/entry (JWT authenticated). +// Called by the game client to request boss raid entry. +// The authenticated user must be included in the usernames list. +func (h *Handler) RequestEntryAuth(c *fiber.Ctx) error { + var req struct { + Usernames []string `json:"usernames"` + BossID int `json:"bossId"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + + // 인증된 유저의 username + authUsername, _ := c.Locals("username").(string) + if authUsername == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 없습니다"}) + } + + // 빈 usernames이면 솔로 입장 — 본인만 포함 + if len(req.Usernames) == 0 { + req.Usernames = []string{authUsername} + } + if req.BossID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bossId는 필수입니다"}) + } + + // 인증된 유저가 요청 목록에 포함되어 있는지 검증 + found := false + for _, u := range req.Usernames { + if u == authUsername { + found = true + break + } + } + if !found { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "본인이 입장 목록에 포함되어야 합니다"}) + } + + for _, u := range req.Usernames { + if len(u) == 0 || len(u) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 username입니다"}) + } + } + + room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID) + if err != nil { + return bossError(c, fiber.StatusConflict, err.Error(), err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "roomId": room.ID, + "sessionName": room.SessionName, + "bossId": room.BossID, + "players": req.Usernames, + "status": room.Status, + "entryToken": tokens[authUsername], + }) +} + +// GetMyEntryToken handles GET /api/bossraid/my-entry-token (JWT authenticated). +// Returns the pending entry token for the authenticated user. +// Called by party members after the leader requests entry. +func (h *Handler) GetMyEntryToken(c *fiber.Ctx) error { + username, _ := c.Locals("username").(string) + if username == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 없습니다"}) + } + + sessionName, entryToken, err := h.svc.GetMyEntryToken(username) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "sessionName": sessionName, + "entryToken": entryToken, + }) +} + +// ValidateEntryToken handles POST /api/internal/bossraid/validate-entry (ServerAuth). +// Called by the dedicated server to validate a player's entry token. +// Consumes the token (one-time use). +func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error { + var req struct { + EntryToken string `json:"entryToken"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.EntryToken == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.JSON(fiber.Map{ + "valid": true, + "username": username, + "sessionName": sessionName, + }) +} + // GetRoom handles GET /api/internal/bossraid/room // Query param: sessionName func (h *Handler) GetRoom(c *fiber.Ctx) error { diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index 2a6c319..4efb72d 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -1,21 +1,41 @@ package bossraid import ( + "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "log" "time" + "github.com/redis/go-redis/v9" "github.com/tolelom/tolchain/core" ) -type Service struct { - repo *Repository - rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error +const ( + // entryTokenTTL is the TTL for boss raid entry tokens in Redis. + entryTokenTTL = 5 * time.Minute + // entryTokenPrefix is the Redis key prefix for entry token → {username, sessionName}. + entryTokenPrefix = "bossraid:entry:" + // pendingEntryPrefix is the Redis key prefix for username → {sessionName, entryToken}. + pendingEntryPrefix = "bossraid:pending:" +) + +// entryTokenData is stored in Redis for each entry token. +type entryTokenData struct { + Username string `json:"username"` + SessionName string `json:"sessionName"` } -func NewService(repo *Repository) *Service { - return &Service{repo: repo} +type Service struct { + repo *Repository + rdb *redis.Client + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error +} + +func NewService(repo *Repository, rdb *redis.Client) *Service { + return &Service{repo: repo, rdb: rdb} } // SetRewardGranter sets the callback for granting rewards via blockchain. @@ -213,3 +233,108 @@ func (s *Service) FailRaid(sessionName string) (*BossRoom, error) { func (s *Service) GetRoom(sessionName string) (*BossRoom, error) { return s.repo.FindBySessionName(sessionName) } + +// generateToken creates a cryptographically random hex token. +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// GenerateEntryTokens creates entry tokens for all players in a room +// and stores them in Redis. Returns a map of username → entryToken. +func (s *Service) GenerateEntryTokens(sessionName string, usernames []string) (map[string]string, error) { + ctx := context.Background() + tokens := make(map[string]string, len(usernames)) + + for _, username := range usernames { + token, err := generateToken() + if err != nil { + return nil, fmt.Errorf("토큰 생성 실패: %w", err) + } + tokens[username] = token + + // Store entry token → {username, sessionName} + data, _ := json.Marshal(entryTokenData{ + Username: username, + SessionName: sessionName, + }) + entryKey := entryTokenPrefix + token + if err := s.rdb.Set(ctx, entryKey, string(data), entryTokenTTL).Err(); err != nil { + return nil, fmt.Errorf("Redis 저장 실패: %w", err) + } + + // Store pending entry: username → {sessionName, entryToken} + pendingData, _ := json.Marshal(map[string]string{ + "sessionName": sessionName, + "entryToken": token, + }) + pendingKey := pendingEntryPrefix + username + if err := s.rdb.Set(ctx, pendingKey, string(pendingData), entryTokenTTL).Err(); err != nil { + return nil, fmt.Errorf("Redis 저장 실패: %w", err) + } + } + + return tokens, nil +} + +// ValidateEntryToken validates and consumes a one-time entry token. +// Returns the username and sessionName if valid. +func (s *Service) ValidateEntryToken(token string) (username, sessionName string, err error) { + ctx := context.Background() + key := entryTokenPrefix + token + + val, err := s.rdb.GetDel(ctx, key).Result() + if err == redis.Nil { + return "", "", fmt.Errorf("유효하지 않거나 만료된 입장 토큰입니다") + } + if err != nil { + return "", "", fmt.Errorf("토큰 검증 실패: %w", err) + } + + var data entryTokenData + if err := json.Unmarshal([]byte(val), &data); err != nil { + return "", "", fmt.Errorf("토큰 데이터 파싱 실패: %w", err) + } + + return data.Username, data.SessionName, nil +} + +// GetMyEntryToken returns the pending entry token for a username. +func (s *Service) GetMyEntryToken(username string) (sessionName, entryToken string, err error) { + ctx := context.Background() + key := pendingEntryPrefix + username + + val, err := s.rdb.Get(ctx, key).Result() + if err == redis.Nil { + return "", "", fmt.Errorf("대기 중인 입장 토큰이 없습니다") + } + if err != nil { + return "", "", fmt.Errorf("토큰 조회 실패: %w", err) + } + + var data map[string]string + if err := json.Unmarshal([]byte(val), &data); err != nil { + return "", "", fmt.Errorf("토큰 데이터 파싱 실패: %w", err) + } + + return data["sessionName"], data["entryToken"], nil +} + +// RequestEntryWithTokens creates a boss room and generates entry tokens for all players. +// Returns the room and a map of username → entryToken. +func (s *Service) RequestEntryWithTokens(usernames []string, bossID int) (*BossRoom, map[string]string, error) { + room, err := s.RequestEntry(usernames, bossID) + if err != nil { + return nil, nil, err + } + + tokens, err := s.GenerateEntryTokens(room.SessionName, usernames) + if err != nil { + return nil, nil, fmt.Errorf("입장 토큰 생성 실패: %w", err) + } + + return room, tokens, nil +} diff --git a/internal/player/handler.go b/internal/player/handler.go new file mode 100644 index 0000000..e497f00 --- /dev/null +++ b/internal/player/handler.go @@ -0,0 +1,84 @@ +package player + +import ( + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +// GetProfile 자신의 프로필 조회 (JWT 인증) +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": "인증 정보가 올바르지 않습니다"}) + } + + profile, err := h.svc.GetProfile(userID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(profile) +} + +// UpdateProfile 자신의 프로필 수정 (JWT 인증) +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": "인증 정보가 올바르지 않습니다"}) + } + + var req struct { + Nickname string `json:"nickname"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + + profile, err := h.svc.UpdateProfile(userID, req.Nickname) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(profile) +} + +// InternalGetProfile 내부 API: username 쿼리 파라미터로 프로필 조회 +func (h *Handler) InternalGetProfile(c *fiber.Ctx) error { + username := c.Query("username") + if username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"}) + } + + profile, err := h.svc.GetProfileByUsername(username) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(profile) +} + +// InternalSaveGameData 내부 API: username 쿼리 파라미터로 게임 데이터 저장 +func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error { + username := c.Query("username") + if username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username 파라미터가 필요합니다"}) + } + + var req GameDataRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + + if err := h.svc.SaveGameDataByUsername(username, &req); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"message": "게임 데이터가 저장되었습니다"}) +} diff --git a/internal/player/model.go b/internal/player/model.go new file mode 100644 index 0000000..9918d08 --- /dev/null +++ b/internal/player/model.go @@ -0,0 +1,37 @@ +package player + +import ( + "time" + + "gorm.io/gorm" +) + +type PlayerProfile struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + UserID uint `json:"userId" gorm:"uniqueIndex;not null"` + Nickname string `json:"nickname" gorm:"type:varchar(50);not null;default:''"` + + // 레벨 & 경험치 + Level int `json:"level" gorm:"default:1"` + Experience int `json:"experience" gorm:"default:0"` + + // 전투 스탯 + MaxHP float64 `json:"maxHp" gorm:"default:100"` + MaxMP float64 `json:"maxMp" gorm:"default:50"` + AttackPower float64 `json:"attackPower" gorm:"default:10"` + AttackRange float64 `json:"attackRange" gorm:"default:3"` + SprintMultiplier float64 `json:"sprintMultiplier" gorm:"default:1.8"` + + // 마지막 위치 + LastPosX float64 `json:"lastPosX" gorm:"default:0"` + LastPosY float64 `json:"lastPosY" gorm:"default:0"` + LastPosZ float64 `json:"lastPosZ" gorm:"default:0"` + LastRotY float64 `json:"lastRotY" gorm:"default:0"` + + // 플레이 시간 (초 단위) + TotalPlayTime int64 `json:"totalPlayTime" gorm:"default:0"` +} diff --git a/internal/player/repository.go b/internal/player/repository.go new file mode 100644 index 0000000..b7f15e1 --- /dev/null +++ b/internal/player/repository.go @@ -0,0 +1,41 @@ +package player + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(profile *PlayerProfile) error { + return r.db.Create(profile).Error +} + +func (r *Repository) FindByUserID(userID uint) (*PlayerProfile, error) { + var profile PlayerProfile + if err := r.db.Where("user_id = ?", userID).First(&profile).Error; err != nil { + return nil, err + } + return &profile, nil +} + +func (r *Repository) Update(profile *PlayerProfile) error { + return r.db.Save(profile).Error +} + +func (r *Repository) UpdateStats(userID uint, updates map[string]interface{}) error { + return r.db.Model(&PlayerProfile{}).Where("user_id = ?", userID).Updates(updates).Error +} + +func (r *Repository) FindByUsername(username string) (*PlayerProfile, error) { + var profile PlayerProfile + if err := r.db.Joins("JOIN users ON users.id = player_profiles.user_id"). + Where("users.username = ? AND users.deleted_at IS NULL", username). + First(&profile).Error; err != nil { + return nil, err + } + return &profile, nil +} diff --git a/internal/player/service.go b/internal/player/service.go new file mode 100644 index 0000000..df71863 --- /dev/null +++ b/internal/player/service.go @@ -0,0 +1,148 @@ +package player + +import ( + "fmt" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + userResolver func(username string) (uint, error) +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) SetUserResolver(fn func(username string) (uint, error)) { + s.userResolver = fn +} + +// CreateProfile 회원가입 시 자동 호출되어 기본 프로필을 생성한다. +func (s *Service) CreateProfile(userID uint) error { + profile := &PlayerProfile{ + UserID: userID, + } + return s.repo.Create(profile) +} + +// GetProfile JWT 인증된 유저의 프로필을 조회한다. +func (s *Service) GetProfile(userID uint) (*PlayerProfile, error) { + profile, err := s.repo.FindByUserID(userID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("프로필이 존재하지 않습니다") + } + return nil, fmt.Errorf("프로필 조회에 실패했습니다") + } + return profile, nil +} + +// GetProfileByUsername 내부 API용: username으로 프로필 조회. +func (s *Service) 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) +} + +// UpdateProfile 유저 자신의 프로필(닉네임)을 수정한다. +func (s *Service) 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 +} + +// SaveGameData 게임 서버에서 호출: 게임 데이터를 저장한다. +func (s *Service) SaveGameData(userID uint, data *GameDataRequest) error { + 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 { + // 플레이 시간은 delta로 누적 + profile, err := s.repo.FindByUserID(userID) + if err != nil { + return fmt.Errorf("프로필이 존재하지 않습니다") + } + updates["total_play_time"] = profile.TotalPlayTime + *data.PlayTimeDelta + } + + if len(updates) == 0 { + return nil + } + + return s.repo.UpdateStats(userID, updates) +} + +// SaveGameDataByUsername 내부 API용: username 기반으로 게임 데이터 저장. +func (s *Service) SaveGameDataByUsername(username string, data *GameDataRequest) error { + if s.userResolver == nil { + return fmt.Errorf("userResolver가 설정되지 않았습니다") + } + userID, err := s.userResolver(username) + if err != nil { + return fmt.Errorf("존재하지 않는 유저입니다") + } + return s.SaveGameData(userID, data) +} + +// GameDataRequest 게임 데이터 저장 요청 (nil 필드는 변경하지 않음). +type GameDataRequest struct { + Level *int `json:"level,omitempty"` + Experience *int `json:"experience,omitempty"` + MaxHP *float64 `json:"maxHp,omitempty"` + MaxMP *float64 `json:"maxMp,omitempty"` + AttackPower *float64 `json:"attackPower,omitempty"` + AttackRange *float64 `json:"attackRange,omitempty"` + SprintMultiplier *float64 `json:"sprintMultiplier,omitempty"` + LastPosX *float64 `json:"lastPosX,omitempty"` + LastPosY *float64 `json:"lastPosY,omitempty"` + LastPosZ *float64 `json:"lastPosZ,omitempty"` + LastRotY *float64 `json:"lastRotY,omitempty"` + PlayTimeDelta *int64 `json:"playTimeDelta,omitempty"` // 누적할 플레이 시간(초) +} diff --git a/main.go b/main.go index 2bc453d..b8cfb0b 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "log" "os" "os/signal" + "strconv" "syscall" "time" @@ -12,6 +13,7 @@ import ( "a301_server/internal/bossraid" "a301_server/internal/chain" "a301_server/internal/download" + "a301_server/internal/player" "github.com/tolelom/tolchain/core" "a301_server/pkg/config" @@ -35,7 +37,7 @@ func main() { log.Println("MySQL 연결 성공") // AutoMigrate - if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}); err != nil { + if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &player.PlayerProfile{}); err != nil { log.Fatalf("AutoMigrate 실패: %v", err) } @@ -49,13 +51,6 @@ func main() { authSvc := auth.NewService(authRepo, database.RDB) authHandler := auth.NewHandler(authSvc) - // 초기 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) - } - // Chain (blockchain integration) chainClient := chain.NewClient(config.C.ChainNodeURL) chainRepo := chain.NewRepository(database.DB) @@ -80,9 +75,33 @@ func main() { return err }) + // Player Profile + playerRepo := player.NewRepository(database.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 + }) + 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) + brSvc := bossraid.NewService(brRepo, database.RDB) brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { _, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) return err @@ -105,7 +124,11 @@ func main() { StreamRequestBody: true, BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB }) - app.Use(logger.New()) + 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", @@ -137,7 +160,40 @@ func main() { }, }) - routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, authLimiter, apiLimiter) + // 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"}) + } + + // 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) // Graceful shutdown go func() { @@ -148,6 +204,22 @@ func main() { 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 { + log.Printf("Redis 종료 실패: %v", err) + } else { + log.Println("Redis 연결 종료 완료") + } + } + // MySQL 연결 정리 + if sqlDB, err := database.DB.DB(); err == nil { + if err := sqlDB.Close(); err != nil { + log.Printf("MySQL 종료 실패: %v", err) + } else { + log.Println("MySQL 연결 종료 완료") + } + } }() log.Fatal(app.Listen(":" + config.C.AppPort)) diff --git a/pkg/config/config.go b/pkg/config/config.go index 850330b..60a3765 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -77,19 +77,30 @@ func Load() { } // WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults. +// In production mode (APP_ENV=production), insecure defaults cause a fatal exit. func WarnInsecureDefaults() { + isProd := getEnv("APP_ENV", "") == "production" + + insecure := false if C.JWTSecret == "secret" { log.Println("WARNING: JWT_SECRET is using the default value — set a strong secret for production") + insecure = true } if C.RefreshSecret == "refresh-secret" { log.Println("WARNING: REFRESH_SECRET is using the default value — set a strong secret for production") + insecure = true } if C.AdminPassword == "admin1234" { log.Println("WARNING: ADMIN_PASSWORD is using the default value — change it for production") + insecure = true } if C.WalletEncryptionKey == "" { log.Println("WARNING: WALLET_ENCRYPTION_KEY is empty — blockchain wallet features will fail") } + + if isProd && insecure { + log.Fatal("FATAL: insecure default secrets detected in production — set JWT_SECRET, REFRESH_SECRET, and ADMIN_PASSWORD") + } } func getEnv(key, fallback string) string { diff --git a/pkg/database/mysql.go b/pkg/database/mysql.go index 21eb4d8..7ff91e4 100644 --- a/pkg/database/mysql.go +++ b/pkg/database/mysql.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "time" "a301_server/pkg/config" "gorm.io/driver/mysql" @@ -19,6 +20,15 @@ func ConnectMySQL() error { if err != nil { return err } + + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("sql.DB 획득 실패: %w", err) + } + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(5 * time.Minute) + DB = db return nil } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 3235b9b..96ca433 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -76,7 +76,7 @@ func ServerAuth(c *fiber.Ctx) error { key := c.Get("X-API-Key") expected := config.C.InternalAPIKey if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 { - log.Printf("ServerAuth 실패: IP=%s, Path=%s, KeyPresent=%v", c.IP(), c.Path(), key != "") + log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path()) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"}) } return c.Next() diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go index 8be6570..77babeb 100644 --- a/pkg/middleware/idempotency.go +++ b/pkg/middleware/idempotency.go @@ -26,6 +26,9 @@ func Idempotency(c *fiber.Ctx) error { if key == "" { return c.Next() } + if len(key) > 256 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Idempotency-Key가 너무 깁니다"}) + } // userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지 redisKey := "idempotency:" diff --git a/pkg/middleware/requestid.go b/pkg/middleware/requestid.go new file mode 100644 index 0000000..00eff46 --- /dev/null +++ b/pkg/middleware/requestid.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// RequestID generates a unique request ID for each request and stores it in Locals and response header. +func RequestID(c *fiber.Ctx) error { + id := c.Get("X-Request-ID") + if id == "" { + id = uuid.NewString() + } + c.Locals("requestID", id) + c.Set("X-Request-ID", id) + return c.Next() +} diff --git a/pkg/middleware/security.go b/pkg/middleware/security.go index 64e671c..260c4bb 100644 --- a/pkg/middleware/security.go +++ b/pkg/middleware/security.go @@ -9,5 +9,6 @@ func SecurityHeaders(c *fiber.Ctx) error { c.Set("X-XSS-Protection", "0") c.Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") return c.Next() } diff --git a/routes/routes.go b/routes/routes.go index 77c9be2..0f96407 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -6,6 +6,7 @@ import ( "a301_server/internal/bossraid" "a301_server/internal/chain" "a301_server/internal/download" + "a301_server/internal/player" "a301_server/pkg/middleware" "github.com/gofiber/fiber/v2" ) @@ -17,9 +18,17 @@ func Register( dlH *download.Handler, chainH *chain.Handler, brH *bossraid.Handler, + playerH *player.Handler, authLimiter fiber.Handler, apiLimiter fiber.Handler, + healthCheck fiber.Handler, + readyCheck fiber.Handler, + chainUserLimiter fiber.Handler, ) { + // Health / Ready (rate limiter 밖) + app.Get("/health", healthCheck) + app.Get("/ready", readyCheck) + api := app.Group("/api", apiLimiter) // Auth @@ -63,14 +72,14 @@ func Register( ch.Get("/market", chainH.GetMarketListings) ch.Get("/market/:id", chainH.GetMarketListing) - // Chain - User Transactions (authenticated, idempotency-protected) - ch.Post("/transfer", middleware.Idempotency, chainH.Transfer) - ch.Post("/asset/transfer", middleware.Idempotency, chainH.TransferAsset) - ch.Post("/market/list", middleware.Idempotency, chainH.ListOnMarket) - ch.Post("/market/buy", middleware.Idempotency, chainH.BuyFromMarket) - ch.Post("/market/cancel", middleware.Idempotency, chainH.CancelListing) - ch.Post("/inventory/equip", middleware.Idempotency, chainH.EquipItem) - ch.Post("/inventory/unequip", middleware.Idempotency, chainH.UnequipItem) + // Chain - User Transactions (authenticated, per-user rate limited, idempotency-protected) + ch.Post("/transfer", chainUserLimiter, middleware.Idempotency, chainH.Transfer) + ch.Post("/asset/transfer", chainUserLimiter, middleware.Idempotency, chainH.TransferAsset) + ch.Post("/market/list", chainUserLimiter, middleware.Idempotency, chainH.ListOnMarket) + ch.Post("/market/buy", chainUserLimiter, middleware.Idempotency, chainH.BuyFromMarket) + ch.Post("/market/cancel", chainUserLimiter, middleware.Idempotency, chainH.CancelListing) + ch.Post("/inventory/equip", chainUserLimiter, middleware.Idempotency, chainH.EquipItem) + ch.Post("/inventory/unequip", chainUserLimiter, middleware.Idempotency, chainH.UnequipItem) // Chain - Admin Transactions (admin only, idempotency-protected) chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly) @@ -78,6 +87,11 @@ func Register( chainAdmin.Post("/reward", middleware.Idempotency, chainH.GrantReward) chainAdmin.Post("/template", middleware.Idempotency, chainH.RegisterTemplate) + // Boss Raid - Client entry (JWT authenticated) + bossRaid := api.Group("/bossraid", middleware.Auth) + bossRaid.Post("/entry", brH.RequestEntryAuth) + bossRaid.Get("/my-entry-token", brH.GetMyEntryToken) + // Internal - Boss Raid (API key auth) br := api.Group("/internal/bossraid", middleware.ServerAuth) br.Post("/entry", brH.RequestEntry) @@ -85,6 +99,17 @@ func Register( br.Post("/complete", middleware.Idempotency, brH.CompleteRaid) br.Post("/fail", brH.FailRaid) br.Get("/room", brH.GetRoom) + br.Post("/validate-entry", brH.ValidateEntryToken) + + // Player Profile (authenticated) + p := api.Group("/player", middleware.Auth) + p.Get("/profile", playerH.GetProfile) + p.Put("/profile", playerH.UpdateProfile) + + // Internal - Player (API key auth) + internalPlayer := api.Group("/internal/player", middleware.ServerAuth) + internalPlayer.Get("/profile", playerH.InternalGetProfile) + internalPlayer.Post("/save", playerH.InternalSaveGameData) // Internal - Game server endpoints (API key auth, username-based, idempotency-protected) internal := api.Group("/internal/chain", middleware.ServerAuth)