feat: add daily chess puzzle api
All checks were successful
Deploy Blog Backend / test (push) Successful in 1m33s
Deploy Blog Backend / deploy (push) Successful in 1m23s

This commit is contained in:
wypark
2026-05-29 00:18:42 +09:00
parent c14d123fcc
commit b47258ea53
8 changed files with 360 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.ChessPuzzleResponse
import me.wypark.blogbackend.domain.chess.ChessPuzzleService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chess-puzzles")
class ChessPuzzleController(
private val chessPuzzleService: ChessPuzzleService
) {
@GetMapping("/today")
fun getTodayPuzzle(
@RequestParam(defaultValue = "Asia/Seoul") timezone: String
): ResponseEntity<ApiResponse<ChessPuzzleResponse>> {
val puzzle = chessPuzzleService.getTodayPuzzle(timezone)
?: return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ApiResponse("CHESS_PUZZLE_NOT_FOUND", "오늘의 체스 퍼즐이 준비되지 않았습니다.", null))
return ResponseEntity.ok(ApiResponse.success(puzzle))
}
}

View File

@@ -0,0 +1,34 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.chess.ChessPuzzle
import java.time.LocalDate
data class ChessPuzzleResponse(
val id: Long,
val date: LocalDate,
val title: String,
val theme: String,
val fen: String,
val answer: String,
val answerUci: String,
val hint: String,
val rating: Int,
val sourceUrl: String
) {
companion object {
fun from(puzzle: ChessPuzzle, date: LocalDate): ChessPuzzleResponse {
return ChessPuzzleResponse(
id = puzzle.id!!,
date = date,
title = puzzle.title,
theme = puzzle.theme,
fen = puzzle.fen,
answer = puzzle.answer,
answerUci = puzzle.answerUci,
hint = puzzle.hint,
rating = puzzle.rating,
sourceUrl = puzzle.sourceUrl
)
}
}
}

View File

@@ -77,7 +77,7 @@ class SecurityConfig(
auth.requestMatchers("/api/auth/**").permitAll()
// 조회(Read) 작업은 비회원에게도 허용 (GET 메서드 한정)
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**", "/api/chess-puzzles/**").permitAll()
auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll()
// 댓글 API: 비회원 작성/삭제도 지원하므로 전체 허용 (내부 로직에서 비밀번호 검증)

View File

@@ -0,0 +1,54 @@
package me.wypark.blogbackend.domain.chess
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import me.wypark.blogbackend.domain.common.BaseTimeEntity
@Entity
@Table(name = "chess_puzzle")
class ChessPuzzle(
@Column(nullable = false, unique = true, length = 32)
val sourcePuzzleId: String,
@Column(nullable = false)
val sourceUrl: String,
@Column(nullable = false, length = 100)
val title: String,
@Column(nullable = false, length = 80)
val theme: String,
@Column(nullable = false, length = 120)
val fen: String,
@Column(nullable = false, length = 20)
val answer: String,
@Column(nullable = false, length = 8)
val answerUci: String,
@Column(nullable = false)
val hint: String,
@Column(nullable = false)
val rating: Int,
@Column(nullable = false)
val popularity: Int,
@Column(nullable = false)
val sortOrder: Int,
@Column(nullable = false)
val active: Boolean = true
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}

View File

@@ -0,0 +1,8 @@
package me.wypark.blogbackend.domain.chess
import org.springframework.data.jpa.repository.JpaRepository
interface ChessPuzzleRepository : JpaRepository<ChessPuzzle, Long> {
fun findAllByActiveTrueOrderBySortOrderAscIdAsc(): List<ChessPuzzle>
}

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.api.dto.ChessPuzzleResponse
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
@Service
@Transactional(readOnly = true)
class ChessPuzzleService(
private val chessPuzzleRepository: ChessPuzzleRepository,
private val clock: Clock
) {
fun getTodayPuzzle(timezone: String): ChessPuzzleResponse? {
val puzzles = chessPuzzleRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
if (puzzles.isEmpty()) return null
val zoneId = ZoneId.of(timezone)
val today = LocalDate.now(clock.withZone(zoneId))
val index = ChessPuzzleSelector.indexFor(today, puzzles.size)
return ChessPuzzleResponse.from(puzzles[index], today)
}
}
object ChessPuzzleSelector {
private val baseDate: LocalDate = LocalDate.of(2026, 5, 28)
fun indexFor(date: LocalDate, puzzleCount: Int): Int {
require(puzzleCount > 0) { "퍼즐 개수는 1개 이상이어야 합니다." }
return Math.floorMod(ChronoUnit.DAYS.between(baseDate, date).toInt(), puzzleCount)
}
}

View File

@@ -0,0 +1,66 @@
CREATE TABLE chess_puzzle (
id BIGSERIAL PRIMARY KEY,
source_puzzle_id VARCHAR(32) NOT NULL,
source_url VARCHAR(255) NOT NULL,
title VARCHAR(100) NOT NULL,
theme VARCHAR(80) NOT NULL,
fen VARCHAR(120) NOT NULL,
answer VARCHAR(20) NOT NULL,
answer_uci VARCHAR(8) NOT NULL,
hint VARCHAR(255) NOT NULL,
rating INTEGER NOT NULL,
popularity INTEGER NOT NULL,
sort_order INTEGER NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_chess_puzzle_source_puzzle_id UNIQUE (source_puzzle_id)
);
CREATE INDEX idx_chess_puzzle_active_sort_order
ON chess_puzzle (active, sort_order);
INSERT INTO chess_puzzle (
source_puzzle_id,
source_url,
title,
theme,
fen,
answer,
answer_uci,
hint,
rating,
popularity,
sort_order,
active
) VALUES
('001cr', 'https://lichess.org/7FFNwibw/black#76', '오늘의 메이트 #01', 'mateIn1', '8/3B2pp/p5k1/6P1/1ppp1K2/8/1P6/8 w - - 0 39', 'Be8#', 'd7e8', '체크메이트가 되는 한 수를 찾으세요.', 1679, 93, 1, TRUE),
('001wb', 'https://lichess.org/hNzYRMWP#33', '오늘의 메이트 #02', 'mateIn1', 'r3k2r/pb1p1ppp/1b4q1/1Q2P3/8/2NP1PP1/PP4P1/R1B2R1K b kq - 0 17', 'Qh5#', 'g6h5', '체크메이트가 되는 한 수를 찾으세요.', 1144, 95, 2, TRUE),
('002HE', 'https://lichess.org/U21EXYdv#39', '오늘의 메이트 #03', 'mateIn1', '1qr2rk1/1p1p1ppp/pB2p1n1/7n/2P1P3/1Q2NP1P/PP2BKPb/3R1R2 b - - 2 20', 'Qg3#', 'b8g3', '체크메이트가 되는 한 수를 찾으세요.', 1116, 94, 3, TRUE),
('002Q2', 'https://lichess.org/yqAJ1jMv#55', '오늘의 메이트 #04', 'mateIn1', '7k/p4R1p/3p3B/2pN1n2/2PbB1b1/3P2P1/P3r3/5R1K b - - 0 28', 'Nxg3#', 'f5g3', '체크메이트가 되는 한 수를 찾으세요.', 925, 100, 4, TRUE),
('003AX', 'https://lichess.org/kRgejRSt/black#36', '오늘의 메이트 #05', 'mateIn1', '2r2rk1/5ppp/bq2p3/p2pP1N1/Pb1p2P1/1P2P2P/2QN4/2R1K2R w K - 0 19', 'Qxh7#', 'c2h7', '체크메이트가 되는 한 수를 찾으세요.', 1070, 94, 5, TRUE),
('003IX', 'https://lichess.org/576Btq56#85', '오늘의 메이트 #06', 'mateIn1', '8/3pk3/R7/1R2PK1p/2PPn1r1/8/8/8 b - - 0 43', 'Ng3#', 'e4g3', '체크메이트가 되는 한 수를 찾으세요.', 1709, 89, 6, TRUE),
('004JD', 'https://lichess.org/6vTeEc3x#81', '오늘의 메이트 #07', 'mateIn1', '3r4/R7/2p5/p1P2p2/1p4k1/nP2K3/P3NP2/8 b - - 4 41', 'Nc2#', 'a3c2', '체크메이트가 되는 한 수를 찾으세요.', 1297, 91, 7, TRUE),
('004WZ', 'https://lichess.org/BQIdXPOM#51', '오늘의 메이트 #08', 'mateIn1', 'r6k/1b3pp1/p1q1pn1p/2p5/P1B5/1PN4Q/2P1RP1P/R5K1 b - - 0 26', 'Qh1#', 'c6h1', '체크메이트가 되는 한 수를 찾으세요.', 912, 94, 8, TRUE),
('004yJ', 'https://lichess.org/8N3Hfj05#31', '오늘의 메이트 #09', 'mateIn1', 'r4rk1/1bp2ppp/p1q1pn2/2P1N3/8/3B4/P1P1QPPP/R4RK1 b - - 1 16', 'Qxg2#', 'c6g2', '체크메이트가 되는 한 수를 찾으세요.', 926, 100, 9, TRUE),
('004zI', 'https://lichess.org/HE33gdt4/black#54', '오늘의 메이트 #10', 'mateIn1', '2q3k1/4br2/6pQ/1p1n2p1/7P/1P4P1/1B2PP2/6K1 w - - 0 28', 'Qh8#', 'h6h8', '체크메이트가 되는 한 수를 찾으세요.', 1443, 91, 10, TRUE),
('005wJ', 'https://lichess.org/2mU9GJhQ#17', '오늘의 메이트 #11', 'mateIn1', 'r3kb1r/ppqN1ppp/4pn2/1Q3b2/3P4/8/PP2PPPP/RNB1KB1R b KQkq - 0 9', 'Qxc1#', 'c7c1', '체크메이트가 되는 한 수를 찾으세요.', 1389, 80, 11, TRUE),
('005x9', 'https://lichess.org/TiwHxq8n#25', '오늘의 메이트 #12', 'mateIn1', 'r1b1kb1Q/ppp4p/6pB/3P4/2pn4/8/PPP1qPPP/RNK4R b q - 3 13', 'Qxc2#', 'e2c2', '체크메이트가 되는 한 수를 찾으세요.', 906, 100, 12, TRUE),
('007QU', 'https://lichess.org/ZduvBU6U/black#44', '오늘의 메이트 #13', 'mateIn1', '2rq1rk1/1b5p/p3p3/1p1pBpp1/2nP2N1/1RP1PP2/P1Q3PP/3R2K1 w - - 0 23', 'Nh6#', 'g4h6', '체크메이트가 되는 한 수를 찾으세요.', 1108, 100, 13, TRUE),
('007bH', 'https://lichess.org/Hoq46gJU/black#30', '오늘의 메이트 #14', 'mateIn1', 'r2q1rk1/2p3pn/2pbp2p/p2p1p2/P4PQ1/1P1PP3/1BPN2PP/4RR1K w - - 0 16', 'Qxg7#', 'g4g7', '체크메이트가 되는 한 수를 찾으세요.', 963, 87, 14, TRUE),
('009J1', 'https://lichess.org/lPhlt9XF/black#34', '오늘의 메이트 #15', 'mateIn1', 'rn3k1r/pp2bp1p/2p1pNp1/6B1/5P2/7P/PPP4P/2K1RR2 w - - 4 18', 'Bh6#', 'g5h6', '체크메이트가 되는 한 수를 찾으세요.', 1164, 98, 15, TRUE),
('00AGs', 'https://lichess.org/gBoWCcF2/black#46', '오늘의 메이트 #16', 'mateIn1', 'rn5Q/4kp2/2p1p1r1/1q4p1/8/8/4NPPP/3R1K1R w - - 6 24', 'Qd8#', 'h8d8', '체크메이트가 되는 한 수를 찾으세요.', 939, 94, 16, TRUE),
('00AfZ', 'https://lichess.org/xSsuq2RP/black#86', '오늘의 메이트 #17', 'mateIn1', '2r3kq/Q7/8/1brpN3/5Pp1/4P1P1/6K1/1B6 w - - 0 44', 'Qf7#', 'a7f7', '체크메이트가 되는 한 수를 찾으세요.', 1107, 100, 17, TRUE),
('00Bm8', 'https://lichess.org/ynNNLRgG#67', '오늘의 메이트 #18', 'mateIn1', '8/6kp/4b1q1/1p6/1PpPN2Q/2P1P3/r5P1/5RK1 b - - 0 34', 'Qxg2#', 'g6g2', '체크메이트가 되는 한 수를 찾으세요.', 1126, 86, 18, TRUE),
('00C8Y', 'https://lichess.org/hTQm3Cc6#23', '오늘의 메이트 #19', 'mateIn1', 'rnb2rk1/pp3p1p/3p2Pb/4p1q1/3pQ3/5N2/PPP1PPP1/RN2KB1R b KQ - 2 12', 'Qc1#', 'g5c1', '체크메이트가 되는 한 수를 찾으세요.', 949, 96, 19, TRUE),
('00CWE', 'https://lichess.org/VCQ4NAd0/black#62', '오늘의 메이트 #20', 'mateIn1', '3r4/1p4p1/2pB1bBp/p1Pk4/3rp3/P7/1PK2P2/4R3 w - - 2 32', 'Bf7#', 'g6f7', '체크메이트가 되는 한 수를 찾으세요.', 1386, 94, 20, TRUE),
('00DWo', 'https://lichess.org/bSwKB2bL#31', '오늘의 메이트 #21', 'mateIn1', 'b4b1r/3k1ppp/p2p4/1p2p3/3Pq3/N3B3/PP3PPP/R2Q1RK1 b - - 0 16', 'Qxg2#', 'e4g2', '체크메이트가 되는 한 수를 찾으세요.', 1060, 82, 21, TRUE),
('00EDa', 'https://lichess.org/dTj38zoQ#27', '오늘의 메이트 #22', 'mateIn1', 'r1bk3r/ppp1np1p/3P2pP/1N4q1/2BP1n2/8/PPP3P1/R1BQ2KR b - - 0 14', 'Qxg2#', 'g5g2', '체크메이트가 되는 한 수를 찾으세요.', 1049, 98, 22, TRUE),
('00FH6', 'https://lichess.org/ckigRGV8#59', '오늘의 메이트 #23', 'mateIn1', 'r6r/1q2bpk1/7p/p1p1pPpn/Pp2P1nP/1P1B1N2/1BP3P1/3RR1QK b - - 8 30', 'Ng3#', 'h5g3', '체크메이트가 되는 한 수를 찾으세요.', 986, 100, 23, TRUE),
('00GY4', 'https://lichess.org/uDdpetkC/black#54', '오늘의 메이트 #24', 'mateIn1', '3k2r1/pR5R/3r4/4p3/7q/3Pn1PP/PP5K/8 w - - 0 28', 'Rb8#', 'b7b8', '체크메이트가 되는 한 수를 찾으세요.', 1167, 94, 24, TRUE),
('00H1C', 'https://lichess.org/GzcjOw0p/black#44', '오늘의 메이트 #25', 'mateIn1', 'r3r3/1kpR1qpp/p1n2p2/Qp2P2P/1N6/4Pb2/PPP3P1/2K2R2 w - - 1 23', 'Qxc7#', 'a5c7', '체크메이트가 되는 한 수를 찾으세요.', 1242, 94, 25, TRUE),
('00Hk4', 'https://lichess.org/gpoTbULY#35', '오늘의 메이트 #26', 'mateIn1', '5rk1/p4ppp/4p3/1Q6/1P1BN1b1/8/Pq3PPP/2R1KB1R b K - 0 18', 'Qxc1#', 'b2c1', '체크메이트가 되는 한 수를 찾으세요.', 910, 98, 26, TRUE),
('00HoG', 'https://lichess.org/zwJu9mKP/black#72', '오늘의 메이트 #27', 'mateIn1', '5r1k/8/2b2rQp/1p1p2p1/1q4P1/8/8/1B3R1K w - - 0 37', 'Qh7#', 'g6h7', '체크메이트가 되는 한 수를 찾으세요.', 1496, 89, 27, TRUE),
('00Hut', 'https://lichess.org/wzUXW4h3#43', '오늘의 메이트 #28', 'mateIn1', 'r4rk1/4p1bp/3p2p1/q1pP1P2/4QP2/4B3/1pK4P/1B1R3R b - - 1 22', 'Qc3#', 'a5c3', '체크메이트가 되는 한 수를 찾으세요.', 1278, 94, 28, TRUE),
('00Hxb', 'https://lichess.org/FW9LwaHK/black#54', '오늘의 메이트 #29', 'mateIn1', '1rb2k2/p4ppp/2B5/2pr1NP1/2P5/P7/7P/4R1K1 w - - 0 28', 'Re8#', 'e1e8', '체크메이트가 되는 한 수를 찾으세요.', 1125, 83, 29, TRUE),
('00IPp', 'https://lichess.org/JKNcTJw1#69', '오늘의 메이트 #30', 'mateIn1', '4Q3/6pk/p3p2p/5P2/1p1P4/4q2P/2B1n2B/7K b - - 0 35', 'Qf3#', 'e3f3', '체크메이트가 되는 한 수를 찾으세요.', 1553, 90, 30, TRUE);

View File

@@ -0,0 +1,130 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.domain.chess.ChessPuzzle
import me.wypark.blogbackend.domain.chess.ChessPuzzleRepository
import org.junit.jupiter.api.BeforeEach
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.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
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.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class ChessPuzzleControllerIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var chessPuzzleRepository: ChessPuzzleRepository
@Autowired
lateinit var clock: MutableClock
@BeforeEach
fun setUp() {
clock.setDate(LocalDate.of(2026, 5, 28), ZoneId.of("Asia/Seoul"))
}
@Test
fun `today puzzle is public and returns selected puzzle`() {
savePuzzle(sourcePuzzleId = "puzzle-1", sortOrder = 1, answer = "Be8#", answerUci = "d7e8")
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.code").value("SUCCESS"))
.andExpect(jsonPath("$.data.date").value("2026-05-28"))
.andExpect(jsonPath("$.data.title").value("오늘의 메이트"))
.andExpect(jsonPath("$.data.theme").value("mateIn1"))
.andExpect(jsonPath("$.data.answer").value("Be8#"))
.andExpect(jsonPath("$.data.answerUci").value("d7e8"))
}
@Test
fun `today puzzle rotates by date`() {
savePuzzle(sourcePuzzleId = "puzzle-1", sortOrder = 1, answer = "Be8#", answerUci = "d7e8")
savePuzzle(sourcePuzzleId = "puzzle-2", sortOrder = 2, answer = "Qh5#", answerUci = "g6h5")
clock.setDate(LocalDate.of(2026, 5, 28), ZoneId.of("Asia/Seoul"))
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.answer").value("Be8#"))
clock.setDate(LocalDate.of(2026, 5, 29), ZoneId.of("Asia/Seoul"))
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.answer").value("Qh5#"))
}
@Test
fun `today puzzle returns not found when no active puzzle exists`() {
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
.andExpect(status().isNotFound)
.andExpect(jsonPath("$.code").value("CHESS_PUZZLE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("오늘의 체스 퍼즐이 준비되지 않았습니다."))
}
private fun savePuzzle(
sourcePuzzleId: String,
sortOrder: Int,
answer: String,
answerUci: String
): ChessPuzzle {
return chessPuzzleRepository.saveAndFlush(
ChessPuzzle(
sourcePuzzleId = sourcePuzzleId,
sourceUrl = "https://lichess.org/training/$sourcePuzzleId",
title = "오늘의 메이트",
theme = "mateIn1",
fen = "8/3B2pp/p5k1/6P1/1ppp1K2/8/1P6/8 w - - 0 39",
answer = answer,
answerUci = answerUci,
hint = "체크메이트가 되는 한 수를 찾으세요.",
rating = 1200,
popularity = 90,
sortOrder = sortOrder
)
)
}
@TestConfiguration
class ClockTestConfiguration {
@Bean
@Primary
fun mutableClock(): MutableClock = MutableClock()
}
}
class MutableClock(
private var currentInstant: Instant = LocalDate.of(2026, 5, 28)
.atStartOfDay(ZoneId.of("Asia/Seoul"))
.toInstant(),
private val currentZone: ZoneId = ZoneOffset.UTC
) : Clock() {
override fun getZone(): ZoneId = currentZone
override fun withZone(zone: ZoneId): Clock = MutableClock(currentInstant, zone)
override fun instant(): Instant = currentInstant
fun setDate(date: LocalDate, zoneId: ZoneId) {
currentInstant = date.atStartOfDay(zoneId).toInstant()
}
}