Add Maia chess gameplay and deployment
Some checks failed
Deploy Blog Backend / test (push) Successful in 2m6s
Deploy Blog Backend / deploy (push) Failing after 1m8s

This commit is contained in:
박원엽
2026-06-19 14:49:20 +09:00
parent b47258ea53
commit 3042dc898e
26 changed files with 1682 additions and 32 deletions

View File

@@ -0,0 +1,82 @@
package me.wypark.blogbackend.api.controller
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
import me.wypark.blogbackend.api.dto.ChessGameResponse
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
import me.wypark.blogbackend.api.dto.ChessMoveRequest
import me.wypark.blogbackend.domain.auth.CustomUserDetails
import me.wypark.blogbackend.domain.chess.ChessGameService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chess/games")
class ChessGameController(
private val chessGameService: ChessGameService
) {
@PostMapping
fun createGame(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@Valid @RequestBody request: ChessGameCreateRequest
): ResponseEntity<ApiResponse<ChessGameResponse>> {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.success(chessGameService.createGame(userDetails.memberId, request)))
}
@GetMapping
fun getGames(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PageableDefault(size = 20, sort = ["updatedAt"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<ChessGameSummaryResponse>>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGames(userDetails.memberId, pageable)))
}
@GetMapping("/stats")
fun getStats(
@AuthenticationPrincipal userDetails: CustomUserDetails
): ResponseEntity<ApiResponse<ChessGameStatsResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getStats(userDetails.memberId)))
}
@GetMapping("/{gameId}")
fun getGame(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PathVariable gameId: String
): ResponseEntity<ApiResponse<ChessGameResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGame(userDetails.memberId, gameId)))
}
@GetMapping("/{gameId}/pgn")
fun getPgn(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PathVariable gameId: String
): ResponseEntity<ApiResponse<ChessGamePgnResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getPgn(userDetails.memberId, gameId)))
}
@PostMapping("/{gameId}/moves")
fun playMove(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PathVariable gameId: String,
@Valid @RequestBody request: ChessMoveRequest
): ResponseEntity<ApiResponse<ChessGameResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request)))
}
}

View File

@@ -0,0 +1,153 @@
package me.wypark.blogbackend.api.dto
import jakarta.validation.constraints.DecimalMax
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Pattern
import me.wypark.blogbackend.domain.chess.ChessGameHistoryStats
import me.wypark.blogbackend.domain.chess.ChessGameRecord
import me.wypark.blogbackend.domain.chess.ChessGameSession
import java.time.LocalDateTime
data class ChessGameCreateRequest(
@field:Min(600, message = "레이팅은 600 이상이어야 합니다.")
@field:Max(2600, message = "레이팅은 2600 이하여야 합니다.")
val rating: Int = 1500,
@field:Pattern(
regexp = "(?i)^(white|black)$",
message = "playerColor는 white 또는 black이어야 합니다."
)
val playerColor: String = "white",
@field:Pattern(
regexp = "^(3m|5m|23m|79m)$",
message = "model은 3m, 5m, 23m, 79m 중 하나여야 합니다."
)
val model: String = "5m",
@field:DecimalMin(value = "0.0", message = "temperature는 0.0 이상이어야 합니다.")
@field:DecimalMax(value = "2.0", message = "temperature는 2.0 이하여야 합니다.")
val temperature: Double = 0.8,
@field:DecimalMin(value = "0.0", message = "topP는 0.0 이상이어야 합니다.")
@field:DecimalMax(value = "1.0", message = "topP는 1.0 이하여야 합니다.")
val topP: Double = 0.95
)
data class ChessMoveRequest(
@field:Pattern(
regexp = "^[a-h][1-8][a-h][1-8][qrbn]?$",
message = "move는 UCI 형식이어야 합니다. 예: e2e4, e7e8q"
)
val move: String
)
data class ChessGameResponse(
val gameId: String,
val rating: Int,
val playerColor: String,
val model: String,
val fen: String,
val turn: String,
val moves: List<String>,
val status: String,
val result: String?,
val outcome: String,
val pgn: String,
val maiaMove: String?
) {
companion object {
fun from(session: ChessGameSession, maiaMove: String? = null): ChessGameResponse {
return ChessGameResponse(
gameId = session.gameId,
rating = session.rating,
playerColor = session.playerColor.value,
model = session.model,
fen = session.fen,
turn = session.turn.value,
moves = session.moves,
status = session.status,
result = session.result,
outcome = session.outcome().name,
pgn = session.pgn,
maiaMove = maiaMove
)
}
fun from(record: ChessGameRecord, maiaMove: String? = null): ChessGameResponse {
return ChessGameResponse(
gameId = record.gameId,
rating = record.rating,
playerColor = record.playerColor.value,
model = record.model,
fen = record.fen,
turn = record.turn.value,
moves = record.moveList(),
status = record.status,
result = record.result,
outcome = record.outcome.name,
pgn = record.pgn,
maiaMove = maiaMove
)
}
}
}
data class ChessGameSummaryResponse(
val gameId: String,
val rating: Int,
val playerColor: String,
val model: String,
val status: String,
val result: String?,
val outcome: String,
val movesCount: Int,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
) {
companion object {
fun from(record: ChessGameRecord): ChessGameSummaryResponse {
return ChessGameSummaryResponse(
gameId = record.gameId,
rating = record.rating,
playerColor = record.playerColor.value,
model = record.model,
status = record.status,
result = record.result,
outcome = record.outcome.name,
movesCount = record.moveList().size,
createdAt = record.createdAt,
updatedAt = record.updatedAt
)
}
}
}
data class ChessGamePgnResponse(
val gameId: String,
val pgn: String
)
data class ChessGameStatsResponse(
val total: Long,
val inProgress: Long,
val wins: Long,
val losses: Long,
val draws: Long,
val unknown: Long
) {
companion object {
fun from(stats: ChessGameHistoryStats): ChessGameStatsResponse {
return ChessGameStatsResponse(
total = stats.total,
inProgress = stats.inProgress,
wins = stats.wins,
losses = stats.losses,
draws = stats.draws,
unknown = stats.unknown
)
}
}
}

View File

@@ -30,6 +30,8 @@ class CorsConfig {
// 2. 신뢰할 수 있는 출처(Origin) 명시
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
config.addAllowedOrigin("https://blog.wypark.me")
config.addAllowedOrigin("http://localhost:3000")
config.addAllowedOrigin("http://localhost:5173")
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
// 3. 허용할 HTTP 메서드 및 헤더
@@ -46,4 +48,4 @@ class CorsConfig {
source.registerCorsConfiguration("/api/**", config)
return CorsFilter(source)
}
}
}

View File

@@ -0,0 +1,29 @@
package me.wypark.blogbackend.core.config
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
import java.time.Duration
@Configuration
@EnableConfigurationProperties(MaiaProperties::class)
class MaiaConfig {
@Bean
fun maiaRestClient(
builder: RestClient.Builder,
properties: MaiaProperties
): RestClient {
return builder
.baseUrl(properties.engineUrl)
.build()
}
}
@ConfigurationProperties(prefix = "maia")
data class MaiaProperties(
val engineUrl: String = "http://localhost:8000",
val gameSessionTtl: Duration = Duration.ofHours(6)
)

View File

@@ -0,0 +1,69 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
interface ChessGameHistoryStore {
fun save(session: ChessGameSession)
fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord?
fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
fun getStats(memberId: Long): ChessGameHistoryStats
}
data class ChessGameHistoryStats(
val total: Long,
val inProgress: Long,
val wins: Long,
val losses: Long,
val draws: Long,
val unknown: Long
)
@Repository
class JpaChessGameHistoryStore(
private val chessGameRecordRepository: ChessGameRecordRepository,
private val memberRepository: MemberRepository
) : ChessGameHistoryStore {
@Transactional
override fun save(session: ChessGameSession) {
val existing = chessGameRecordRepository.findByGameId(session.gameId)
if (existing != null) {
require(existing.member.id == session.memberId) { "Chess game not found." }
existing.apply(session)
return
}
val member = memberRepository.getReferenceById(session.memberId)
chessGameRecordRepository.save(ChessGameRecord.from(member, session))
}
@Transactional(readOnly = true)
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? {
return chessGameRecordRepository.findByGameIdAndMember_Id(gameId, memberId)
}
@Transactional(readOnly = true)
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
return chessGameRecordRepository.findAllByMember_Id(memberId, pageable)
}
@Transactional(readOnly = true)
override fun getStats(memberId: Long): ChessGameHistoryStats {
return ChessGameHistoryStats(
total = chessGameRecordRepository.countByMember_Id(memberId),
inProgress = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.IN_PROGRESS),
wins = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.WIN),
losses = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.LOSS),
draws = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.DRAW),
unknown = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.UNKNOWN)
)
}
}

View File

@@ -0,0 +1,144 @@
package me.wypark.blogbackend.domain.chess
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import me.wypark.blogbackend.domain.user.Member
import java.time.ZoneId
@Entity
@Table(
name = "chess_game_record",
uniqueConstraints = [
UniqueConstraint(name = "uk_chess_game_record_game_id", columnNames = ["game_id"])
],
indexes = [
Index(name = "idx_chess_game_record_member_updated_at", columnList = "member_id, updated_at"),
Index(name = "idx_chess_game_record_member_outcome", columnList = "member_id, outcome")
]
)
class ChessGameRecord(
@Column(name = "game_id", nullable = false, length = 36)
val gameId: String,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id", nullable = false)
val member: Member,
@Column(nullable = false)
var rating: Int,
@Enumerated(EnumType.STRING)
@Column(name = "player_color", nullable = false, length = 10)
var playerColor: ChessSide,
@Column(nullable = false, length = 10)
var model: String,
@Column(nullable = false)
var temperature: Double,
@Column(name = "top_p", nullable = false)
var topP: Double,
@Column(nullable = false, columnDefinition = "TEXT")
var fen: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
var turn: ChessSide,
@Column(nullable = false, columnDefinition = "TEXT")
var moves: String,
@Column(nullable = false, length = 40)
var status: String,
@Column(length = 16)
var result: String?,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var outcome: ChessGameOutcome,
@Column(nullable = false, columnDefinition = "TEXT")
var pgn: String
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun apply(session: ChessGameSession) {
rating = session.rating
playerColor = session.playerColor
model = session.model
temperature = session.temperature
topP = session.topP
fen = session.fen
turn = session.turn
moves = session.moves.joinToString(" ")
status = session.status
result = session.result
outcome = session.outcome()
pgn = session.pgn
}
fun moveList(): List<String> {
return moves.split(" ").filter { it.isNotBlank() }
}
fun toSession(): ChessGameSession {
val zoneId = ZoneId.systemDefault()
val memberId = requireNotNull(member.id) { "member id is required." }
return ChessGameSession(
gameId = gameId,
memberId = memberId,
rating = rating,
playerColor = playerColor,
model = model,
temperature = temperature,
topP = topP,
fen = fen,
turn = turn,
moves = moveList(),
status = status,
result = result,
pgn = pgn,
createdAt = createdAt.atZone(zoneId).toInstant(),
updatedAt = updatedAt.atZone(zoneId).toInstant()
)
}
companion object {
fun from(member: Member, session: ChessGameSession): ChessGameRecord {
return ChessGameRecord(
gameId = session.gameId,
member = member,
rating = session.rating,
playerColor = session.playerColor,
model = session.model,
temperature = session.temperature,
topP = session.topP,
fen = session.fen,
turn = session.turn,
moves = session.moves.joinToString(" "),
status = session.status,
result = session.result,
outcome = session.outcome(),
pgn = session.pgn
)
}
}
}

View File

@@ -0,0 +1,18 @@
package me.wypark.blogbackend.domain.chess
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
interface ChessGameRecordRepository : JpaRepository<ChessGameRecord, Long> {
fun findByGameId(gameId: String): ChessGameRecord?
fun findByGameIdAndMember_Id(gameId: String, memberId: Long): ChessGameRecord?
fun findAllByMember_Id(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
fun countByMember_Id(memberId: Long): Long
fun countByMember_IdAndOutcome(memberId: Long, outcome: ChessGameOutcome): Long
}

View File

@@ -0,0 +1,176 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
import me.wypark.blogbackend.api.dto.ChessGameResponse
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
import me.wypark.blogbackend.api.dto.ChessMoveRequest
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Instant
import java.util.UUID
@Service
@Transactional(readOnly = true)
class ChessGameService(
private val chessGameStore: ChessGameStore,
private val chessGameHistoryStore: ChessGameHistoryStore,
private val maiaEngine: MaiaEngine,
private val clock: Clock
) {
@Transactional
fun createGame(memberId: Long, request: ChessGameCreateRequest): ChessGameResponse {
val playerColor = ChessSide.from(request.playerColor)
val now = Instant.now(clock)
val labels = playerLabels(playerColor, request.rating, request.model)
val initialState = maiaEngine.getState(
MaiaStateRequest(
moves = emptyList(),
white = labels.white,
black = labels.black
)
)
var session = ChessGameSession(
gameId = UUID.randomUUID().toString(),
memberId = memberId,
rating = request.rating,
playerColor = playerColor,
model = request.model,
temperature = request.temperature,
topP = request.topP,
fen = initialState.fen,
turn = ChessSide.from(initialState.turn),
moves = emptyList(),
status = initialState.status,
result = initialState.result,
pgn = initialState.pgn,
createdAt = now,
updatedAt = now
)
var maiaMove: String? = null
if (playerColor == ChessSide.BLACK) {
val maiaResponse = maiaEngine.playMove(session.toMaiaPlayRequest())
maiaMove = maiaResponse.move
session = session.applyEngineResponse(maiaResponse, now)
}
saveSession(session)
return ChessGameResponse.from(session, maiaMove)
}
fun getGames(memberId: Long, pageable: Pageable): Page<ChessGameSummaryResponse> {
return chessGameHistoryStore.findAllByMemberId(memberId, pageable)
.map { ChessGameSummaryResponse.from(it) }
}
fun getStats(memberId: Long): ChessGameStatsResponse {
return ChessGameStatsResponse.from(chessGameHistoryStore.getStats(memberId))
}
fun getGame(memberId: Long, gameId: String): ChessGameResponse {
val session = chessGameStore.findById(gameId)
if (session != null) {
requireOwnedBy(session, memberId)
return ChessGameResponse.from(session)
}
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
?: throw IllegalArgumentException("Chess game not found.")
return ChessGameResponse.from(record)
}
fun getPgn(memberId: Long, gameId: String): ChessGamePgnResponse {
val game = getGame(memberId, gameId)
return ChessGamePgnResponse(gameId = game.gameId, pgn = game.pgn)
}
@Transactional
fun playMove(memberId: Long, gameId: String, request: ChessMoveRequest): ChessGameResponse {
val session = getPlayableSession(memberId, gameId)
require(session.status == "IN_PROGRESS") { "This chess game is already finished." }
require(session.turn == session.playerColor) { "It is not the player's turn." }
val movesAfterPlayer = session.moves + request.move
val maiaResponse = maiaEngine.playMove(
session.toMaiaPlayRequest(moves = movesAfterPlayer)
)
val updated = session.applyEngineResponse(maiaResponse, Instant.now(clock), movesAfterPlayer)
saveSession(updated)
return ChessGameResponse.from(updated, maiaResponse.move)
}
private fun getPlayableSession(memberId: Long, gameId: String): ChessGameSession {
val session = chessGameStore.findById(gameId)
if (session != null) {
requireOwnedBy(session, memberId)
return session
}
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
?: throw IllegalArgumentException("Chess game not found.")
return record.toSession()
}
private fun saveSession(session: ChessGameSession) {
chessGameStore.save(session)
chessGameHistoryStore.save(session)
}
private fun requireOwnedBy(session: ChessGameSession, memberId: Long) {
require(session.memberId == memberId) { "Chess game not found." }
}
private fun playerLabels(playerColor: ChessSide, rating: Int, model: String): PlayerLabels {
val maiaName = "Maia3-$model-$rating"
return if (playerColor == ChessSide.WHITE) {
PlayerLabels(white = "Player", black = maiaName)
} else {
PlayerLabels(white = maiaName, black = "Player")
}
}
private fun ChessGameSession.toMaiaPlayRequest(
moves: List<String> = this.moves
): MaiaPlayRequest {
val labels = playerLabels(playerColor, rating, model)
return MaiaPlayRequest(
moves = moves,
rating = rating,
model = model,
temperature = temperature,
topP = topP,
white = labels.white,
black = labels.black
)
}
private fun ChessGameSession.applyEngineResponse(
response: MaiaPlayResponse,
now: Instant,
movesBeforeMaia: List<String> = moves
): ChessGameSession {
val nextMoves = response.move?.let { movesBeforeMaia + it } ?: movesBeforeMaia
return copy(
fen = response.fen,
turn = ChessSide.from(response.turn),
moves = nextMoves,
status = response.status,
result = response.result,
pgn = response.pgn,
updatedAt = now
)
}
private data class PlayerLabels(
val white: String,
val black: String
)
}

View File

@@ -0,0 +1,63 @@
package me.wypark.blogbackend.domain.chess
import java.time.Instant
data class ChessGameSession(
val gameId: String,
val memberId: Long,
val rating: Int,
val playerColor: ChessSide,
val model: String,
val temperature: Double,
val topP: Double,
val fen: String,
val turn: ChessSide,
val moves: List<String>,
val status: String,
val result: String?,
val pgn: String,
val createdAt: Instant,
val updatedAt: Instant
) {
fun outcome(): ChessGameOutcome {
return ChessGameOutcome.from(result = result, playerColor = playerColor, status = status)
}
}
enum class ChessSide(val value: String) {
WHITE("white"),
BLACK("black");
fun opposite(): ChessSide = if (this == WHITE) BLACK else WHITE
companion object {
fun from(value: String): ChessSide {
return entries.firstOrNull { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("playerColor must be white or black.")
}
}
}
enum class ChessGameOutcome {
IN_PROGRESS,
WIN,
LOSS,
DRAW,
UNKNOWN;
companion object {
fun from(result: String?, playerColor: ChessSide, status: String): ChessGameOutcome {
if (status == "IN_PROGRESS") {
return IN_PROGRESS
}
return when (result) {
"1-0" -> if (playerColor == ChessSide.WHITE) WIN else LOSS
"0-1" -> if (playerColor == ChessSide.BLACK) WIN else LOSS
"1/2-1/2" -> DRAW
null, "", "*" -> UNKNOWN
else -> UNKNOWN
}
}
}
}

View File

@@ -0,0 +1,8 @@
package me.wypark.blogbackend.domain.chess
interface ChessGameStore {
fun save(session: ChessGameSession): ChessGameSession
fun findById(gameId: String): ChessGameSession?
}

View File

@@ -0,0 +1,43 @@
package me.wypark.blogbackend.domain.chess
interface MaiaEngine {
fun getState(request: MaiaStateRequest): MaiaStateResponse
fun playMove(request: MaiaPlayRequest): MaiaPlayResponse
}
data class MaiaStateRequest(
val moves: List<String>,
val white: String? = null,
val black: String? = null,
val event: String = "Maia3"
)
data class MaiaPlayRequest(
val moves: List<String>,
val rating: Int,
val model: String,
val temperature: Double,
val topP: Double,
val white: String? = null,
val black: String? = null,
val event: String = "Maia3"
)
data class MaiaStateResponse(
val fen: String,
val turn: String,
val status: String,
val result: String?,
val pgn: String = ""
)
data class MaiaPlayResponse(
val move: String?,
val fen: String,
val turn: String,
val status: String,
val result: String?,
val pgn: String = ""
)

View File

@@ -0,0 +1,41 @@
package me.wypark.blogbackend.domain.chess
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
@Component
class MaiaEngineClient(
private val maiaRestClient: RestClient
) : MaiaEngine {
private val log = LoggerFactory.getLogger(this.javaClass)
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
return exchange("/maia/state", request, MaiaStateResponse::class.java)
}
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
return exchange("/maia/move", request, MaiaPlayResponse::class.java)
}
private fun <T : Any> exchange(path: String, request: Any, responseType: Class<T>): T {
try {
return maiaRestClient
.post()
.uri(path)
.body(request)
.retrieve()
.body(responseType)
?: throw IllegalStateException("Maia 엔진 응답이 비어 있습니다.")
} catch (e: RestClientResponseException) {
log.warn("Maia engine returned {}: {}", e.statusCode, e.responseBodyAsString)
throw IllegalArgumentException("Maia 엔진이 요청을 처리하지 못했습니다.")
} catch (e: RestClientException) {
log.error("Failed to call Maia engine", e)
throw IllegalStateException("Maia 엔진에 연결할 수 없습니다.")
}
}
}

View File

@@ -0,0 +1,32 @@
package me.wypark.blogbackend.domain.chess
import com.fasterxml.jackson.databind.ObjectMapper
import me.wypark.blogbackend.core.config.MaiaProperties
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Repository
import java.util.concurrent.TimeUnit
@Repository
class RedisChessGameStore(
private val redisTemplate: RedisTemplate<String, String>,
private val objectMapper: ObjectMapper,
private val maiaProperties: MaiaProperties
) : ChessGameStore {
override fun save(session: ChessGameSession): ChessGameSession {
redisTemplate.opsForValue().set(
key(session.gameId),
objectMapper.writeValueAsString(session),
maiaProperties.gameSessionTtl.toMillis(),
TimeUnit.MILLISECONDS
)
return session
}
override fun findById(gameId: String): ChessGameSession? {
val json = redisTemplate.opsForValue().get(key(gameId)) ?: return null
return objectMapper.readValue(json, ChessGameSession::class.java)
}
private fun key(gameId: String): String = "CHESS_GAME:$gameId"
}

View File

@@ -2,4 +2,8 @@ spring:
application:
name: blog-api
profiles:
default: prod
default: prod
maia:
engine-url: ${MAIA_ENGINE_URL:http://localhost:8000}
game-session-ttl: ${MAIA_GAME_SESSION_TTL:PT6H}

View File

@@ -0,0 +1,28 @@
CREATE TABLE chess_game_record (
id BIGSERIAL PRIMARY KEY,
game_id VARCHAR(36) NOT NULL,
member_id BIGINT NOT NULL,
rating INTEGER NOT NULL,
player_color VARCHAR(10) NOT NULL,
model VARCHAR(10) NOT NULL,
temperature DOUBLE PRECISION NOT NULL,
top_p DOUBLE PRECISION NOT NULL,
fen TEXT NOT NULL,
turn VARCHAR(10) NOT NULL,
moves TEXT NOT NULL DEFAULT '',
status VARCHAR(40) NOT NULL,
result VARCHAR(16),
outcome VARCHAR(20) NOT NULL,
pgn TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_chess_game_record_game_id UNIQUE (game_id),
CONSTRAINT fk_chess_game_record_member
FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE
);
CREATE INDEX idx_chess_game_record_member_updated_at
ON chess_game_record (member_id, updated_at DESC);
CREATE INDEX idx_chess_game_record_member_outcome
ON chess_game_record (member_id, outcome);

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.api.controller
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ChessGameControllerSecurityIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `chess game creation requires login`() {
mockMvc.perform(
post("/api/chess/games")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"rating":1500,"playerColor":"white","model":"5m"}""")
)
.andExpect(status().isUnauthorized)
}
@Test
fun `chess game history requires login`() {
mockMvc.perform(get("/api/chess/games"))
.andExpect(status().isUnauthorized)
}
}

View File

@@ -0,0 +1,277 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
import me.wypark.blogbackend.api.dto.ChessMoveRequest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class ChessGameServiceTest {
private lateinit var store: FakeChessGameStore
private lateinit var historyStore: FakeChessGameHistoryStore
private lateinit var engine: FakeMaiaEngine
private lateinit var service: ChessGameService
@BeforeEach
fun setUp() {
store = FakeChessGameStore()
historyStore = FakeChessGameHistoryStore()
engine = FakeMaiaEngine()
service = ChessGameService(
chessGameStore = store,
chessGameHistoryStore = historyStore,
maiaEngine = engine,
clock = Clock.fixed(Instant.parse("2026-06-19T00:00:00Z"), ZoneOffset.UTC)
)
}
@Test
fun `creates white game without an immediate Maia move`() {
val response = service.createGame(
memberId = MEMBER_ID,
request = ChessGameCreateRequest(rating = 1500, playerColor = "white", model = "5m")
)
assertEquals(1500, response.rating)
assertEquals("white", response.playerColor)
assertEquals(emptyList(), response.moves)
assertEquals(null, response.maiaMove)
assertEquals("white", response.turn)
assertEquals("IN_PROGRESS", response.status)
assertEquals("IN_PROGRESS", response.outcome)
assertEquals(MEMBER_ID, historyStore.savedSessions.single().memberId)
}
@Test
fun `creates black game with Maia opening move`() {
engine.playResponses.add(
MaiaPlayResponse(
move = "e2e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
turn = "black",
status = "IN_PROGRESS",
result = null,
pgn = "1. e4 *"
)
)
val response = service.createGame(
memberId = MEMBER_ID,
request = ChessGameCreateRequest(rating = 1500, playerColor = "black", model = "5m")
)
assertEquals(listOf("e2e4"), response.moves)
assertEquals("e2e4", response.maiaMove)
assertEquals("black", response.turn)
assertEquals("1. e4 *", response.pgn)
assertEquals(1, engine.playRequests.size)
}
@Test
fun `stores player move Maia reply PGN and outcome`() {
val session = store.save(
ChessGameSession(
gameId = "game-1",
memberId = MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.WHITE,
moves = emptyList(),
status = "IN_PROGRESS",
result = null,
pgn = "*",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
engine.playResponses.add(
MaiaPlayResponse(
move = "c7c5",
fen = "rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2",
turn = "white",
status = "IN_PROGRESS",
result = null,
pgn = "1. e4 c5 *"
)
)
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
assertEquals(listOf("e2e4", "c7c5"), response.moves)
assertEquals("c7c5", response.maiaMove)
assertEquals("white", response.turn)
assertEquals("1. e4 c5 *", response.pgn)
assertEquals("IN_PROGRESS", response.outcome)
assertEquals(listOf("e2e4"), engine.playRequests.single().moves)
assertEquals("1. e4 c5 *", historyStore.savedSessions.last().pgn)
}
@Test
fun `records a player win when result favors player color`() {
val session = store.save(
ChessGameSession(
gameId = "game-1",
memberId = MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.WHITE,
moves = emptyList(),
status = "IN_PROGRESS",
result = null,
pgn = "*",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
engine.playResponses.add(
MaiaPlayResponse(
move = null,
fen = "8/8/8/8/8/8/8/8 b - - 0 1",
turn = "black",
status = "CHECKMATE",
result = "1-0",
pgn = "1. e4 1-0"
)
)
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
assertEquals("WIN", response.outcome)
assertEquals("WIN", historyStore.savedSessions.last().outcome().name)
}
@Test
fun `rejects move when it is not player's turn`() {
store.save(
ChessGameSession(
gameId = "game-1",
memberId = MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.BLACK,
moves = listOf("e2e4"),
status = "IN_PROGRESS",
result = null,
pgn = "1. e4 *",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
assertFailsWith<IllegalArgumentException> {
service.playMove(MEMBER_ID, "game-1", ChessMoveRequest("d2d4"))
}
}
@Test
fun `rejects access to another member game`() {
store.save(
ChessGameSession(
gameId = "game-1",
memberId = OTHER_MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.WHITE,
moves = emptyList(),
status = "IN_PROGRESS",
result = null,
pgn = "*",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
assertFailsWith<IllegalArgumentException> {
service.getGame(MEMBER_ID, "game-1")
}
}
companion object {
private const val MEMBER_ID = 1L
private const val OTHER_MEMBER_ID = 2L
}
}
private class FakeChessGameStore : ChessGameStore {
private val sessions = mutableMapOf<String, ChessGameSession>()
override fun save(session: ChessGameSession): ChessGameSession {
sessions[session.gameId] = session
return session
}
override fun findById(gameId: String): ChessGameSession? = sessions[gameId]
}
private class FakeChessGameHistoryStore : ChessGameHistoryStore {
val savedSessions = mutableListOf<ChessGameSession>()
override fun save(session: ChessGameSession) {
savedSessions.add(session)
}
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? = null
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
return PageImpl(emptyList(), pageable, 0)
}
override fun getStats(memberId: Long): ChessGameHistoryStats {
return ChessGameHistoryStats(
total = 0,
inProgress = 0,
wins = 0,
losses = 0,
draws = 0,
unknown = 0
)
}
}
private class FakeMaiaEngine : MaiaEngine {
val playResponses = ArrayDeque<MaiaPlayResponse>()
val playRequests = mutableListOf<MaiaPlayRequest>()
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
return MaiaStateResponse(
fen = START_FEN,
turn = "white",
status = "IN_PROGRESS",
result = null,
pgn = "*"
)
}
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
playRequests.add(request)
return playResponses.removeFirst()
}
companion object {
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
}
}