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:
141
internal/bossraid/handler.go
Normal file
141
internal/bossraid/handler.go
Normal 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)
|
||||
}
|
||||
31
internal/bossraid/model.go
Normal file
31
internal/bossraid/model.go
Normal 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"`
|
||||
}
|
||||
39
internal/bossraid/repository.go
Normal file
39
internal/bossraid/repository.go
Normal 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
|
||||
}
|
||||
178
internal/bossraid/service.go
Normal file
178
internal/bossraid/service.go
Normal 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
16
main.go
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user