feat: 보스 레이드 방 관리 모듈 추가
All checks were successful
Server CI/CD / deploy (push) Successful in 1m34s

MMO 서버/데디케이트 서버 연동을 위한 내부 API 엔드포인트 구현:
- POST /api/internal/bossraid/entry — 파티 입장 요청 (방 생성)
- POST /api/internal/bossraid/start — 세션 시작 보고
- POST /api/internal/bossraid/complete — 클리어 보고 + TOL Chain 보상 지급
- POST /api/internal/bossraid/fail — 실패 보고
- GET /api/internal/bossraid/room — 방 조회

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:14:03 +09:00
parent 23bec776ab
commit 61cf47070d
6 changed files with 413 additions and 2 deletions

View File

@@ -0,0 +1,141 @@
package bossraid
import (
"log"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func bossError(c *fiber.Ctx, status int, userMsg string, err error) error {
log.Printf("bossraid error: %s: %v", userMsg, err)
return c.Status(status).JSON(fiber.Map{"error": userMsg})
}
// RequestEntry handles POST /api/internal/bossraid/entry
// Called by MMO server when a party requests boss raid entry.
func (h *Handler) RequestEntry(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": "잘못된 요청입니다"})
}
if len(req.Usernames) == 0 || req.BossID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usernames와 bossId는 필수입니다"})
}
room, err := h.svc.RequestEntry(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,
})
}
// StartRaid handles POST /api/internal/bossraid/start
// Called by dedicated server when the Fusion session begins.
func (h *Handler) StartRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
}
room, err := h.svc.StartRaid(req.SessionName)
if err != nil {
return bossError(c, fiber.StatusBadRequest, err.Error(), err)
}
return c.JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"status": room.Status,
})
}
// CompleteRaid handles POST /api/internal/bossraid/complete
// Called by dedicated server when the boss is killed. Distributes rewards.
func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
Rewards []PlayerReward `json:"rewards"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
}
room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards)
if err != nil {
return bossError(c, fiber.StatusBadRequest, err.Error(), err)
}
return c.JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"status": room.Status,
"rewardResults": results,
})
}
// FailRaid handles POST /api/internal/bossraid/fail
// Called by dedicated server on timeout or party wipe.
func (h *Handler) FailRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.SessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
}
room, err := h.svc.FailRaid(req.SessionName)
if err != nil {
return bossError(c, fiber.StatusBadRequest, err.Error(), err)
}
return c.JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"status": room.Status,
})
}
// GetRoom handles GET /api/internal/bossraid/room
// Query param: sessionName
func (h *Handler) GetRoom(c *fiber.Ctx) error {
sessionName := c.Query("sessionName")
if sessionName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"})
}
room, err := h.svc.GetRoom(sessionName)
if err != nil {
return bossError(c, fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
}
return c.JSON(room)
}

View File

@@ -0,0 +1,31 @@
package bossraid
import (
"time"
"gorm.io/gorm"
)
type RoomStatus string
const (
StatusWaiting RoomStatus = "waiting"
StatusInProgress RoomStatus = "in_progress"
StatusCompleted RoomStatus = "completed"
StatusFailed RoomStatus = "failed"
)
// BossRoom represents a boss raid session room.
type BossRoom struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
BossID int `json:"bossId" gorm:"index;not null"`
Status RoomStatus `json:"status" gorm:"type:varchar(20);index;default:waiting;not null"`
MaxPlayers int `json:"maxPlayers" gorm:"default:3;not null"`
Players string `json:"players" gorm:"type:text"` // JSON array of usernames
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}

View File

@@ -0,0 +1,39 @@
package bossraid
import "gorm.io/gorm"
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(room *BossRoom) error {
return r.db.Create(room).Error
}
func (r *Repository) Update(room *BossRoom) error {
return r.db.Save(room).Error
}
func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) {
var room BossRoom
if err := r.db.Where("session_name = ?", sessionName).First(&room).Error; err != nil {
return nil, err
}
return &room, nil
}
// CountActiveByUsername checks if a player is already in an active boss raid.
func (r *Repository) CountActiveByUsername(username string) (int64, error) {
var count int64
search := `"` + username + `"`
err := r.db.Model(&BossRoom{}).
Where("status IN ? AND players LIKE ?",
[]RoomStatus{StatusWaiting, StatusInProgress},
"%"+search+"%",
).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,178 @@
package bossraid
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/tolelom/tolchain/core"
)
type Service struct {
repo *Repository
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
// SetRewardGranter sets the callback for granting rewards via blockchain.
func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error) {
s.rewardGrant = fn
}
// RequestEntry creates a new boss room for a party.
// Returns the room with assigned session name.
func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error) {
if len(usernames) == 0 {
return nil, fmt.Errorf("플레이어 목록이 비어있습니다")
}
if len(usernames) > 3 {
return nil, fmt.Errorf("최대 3명까지 입장할 수 있습니다")
}
// Check if any player is already in an active room
for _, username := range usernames {
count, err := s.repo.CountActiveByUsername(username)
if err != nil {
return nil, fmt.Errorf("플레이어 상태 확인 실패: %w", err)
}
if count > 0 {
return nil, fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username)
}
}
playersJSON, err := json.Marshal(usernames)
if err != nil {
return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err)
}
sessionName := fmt.Sprintf("BossRaid_%d_%d", bossID, time.Now().UnixNano())
room := &BossRoom{
SessionName: sessionName,
BossID: bossID,
Status: StatusWaiting,
MaxPlayers: 3,
Players: string(playersJSON),
}
if err := s.repo.Create(room); err != nil {
return nil, fmt.Errorf("방 생성 실패: %w", err)
}
return room, nil
}
// StartRaid marks a room as in_progress.
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting {
return nil, fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusInProgress
room.StartedAt = &now
if err := s.repo.Update(room); err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
return room, nil
}
// PlayerReward describes the reward for a single player.
type PlayerReward struct {
Username string `json:"username"`
TokenAmount uint64 `json:"tokenAmount"`
Assets []core.MintAssetPayload `json:"assets"`
}
// RewardResult holds the result of granting a reward to one player.
type RewardResult struct {
Username string `json:"username"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// CompleteRaid marks a room as completed and grants rewards via blockchain.
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusInProgress {
return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
}
// Validate reward recipients are room players
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
playerSet[p] = true
}
for _, r := range rewards {
if !playerSet[r.Username] {
return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
}
}
// Mark room completed
now := time.Now()
room.Status = StatusCompleted
room.CompletedAt = &now
if err := s.repo.Update(room); err != nil {
return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Grant rewards
results := make([]RewardResult, 0, len(rewards))
if s.rewardGrant != nil {
for _, r := range rewards {
grantErr := s.rewardGrant(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)
}
results = append(results, result)
}
}
return room, results, nil
}
// FailRaid marks a room as failed.
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting && room.Status != StatusInProgress {
return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusFailed
room.CompletedAt = &now
if err := s.repo.Update(room); err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
return room, nil
}
// GetRoom returns a room by session name.
func (s *Service) GetRoom(sessionName string) (*BossRoom, error) {
return s.repo.FindBySessionName(sessionName)
}

16
main.go
View File

@@ -5,8 +5,11 @@ import (
"a301_server/internal/announcement"
"a301_server/internal/auth"
"a301_server/internal/bossraid"
"a301_server/internal/chain"
"a301_server/internal/download"
"github.com/tolelom/tolchain/core"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/routes"
@@ -27,7 +30,7 @@ func main() {
log.Println("MySQL 연결 성공")
// AutoMigrate
database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{})
database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{})
if err := database.ConnectRedis(); err != nil {
log.Fatalf("Redis 연결 실패: %v", err)
@@ -70,6 +73,15 @@ func main() {
return err
})
// Boss Raid
brRepo := bossraid.NewRepository(database.DB)
brSvc := bossraid.NewService(brRepo)
brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err
})
brHandler := bossraid.NewHandler(brSvc)
if config.C.InternalAPIKey == "" {
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
}
@@ -117,7 +129,7 @@ func main() {
},
})
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, authLimiter, apiLimiter)
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, authLimiter, apiLimiter)
log.Fatal(app.Listen(":" + config.C.AppPort))
}

View File

@@ -3,6 +3,7 @@ package routes
import (
"a301_server/internal/announcement"
"a301_server/internal/auth"
"a301_server/internal/bossraid"
"a301_server/internal/chain"
"a301_server/internal/download"
"a301_server/pkg/middleware"
@@ -15,6 +16,7 @@ func Register(
annH *announcement.Handler,
dlH *download.Handler,
chainH *chain.Handler,
brH *bossraid.Handler,
authLimiter fiber.Handler,
apiLimiter fiber.Handler,
) {
@@ -76,6 +78,14 @@ func Register(
chainAdmin.Post("/reward", middleware.Idempotency, chainH.GrantReward)
chainAdmin.Post("/template", middleware.Idempotency, chainH.RegisterTemplate)
// Internal - Boss Raid (API key auth)
br := api.Group("/internal/bossraid", middleware.ServerAuth)
br.Post("/entry", brH.RequestEntry)
br.Post("/start", brH.StartRaid)
br.Post("/complete", middleware.Idempotency, brH.CompleteRaid)
br.Post("/fail", brH.FailRaid)
br.Get("/room", brH.GetRoom)
// Internal - Game server endpoints (API key auth, username-based, idempotency-protected)
internal := api.Group("/internal/chain", middleware.ServerAuth)
internal.Post("/reward", middleware.Idempotency, chainH.InternalGrantReward)