...
This commit is contained in:
parent
5ac3b05660
commit
73072d1812
@ -1,4 +1,5 @@
|
||||
FROM openjdk:17
|
||||
FROM eclipse-temurin:17-jdk
|
||||
|
||||
ENV TG_TARGET_ID=default
|
||||
ENV TG_MINE=default
|
||||
ENV WEATHER_KEY=default
|
||||
|
||||
@ -36,7 +36,7 @@ class BumsInterceptor(
|
||||
// if (!skippResourcesExtension) {
|
||||
// println("===============================================")
|
||||
// println("==================== BEGIN ====================")
|
||||
// println("Request URL ===> " + request.requestURL)
|
||||
println("Request URL ===> " + request.requestURL)
|
||||
// }
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package kr.lunaticbum.back.lun.configs
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE) // 👈 1. 모든 필터 중 가장 먼저 실행되도록 설정
|
||||
class RequestLoggingFilter : OncePerRequestFilter() {
|
||||
|
||||
// 2. Slf4j 로거 생성 (SecurityConfig의 LogService 대신 표준 로거 사용)
|
||||
private val log = LoggerFactory.getLogger(RequestLoggingFilter::class.java)
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
val requestUri = request.requestURI
|
||||
|
||||
// 3. (선택적) 'puzzle' 관련 요청만 로깅하여 로그 양 조절
|
||||
val shouldLog = requestUri.contains("puzzle") || requestUri.contains("api")
|
||||
|
||||
if (shouldLog) {
|
||||
val method = request.method
|
||||
val queryString = if (request.queryString != null) "?${request.queryString}" else ""
|
||||
log.info(">>> REQUEST: [$method] $requestUri$queryString")
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. 실제 요청 처리 (다음 필터 또는 컨트롤러로 전달)
|
||||
filterChain.doFilter(request, response)
|
||||
} finally {
|
||||
// 5. 응답이 나갈 때 상태 코드 로깅
|
||||
val status = response.status
|
||||
|
||||
// ⚠️ 301 리디렉션이 발생하면 경고(WARN) 레벨로 상세히 로깅
|
||||
if (status == HttpServletResponse.SC_MOVED_PERMANENTLY) { // 301
|
||||
val location = response.getHeader("Location") // 리디렉션 대상 URL
|
||||
log.warn("<<< RESPONSE 301 (Moved Permanently):")
|
||||
log.warn("<<< FROM: [${request.method}] ${request.requestURI}")
|
||||
log.warn("<<< TO: $location") // 👈 이 주소를 확인하세요!
|
||||
}
|
||||
// (선택적) 그 외 'puzzle' 요청의 응답 로깅
|
||||
else if (shouldLog) {
|
||||
log.info("<<< RESPONSE: $status FOR [${request.method}] $requestUri")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -159,6 +159,7 @@ class SecurityConfig(
|
||||
)
|
||||
}.authorizeHttpRequests { auth ->
|
||||
auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
// 1. 정적 리소스 = permitAll
|
||||
.requestMatchers(
|
||||
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
|
||||
|
||||
@ -86,7 +86,13 @@ class PuzzleController(
|
||||
* 스도쿠: 새 게임 시작 (난이도별 문제 반환)
|
||||
*/
|
||||
@GetMapping("/sudoku/start")
|
||||
suspend fun sudokuStartGame(@RequestParam(defaultValue = "easy") difficulty: String): PuzzleService.SudokuGameDto {
|
||||
suspend fun sudokuStartGame(
|
||||
// 🔽 [수정] 'level' -> 'difficulty', 기본값 '4'
|
||||
@RequestParam(defaultValue = "4") difficulty: String
|
||||
// ❌ 'blockSizeStr' 파라미터 제거
|
||||
): PuzzleService.SudokuGameDto {
|
||||
|
||||
// 🔽 [수정] 파라미터 1개만 전달
|
||||
return puzzleService.sudoku_startGame(difficulty)
|
||||
}
|
||||
|
||||
@ -102,11 +108,28 @@ class PuzzleController(
|
||||
/**
|
||||
* 스도쿠: (관리용) 새 퍼즐 문제 생성 및 DB 저장
|
||||
*/
|
||||
@GetMapping("/sudoku/sudoku_gen")
|
||||
suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle {
|
||||
return puzzleService.sudoku_generateAndSavePuzzle()
|
||||
}
|
||||
// @GetMapping("/sudoku/sudoku_gen")
|
||||
// suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle {
|
||||
// puzzleService.sudoku_generateAndSavePuzzle()
|
||||
// return SudokuPuzzle()
|
||||
// }
|
||||
|
||||
@GetMapping("/admin/generate-puzzles")
|
||||
suspend fun generateAdminPuzzles(
|
||||
@RequestParam(defaultValue = "4") blockSize: Int,
|
||||
@RequestParam(defaultValue = "5") count: Int
|
||||
): String {
|
||||
// 예: /admin/generate-puzzles?blockSize=4&count=10
|
||||
// 16x16 퍼즐 10개 생성
|
||||
repeat(count) {
|
||||
try {
|
||||
puzzleService.sudoku_generateAndSavePuzzle(blockSize)
|
||||
} catch (e: Exception) {
|
||||
// (퍼즐 문자열이 unique=true이므로 중복되면 예외 발생 가능)
|
||||
}
|
||||
}
|
||||
return "$count 개의 $blockSize x $blockSize 퍼즐 생성 완료."
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 3. SPIDER API (★ SpiderController에서 마이그레이션 및 Coroutine 변환됨)
|
||||
@ -256,7 +279,7 @@ class GameRankController(private val gameRankService: GameRankService) {
|
||||
}
|
||||
.onErrorResume {
|
||||
// 기타 예외는 500 Internal Server Error로 처리
|
||||
Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다."))
|
||||
Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(it.message))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import kr.lunaticbum.back.lun.utils.SudokuGenerator
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.annotation.Id
|
||||
@ -27,13 +28,16 @@ import kotlin.random.Random
|
||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import org.springframework.data.mongodb.core.index.Indexed
|
||||
import org.springframework.data.repository.reactive.ReactiveSortingRepository
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException // 👈 [추가]
|
||||
import reactor.core.scheduler.Schedulers // 👈 [추가]
|
||||
|
||||
/**
|
||||
* ======================================================
|
||||
@ -80,7 +84,8 @@ class PuzzleService(
|
||||
|
||||
// 3. Spider 의존성
|
||||
private val spiderGameRepository: SpiderGameRepository,
|
||||
@Value("\${puzzle.image.path}") private val puzzleImagePath: String
|
||||
@Value("\${puzzle.image.path}") private val puzzleImagePath: String,
|
||||
private val logService: LogService
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@ -254,39 +259,124 @@ class PuzzleService(
|
||||
// 2. SUDOKU 서비스 로직 (통합됨)
|
||||
// ======================================================
|
||||
|
||||
data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String)
|
||||
// 🔽 [수정] DTO가 puzzleId를 다시 포함하도록 변경
|
||||
data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String, val blockSize: Int)
|
||||
data class SudokuValidateDto(val puzzleId: Long, val answer: String)
|
||||
|
||||
// [신규] 생성 작업 중복 실행 방지용 잠금
|
||||
private val generationLocks = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
/**
|
||||
* [핵심 수정]
|
||||
* - 파라미터를 'difficulty' 1개만 받습니다.
|
||||
* - 'difficulty' (1~8)를 (blockSize, generatorLevel)로 변환합니다.
|
||||
*/
|
||||
suspend fun sudoku_startGame(difficulty: String): SudokuGameDto {
|
||||
val puzzleCount = sudokuPuzzleRepository.count()
|
||||
if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.")
|
||||
|
||||
val randomKey = Random.nextLong(1, puzzleCount - 1)
|
||||
val solvedPuzzle = sudokuPuzzleRepository.findByPuzzleKey(randomKey)
|
||||
?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.")
|
||||
|
||||
val holes = when (difficulty.lowercase()) {
|
||||
"medium" -> 45
|
||||
"hard" -> 55
|
||||
else -> 35 // easy
|
||||
// 🔽 [신규] 8단계 난이도를 (blockSize, generatorLevel)로 매핑
|
||||
val (blockSize, generatorLevel) = when (difficulty) {
|
||||
"1" -> Pair(2, 1) // Level 1: 4x4, Easy
|
||||
"2" -> Pair(2, 3) // Level 2: 4x4, Medium
|
||||
"3" -> Pair(3, 1) // Level 3: 9x9, Easy
|
||||
"4" -> Pair(3, 3) // Level 4: 9x9, Medium (Default)
|
||||
"5" -> Pair(3, 5) // Level 5: 9x9, Hard
|
||||
"6" -> Pair(4, 1) // Level 6: 16x16, Easy
|
||||
"7" -> Pair(4, 3) // Level 7: 16x16, Medium
|
||||
"8" -> Pair(4, 5) // Level 8: 16x16, Hard
|
||||
else -> Pair(3, 3) // 기본값 (Level 4)
|
||||
}
|
||||
|
||||
val question = sudoku_createQuestion(solvedPuzzle.puzzle!!, holes)
|
||||
return SudokuGameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!)
|
||||
// 1. DB에서 조건에 맞는 '미사용 문제' 검색
|
||||
val solutionPuzzle = sudokuPuzzleRepository
|
||||
.findFirstByBlockSizeAndLevelAndPlayCountOrderByPuzzleKey(blockSize, generatorLevel, 0L)
|
||||
// 2. 없으면 오류 반환 (스케줄러가 채워야 함)
|
||||
?: throw IllegalStateException("현재 $blockSize x $blockSize (Level $generatorLevel) 사용 가능한 새 퍼즐이 없습니다.")
|
||||
|
||||
// 3. 생성기 인스턴스화
|
||||
val generator = SudokuGenerator(blockSize)
|
||||
|
||||
// 4. DB의 정답(Solution)으로 문제(Question) 생성
|
||||
val puzzle = generator.generatePuzzleFromSolution(solutionPuzzle.solution!!, generatorLevel)
|
||||
|
||||
// 5. playCount 1 증가 및 저장
|
||||
sudokuPuzzleRepository.save(
|
||||
solutionPuzzle.copy(playCount = solutionPuzzle.playCount + 1)
|
||||
)
|
||||
|
||||
// 6. DTO로 반환
|
||||
return SudokuGameDto(
|
||||
puzzleId = solutionPuzzle.puzzleKey ?: 0L,
|
||||
question = puzzle.question!!,
|
||||
solution = puzzle.solution!!,
|
||||
blockSize = solutionPuzzle.blockSize
|
||||
)
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 600000, initialDelay = 1000)
|
||||
suspend fun fillPuzzleCache() {
|
||||
// 🔽 [수정] 2..5 -> 2..4 (4x4까지만)
|
||||
for (blockSize in 2..4) {
|
||||
for (level in 1..5) { // 1~5 난이도
|
||||
// (이하 로직은 기존과 동일)
|
||||
val lockKey = "$blockSize-$level"
|
||||
if (generationLocks.putIfAbsent(lockKey, true) != null) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val unplayedCount = sudokuPuzzleRepository
|
||||
.countByBlockSizeAndLevelAndPlayCount(blockSize, level, 0L)
|
||||
|
||||
val minCacheSize = 5
|
||||
if (unplayedCount < minCacheSize) {
|
||||
logService.log("[$blockSize x $blockSize Level $level] 미사용 퍼즐 ${unplayedCount}개 감지. ${minCacheSize}개까지 생성 시작...")
|
||||
repeat((minCacheSize - unplayedCount).toInt()) {
|
||||
sudoku_generateAndSavePuzzle(blockSize, level)
|
||||
}
|
||||
logService.log("[$blockSize x $blockSize Level $level] 퍼즐 생성 완료.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logService.log("[$blockSize x $blockSize Level $level] 퍼즐 생성 중 오류: ${e.message}")
|
||||
} finally {
|
||||
generationLocks.remove(lockKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [수정] Generator의 로컬 검증 대신, DB의 정답과 직접 비교 (가장 확실한 방식)
|
||||
*/
|
||||
suspend fun sudoku_validateSolution(validateDto: SudokuValidateDto): Boolean {
|
||||
// 1. [수정] DB에서 'puzzleId'로 정답을 찾음
|
||||
val originalPuzzle = sudokuPuzzleRepository.findByPuzzleKey(validateDto.puzzleId)
|
||||
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
|
||||
return originalPuzzle.puzzle == validateDto.answer
|
||||
|
||||
// 2. [수정] DB의 정답과 문자열 비교
|
||||
return originalPuzzle.solution == validateDto.answer
|
||||
}
|
||||
|
||||
suspend fun sudoku_generateAndSavePuzzle(): SudokuPuzzle {
|
||||
val puzzleString = SudokuGenerator().generate()
|
||||
/**
|
||||
* [수정] 이 함수는 이제 '새 정답 퍼즐'을 1개 생성하여
|
||||
* 'playCount = 0' 상태로 DB에 저장하고, '저장된 객체'를 반환합니다.
|
||||
*/
|
||||
suspend fun sudoku_generateAndSavePuzzle(blockSize: Int = 3, level: Int = 3) { // 👈 [수정]
|
||||
val generator = SudokuGenerator(blockSize)
|
||||
|
||||
// [수정] generatePuzzle은 Solution과 Question을 모두 반환
|
||||
val newPuzzle = generator.generatePuzzle(level)
|
||||
|
||||
val lastPuzzle = sudokuPuzzleRepository.findTopByOrderByPuzzleKeyDesc()
|
||||
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
|
||||
val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString)
|
||||
return sudokuPuzzleRepository.save(newPuzzle)
|
||||
|
||||
val puzzleToSave = SudokuPuzzle(
|
||||
puzzleKey = nextKey,
|
||||
solution = newPuzzle.solution, // 👈 정답 저장
|
||||
question = newPuzzle.question, // 👈 문제 저장
|
||||
blockSize = blockSize,
|
||||
level = level, // 👈 레벨 저장
|
||||
playCount = 0L
|
||||
)
|
||||
sudokuPuzzleRepository.save(puzzleToSave)
|
||||
}
|
||||
|
||||
private fun sudoku_createQuestion(puzzle: String, holes: Int): String {
|
||||
@ -488,13 +578,19 @@ interface SpiderGameRepository : ReactiveMongoRepository<SpiderGame, String> {
|
||||
/**
|
||||
* 스도쿠 퍼즐 원본 데이터를 저장하는 모델
|
||||
*/
|
||||
@Document(collection = "Sudoku") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름)
|
||||
@Document(collection = "Sudoku")
|
||||
data class SudokuPuzzle(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val puzzleKey: Long? = null,
|
||||
@Indexed(unique = true)
|
||||
val puzzle: String? // 81자리 완성된 퍼즐 데이터
|
||||
|
||||
// 🔽 [수정] 'solution' (정답)과 'question' (문제)을 모두 저장
|
||||
val solution: String?, // 완성된 퍼즐
|
||||
val question: String?, // 빈칸이 뚫린 퍼즐
|
||||
|
||||
val blockSize: Int = 3,
|
||||
val level: Int = 3, // 🔽 [추가] 이 퍼즐의 난이도 (1~5)
|
||||
val playCount: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
@ -502,34 +598,49 @@ data class SudokuPuzzle(
|
||||
*/
|
||||
@Repository
|
||||
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
|
||||
// 🔽 [수정] override suspend fun count(): Long -> countByBlockSize
|
||||
suspend fun countByBlockSize(blockSize: Int): Long // 특정 크기의 퍼즐만 카운트
|
||||
override suspend fun count(): Long
|
||||
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
|
||||
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
|
||||
// 🔽 [신규] 특정 blockSize의 퍼즐 중 랜덤 1개 조회
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { blockSize: ?0 } }", // ?0은 첫번째 파라미터(blockSize)
|
||||
"{ \$sample: { size: 1 } }"
|
||||
])
|
||||
suspend fun findRandomByBlockSize(blockSize: Int): SudokuPuzzle?
|
||||
|
||||
// 🔽 [수정] blockSize, level, playCount 모두 일치하는 퍼즐 검색
|
||||
suspend fun findFirstByBlockSizeAndLevelAndPlayCountOrderByPuzzleKey(
|
||||
blockSize: Int,
|
||||
level: Int,
|
||||
playCount: Long
|
||||
): SudokuPuzzle?
|
||||
|
||||
// 🔽 [추가] 스케줄러가 사용할, 특정 조건의 퍼즐 개수 카운트
|
||||
suspend fun countByBlockSizeAndLevelAndPlayCount(
|
||||
blockSize: Int,
|
||||
level: Int,
|
||||
playCount: Long
|
||||
): Long
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 모든 게임의 랭킹을 저장하는 통합 모델
|
||||
*/
|
||||
// In PuzzleData.kt
|
||||
@Document(collection = "game_ranks")
|
||||
data class GameRank(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
|
||||
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
|
||||
val playerName: String, // 표준화된 플레이어 이름 필드
|
||||
|
||||
/** * 기본 점수 필드 (정렬 1순위).
|
||||
* - 2048: 점수 (높을수록 좋음)
|
||||
* - Sudoku: 완료 시간(초) (낮을수록 좋음)
|
||||
* - Spider: 이동 횟수 (낮을수록 좋음)
|
||||
*/
|
||||
@Indexed // 👈 [추가]
|
||||
val userId: String?, // 👈 [추가] 앱-고유 ID 또는 인증된 사용자 ID
|
||||
|
||||
val gameType: GameType,
|
||||
val contextId: String?,
|
||||
val playerName: String,
|
||||
val primaryScore: Long,
|
||||
|
||||
/** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간).
|
||||
*/
|
||||
val secondaryScore: Long? = null,
|
||||
|
||||
val timestamp: Instant = Instant.now()
|
||||
)
|
||||
|
||||
@ -547,6 +658,7 @@ enum class GameType {
|
||||
* 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO
|
||||
*/
|
||||
data class UnifiedRankDto(
|
||||
val userId: String, // 👈 [추가] (널 허용 안 함)
|
||||
val gameType: GameType,
|
||||
val contextId: String?,
|
||||
val playerName: String,
|
||||
@ -572,15 +684,22 @@ interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
|
||||
|
||||
// [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
|
||||
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
|
||||
|
||||
// 🔽 [추가]
|
||||
fun findFirstByUserId(userId: String): Mono<GameRank>
|
||||
fun findByPlayerName(playerName: String): Flux<GameRank> // 이름 중복 확인용
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Service
|
||||
class GameRankService(
|
||||
private val rankRepository: GameRankRepository,
|
||||
private val userManager: UserManager ) {
|
||||
private val userManager: UserManager, // 👈 이 컴포넌트가 Blocking IO를 유발
|
||||
private val logService: LogService
|
||||
) {
|
||||
/**
|
||||
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
|
||||
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. (변경 없음)
|
||||
*/
|
||||
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
|
||||
return when (gameType) {
|
||||
@ -595,50 +714,99 @@ class GameRankService(
|
||||
}
|
||||
|
||||
/**
|
||||
* [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
|
||||
* [수정] 공통 DTO를 받아 랭킹을 저장 (Blocking IO 및 모든 예외 처리)
|
||||
*/
|
||||
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
|
||||
val auth = SecurityContextHolder.getContext().authentication
|
||||
|
||||
// 로그인 사용자인지, 비로그인(익명) 사용자인지 확인
|
||||
val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken
|
||||
|
||||
if (isAuthenticated) {
|
||||
// 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화)
|
||||
val principal = auth.principal as UserDetails
|
||||
val authenticatedUsername = principal.username
|
||||
val gameRankMono: Mono<GameRank>
|
||||
|
||||
val gameRank = GameRank(
|
||||
if (isAuthenticated) {
|
||||
// --- 1. 인증된 사용자 (로그인 상태) ---
|
||||
val principal = auth.principal as UserDetails
|
||||
val authenticatedUserId = principal.username
|
||||
gameRankMono = Mono.just(GameRank(
|
||||
userId = authenticatedUserId,
|
||||
gameType = rankDto.gameType,
|
||||
contextId = rankDto.contextId,
|
||||
playerName = authenticatedUsername, // 실제 인증된 이름 사용
|
||||
playerName = authenticatedUserId,
|
||||
primaryScore = rankDto.primaryScore,
|
||||
secondaryScore = rankDto.secondaryScore
|
||||
)
|
||||
return rankRepository.save(gameRank)
|
||||
))
|
||||
|
||||
} else {
|
||||
// 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인
|
||||
return userManager.findById(rankDto.playerName)
|
||||
.flatMap<GameRank> { existingUser ->
|
||||
// 사용자가 존재하면 에러 발생
|
||||
Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요."))
|
||||
// --- 2. 익명 사용자 (비로그인 상태) ---
|
||||
val anonymousUserId = rankDto.userId
|
||||
val requestedName = rankDto.playerName
|
||||
|
||||
// 🔽 [수정] 'checkAuthUsers'의 예외 처리를 더 견고하게 변경
|
||||
val checkAuthUsers = Mono.fromCallable {
|
||||
userManager.loadUserByUsername(requestedName)
|
||||
}
|
||||
.subscribeOn(Schedulers.boundedElastic()) // 블로킹 IO 작업을 위한 스케줄러
|
||||
.flatMap<GameRank> {
|
||||
// loadUserByUsername이 UserDetails를 반환했다면 (예외가 안 났다면)
|
||||
// -> 이름이 중복된다는 뜻
|
||||
Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다."))
|
||||
}
|
||||
.onErrorResume { error ->
|
||||
// [수정] 모든 예외(error)를 잡아서 처리
|
||||
when (error) {
|
||||
is UsernameNotFoundException -> {
|
||||
// '사용자를 찾을 수 없음'은 정상적인 '중복 아님' 시나리오임
|
||||
Mono.empty()
|
||||
}
|
||||
is IllegalArgumentException -> {
|
||||
// 'flatMap'에서 우리가 직접 발생시킨 "이미 등록된 회원" 오류
|
||||
Mono.error(error)
|
||||
}
|
||||
else -> {
|
||||
// 그 외의 모든 서버 내부 크래시 (NPE, DB 오류 등)
|
||||
logService.log("!!! submitRank: checkAuthUsers 중 예상치 못한 크래시 발생 !!!", error)
|
||||
Mono.error(IllegalArgumentException("이름 확인 중 서버 오류가 발생했습니다."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. 'rankRepository'는 'Reactive'이므로 Schedulers가 필요 없음 (기존과 동일)
|
||||
val checkAnonymousUsers = rankRepository.findFirstByUserId(anonymousUserId)
|
||||
.flatMap { existingRank ->
|
||||
if (existingRank.playerName != requestedName) {
|
||||
Mono.error(IllegalArgumentException("해당 ID는 이미 '${existingRank.playerName}' 님으로 등록되어 있습니다."))
|
||||
} else {
|
||||
Mono.empty<GameRank>()
|
||||
}
|
||||
}
|
||||
.switchIfEmpty(Mono.defer {
|
||||
// 사용자가 존재하지 않으면 랭킹 저장 진행
|
||||
val gameRank = GameRank(
|
||||
gameType = rankDto.gameType,
|
||||
contextId = rankDto.contextId,
|
||||
playerName = rankDto.playerName,
|
||||
primaryScore = rankDto.primaryScore,
|
||||
secondaryScore = rankDto.secondaryScore
|
||||
)
|
||||
rankRepository.save(gameRank)
|
||||
rankRepository.findByPlayerName(requestedName).hasElements()
|
||||
.flatMap { nameExists ->
|
||||
if (nameExists) {
|
||||
Mono.error(IllegalArgumentException("이미 사용 중인 이름입니다."))
|
||||
} else {
|
||||
Mono.empty<GameRank>()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 2c. 모든 검증을 통과하면 랭킹 생성 (기존과 동일)
|
||||
gameRankMono = checkAuthUsers.then(checkAnonymousUsers)
|
||||
.then(Mono.just(GameRank(
|
||||
userId = anonymousUserId,
|
||||
gameType = rankDto.gameType,
|
||||
contextId = rankDto.contextId,
|
||||
playerName = requestedName,
|
||||
primaryScore = rankDto.primaryScore,
|
||||
secondaryScore = rankDto.secondaryScore
|
||||
)))
|
||||
}
|
||||
|
||||
// 3. 랭킹 저장
|
||||
return gameRankMono.flatMap { rankRepository.save(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 플레이어의 모든 게임 랭킹을 조회합니다.
|
||||
* 특정 플레이어의 모든 게임 랭킹을 조회합니다. (변경 없음)
|
||||
*/
|
||||
fun getRanksByPlayer(playerName: String): Flux<GameRank> {
|
||||
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
|
||||
|
||||
@ -52,6 +52,9 @@ class LogService {
|
||||
fun log(id: String) {
|
||||
println("log = $id")
|
||||
}
|
||||
fun log(id: String, throwable: Throwable) {
|
||||
println("log = $id , ${throwable}")
|
||||
}
|
||||
}
|
||||
|
||||
@Controller
|
||||
|
||||
@ -1,86 +1,209 @@
|
||||
package kr.lunaticbum.back.lun.utils
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
class SudokuGenerator {
|
||||
/**
|
||||
* [수정] 생성된 스도쿠 퍼즐과 해답을 반환하는 데이터 클래스 (기존과 동일)
|
||||
*/
|
||||
data class SudokuPuzzle(
|
||||
val solution: String, // N*N x N*N 크기의 완성된 정답 문자열 (예: 81, 256, 625...)
|
||||
val question: String // 빈칸('0')이 포함된 문제 문자열
|
||||
)
|
||||
|
||||
// 9x9 스도쿠 보드
|
||||
private val board = Array(9) { IntArray(9) }
|
||||
/**
|
||||
* [수정] 동적 그리드 생성을 지원하는 스도쿠 생성기
|
||||
*
|
||||
* @param blockSize (N) 박스 하나의 크기. (기본값: 3)
|
||||
* - 2: 4x4 (2x2 블록) 그리드. 숫자 1-4 사용.
|
||||
* - 3: 9x9 (3x3 블록) 그리드. 숫자 1-9 사용.
|
||||
* - 4: 16x16 (4x4 블록) 그리드. 숫자 1-9, A-G 사용.
|
||||
* - 5: 25x25 (5x5 블록) 그리드. 숫자 1-9, A-P 사용.
|
||||
*/
|
||||
class SudokuGenerator(private val blockSize: Int = 3) {
|
||||
|
||||
// N*N (예: 3 -> 9, 4 -> 16)
|
||||
private val gridSize: Int = blockSize * blockSize
|
||||
// N*N x N*N (예: 3 -> 81, 4 -> 256)
|
||||
private val totalCells: Int = gridSize * gridSize
|
||||
|
||||
// 그리드 크기에 맞게 보드 동적 생성
|
||||
private val board = Array(gridSize) { IntArray(gridSize) }
|
||||
|
||||
// 해답의 개수를 세기 위한 내부 변수
|
||||
private var solutionCount = 0
|
||||
|
||||
/**
|
||||
* 완성된 스도쿠 퍼즐 하나를 생성합니다.
|
||||
* @return 81자리 문자열로 된 퍼즐 데이터
|
||||
* [핵심] 지정된 레벨의 스도쿠 퍼즐과 해답을 생성합니다.
|
||||
*/
|
||||
fun generate(): String {
|
||||
// 보드 초기화
|
||||
for (i in 0..8) {
|
||||
for (j in 0..8) {
|
||||
board[i][j] = 0
|
||||
}
|
||||
}
|
||||
// 백트래킹으로 퍼즐 풀기 시작
|
||||
solve()
|
||||
// 2D 배열을 1D 문자열로 변환하여 반환
|
||||
return board.joinToString("") { row -> row.joinToString("") }
|
||||
fun generatePuzzle(level: Int): SudokuPuzzle {
|
||||
// 1. 보드 초기화
|
||||
_clearBoard()
|
||||
|
||||
// 2. 랜덤한 스도쿠 정답 보드 1개 생성
|
||||
_solve()
|
||||
val solutionString = _boardToString()
|
||||
|
||||
// 3. 정답 보드에서 빈칸을 뚫어 문제를 생성
|
||||
_pokeHoles(level)
|
||||
val questionString = _boardToString()
|
||||
|
||||
return SudokuPuzzle(solutionString, questionString)
|
||||
}
|
||||
|
||||
/**
|
||||
* 백트래킹을 사용하여 보드를 채우는 재귀 함수
|
||||
* [신규] DB에서 가져온 solution을 기반으로 문제를 생성
|
||||
*/
|
||||
private fun solve(): Boolean {
|
||||
val emptyCell = findEmpty() ?: return true // 빈 칸이 없으면 성공
|
||||
fun generatePuzzleFromSolution(solutionString: String, level: Int): SudokuPuzzle {
|
||||
// 1. DB의 정답 문자열을 2D Int 배열로 변환
|
||||
val solutionBoard = _stringToBoard(solutionString)
|
||||
|
||||
// 2. 내부 'board' 필드에 정답 보드를 복사
|
||||
for (i in 0 until gridSize) {
|
||||
this.board[i] = solutionBoard[i].clone()
|
||||
}
|
||||
|
||||
// 3. 정답이 채워진 보드에서 빈칸 뚫기 시작
|
||||
_pokeHoles(level)
|
||||
val questionString = _boardToString()
|
||||
|
||||
// 4. (정답, 문제) 반환
|
||||
return SudokuPuzzle(solutionString, questionString)
|
||||
}
|
||||
|
||||
/**
|
||||
* [수정] 레벨에 맞춰 '유일한 해답'을 보장하며 빈칸을 뚫는 함수
|
||||
* 난이도 기준을 고정된 '빈칸 수'가 아닌 '비율(%)'로 변경하여 모든 그리드 크기에 대응
|
||||
*/
|
||||
private fun _pokeHoles(level: Int) {
|
||||
// 난이도별 최소 클루(숫자) 비율, 최대 빈칸 비율
|
||||
val (minCluesRatio, maxHolesRatio) = when (level) {
|
||||
1 -> Pair(0.49, 0.51) // 아주 쉬움 (최소 49% 숫자 보장 / 9x9에서 약 40개)
|
||||
2 -> Pair(0.42, 0.58) // 쉬움 (최소 42% 숫자 보장 / 9x9에서 약 34개)
|
||||
3 -> Pair(0.37, 0.63) // 중급 (최소 37% 숫자 보장 / 9x9에서 약 30개)
|
||||
4 -> Pair(0.32, 0.68) // 어려움 (최소 32% 숫자 보장 / 9x9에서 약 26개)
|
||||
else -> Pair(0.27, 0.73) // 전문가 (최소 27% 숫자 보장 / 9x9에서 약 22개)
|
||||
}
|
||||
|
||||
val minClues = (totalCells * minCluesRatio).toInt()
|
||||
val maxHoles = (totalCells * maxHolesRatio).toInt()
|
||||
|
||||
var holesMade = 0
|
||||
// (0..80) -> (0..totalCells-1)
|
||||
val cells = (0 until totalCells).shuffled(Random)
|
||||
|
||||
for (cellIndex in cells) {
|
||||
val row = cellIndex / gridSize
|
||||
val col = cellIndex % gridSize
|
||||
|
||||
// 이미 빈 칸이면 건너뛰기 (혹시 모를 방어 코드)
|
||||
if (board[row][col] == 0) continue
|
||||
|
||||
val temp = board[row][col]
|
||||
board[row][col] = 0
|
||||
|
||||
// [!!!] 핵심 수정 지점 [!!!]
|
||||
// 9x9 (gridSize=9, blockSize=3) 이하에서만 유일성 검사를 수행합니다.
|
||||
var isUnique = true // 기본값은 '유일하다'로 가정
|
||||
if (gridSize <= 9) {
|
||||
solutionCount = 0
|
||||
_countSolutions()
|
||||
isUnique = (solutionCount == 1)
|
||||
}
|
||||
// [!!!] 수정 끝 [!!!]
|
||||
|
||||
if (!isUnique) {
|
||||
// 9x9에서 유일 해가 깨졌다면 숫자를 복원
|
||||
board[row][col] = temp
|
||||
} else {
|
||||
// 9x9에서 유일 해가 유지되거나,
|
||||
// 16x16 이상이라서 검사를 건너뛰었다면 구멍 뚫기 성공
|
||||
holesMade++
|
||||
}
|
||||
|
||||
val remainingClues = totalCells - holesMade
|
||||
if (holesMade >= maxHoles || (remainingClues <= minClues && holesMade > 0)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [수정] 현재 board 상태에서 가능한 모든 해답의 '개수'를 세는 함수
|
||||
*/
|
||||
private fun _countSolutions() {
|
||||
val empty = _findEmpty(board)
|
||||
if (empty == null) {
|
||||
solutionCount++
|
||||
return
|
||||
}
|
||||
|
||||
val (row, col) = empty
|
||||
// (1..9) -> (1..gridSize)
|
||||
for (num in 1..gridSize) {
|
||||
if (_isValid(num, row, col, board)) {
|
||||
board[row][col] = num
|
||||
_countSolutions()
|
||||
board[row][col] = 0 // 백트래킹
|
||||
|
||||
if (solutionCount > 1) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [수정] 백트래킹을 사용하여 보드를 채우는 재귀 함수
|
||||
*/
|
||||
private fun _solve(): Boolean {
|
||||
val emptyCell = _findEmpty(board) ?: return true
|
||||
|
||||
val row = emptyCell.first
|
||||
val col = emptyCell.second
|
||||
|
||||
// 1~9 숫자를 무작위로 섞어서 시도하면 매번 다른 패턴의 퍼즐이 생성됨
|
||||
val numbers = (1..9).shuffled(Random)
|
||||
// (1..9) -> (1..gridSize)
|
||||
val numbers = (1..gridSize).shuffled(Random)
|
||||
|
||||
for (num in numbers) {
|
||||
if (isValid(num, row, col)) {
|
||||
// 유효한 숫자를 찾으면 칸에 배치
|
||||
if (_isValid(num, row, col, board)) {
|
||||
board[row][col] = num
|
||||
|
||||
// 재귀적으로 다음 칸 풀이 시도
|
||||
if (solve()) {
|
||||
return true // 끝까지 해결되면 성공
|
||||
if (_solve()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 다음 칸 풀이에 실패하면, 현재 칸을 비우고 다른 숫자로 다시 시도 (백트래킹)
|
||||
board[row][col] = 0
|
||||
board[row][col] = 0 // 백트래킹
|
||||
}
|
||||
}
|
||||
return false // 1~9까지 모든 숫자를 시도해도 해결 못하면 실패
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 위치에 숫자를 놓는 것이 유효한지 검사
|
||||
* [수정] 특정 위치에 숫자를 놓는 것이 유효한지 검사 (blockSize 기반)
|
||||
*/
|
||||
private fun isValid(num: Int, row: Int, col: Int): Boolean {
|
||||
// 1. 가로줄 검사
|
||||
for (c in 0..8) {
|
||||
private fun _isValid(num: Int, row: Int, col: Int, board: Array<IntArray>): Boolean {
|
||||
// (0..8) -> (0..gridSize-1)
|
||||
for (c in 0 until gridSize) {
|
||||
if (board[row][c] == num) return false
|
||||
}
|
||||
// 2. 세로줄 검사
|
||||
for (r in 0..8) {
|
||||
for (r in 0 until gridSize) {
|
||||
if (board[r][col] == num) return false
|
||||
}
|
||||
// 3. 3x3 박스 검사
|
||||
val boxStartRow = row - row % 3
|
||||
val boxStartCol = col - col % 3
|
||||
for (r in 0..2) {
|
||||
for (c in 0..2) {
|
||||
|
||||
// (3x3 박스) -> (NxN 박스)
|
||||
val boxStartRow = row - row % blockSize
|
||||
val boxStartCol = col - col % blockSize
|
||||
for (r in 0 until blockSize) {
|
||||
for (c in 0 until blockSize) {
|
||||
if (board[boxStartRow + r][boxStartCol + c] == num) return false
|
||||
}
|
||||
}
|
||||
return true // 모든 검사를 통과하면 유효함
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 보드에서 비어있는 첫 번째 칸의 좌표를 찾습니다. (row, col)
|
||||
* [수정] 보드에서 비어있는 첫 번째 칸의 좌표를 찾습니다.
|
||||
*/
|
||||
private fun findEmpty(): Pair<Int, Int>? {
|
||||
for (r in 0..8) {
|
||||
for (c in 0..8) {
|
||||
private fun _findEmpty(board: Array<IntArray>): Pair<Int, Int>? {
|
||||
// (0..8) -> (0..gridSize-1)
|
||||
for (r in 0 until gridSize) {
|
||||
for (c in 0 until gridSize) {
|
||||
if (board[r][c] == 0) {
|
||||
return Pair(r, c)
|
||||
}
|
||||
@ -88,4 +211,91 @@ class SudokuGenerator {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* [수정] 보드를 0으로 모두 초기화
|
||||
*/
|
||||
private fun _clearBoard() {
|
||||
for (i in 0 until gridSize) {
|
||||
for (j in 0 until gridSize) {
|
||||
board[i][j] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규] 10 이상의 숫자를 'A', 'B' 등으로 변환하는 헬퍼
|
||||
*/
|
||||
private fun _intToChar(num: Int): Char {
|
||||
return when (num) {
|
||||
0 -> '0'
|
||||
in 1..9 -> num.toString()[0]
|
||||
in 10..35 -> ('A' + (num - 10)) // 10=A, 11=B ... 35=Z
|
||||
else -> '?' // 36 이상은 지원 안 함
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규] 10진수 보드(IntArray)를 문자열로 변환 (10 -> A)
|
||||
*/
|
||||
private fun _boardToString(): String {
|
||||
return board.joinToString("") { row ->
|
||||
row.joinToString("") { cell -> _intToChar(cell).toString() }
|
||||
}
|
||||
}
|
||||
|
||||
// [신규] 문자(A)를 다시 10진수(10)로 변환하는 헬퍼
|
||||
private fun _charToInt(char: Char): Int {
|
||||
return when (char) {
|
||||
'0' -> 0
|
||||
in '1'..'9' -> char.toString().toInt()
|
||||
in 'A'..'Z' -> (char - 'A') + 10
|
||||
else -> -1 // Invalid
|
||||
}
|
||||
}
|
||||
|
||||
// [신규] 문자열 보드를 10진수 IntArray로 변환 (A -> 10)
|
||||
private fun _stringToBoard(puzzleString: String): Array<IntArray> {
|
||||
val newBoard = Array(gridSize) { IntArray(gridSize) }
|
||||
for (i in 0 until totalCells) {
|
||||
val row = i / gridSize
|
||||
val col = i % gridSize
|
||||
newBoard[row][col] = _charToInt(puzzleString[i])
|
||||
}
|
||||
return newBoard
|
||||
}
|
||||
|
||||
// [신규] 외부에서 정답 검증을 요청할 때 사용할 공개 함수
|
||||
fun validate(questionString: String, answerString: String): Boolean {
|
||||
if (answerString.length != totalCells || questionString.length != totalCells) {
|
||||
return false // 크기가 다름
|
||||
}
|
||||
if (answerString.contains('0')) {
|
||||
return false // 0을 포함 (미완성)
|
||||
}
|
||||
|
||||
val answerBoard = _stringToBoard(answerString)
|
||||
val questionBoard = _stringToBoard(questionString)
|
||||
|
||||
for (r in 0 until gridSize) {
|
||||
for (c in 0 until gridSize) {
|
||||
val answerNum = answerBoard[r][c]
|
||||
val questionNum = questionBoard[r][c]
|
||||
|
||||
// 1. 답안이 유효한지 (규칙 위반)
|
||||
answerBoard[r][c] = 0 // 임시로 비워서 검사
|
||||
if (!_isValid(answerNum, r, c, answerBoard)) {
|
||||
return false
|
||||
}
|
||||
answerBoard[r][c] = answerNum // 원상복구
|
||||
|
||||
// 2. 답안이 원본 문제와 일치하는지 (원본 숫자를 바꿨는지)
|
||||
if (questionNum != 0 && questionNum != answerNum) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true // 모든 검증 통과
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
//<![CDATA[
|
||||
window.pageContext = { pageType: 'game', gameType: 'SPIDER', contextId: undefined };
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user