diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt index 0cac4e3..134db95 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt @@ -112,8 +112,11 @@ class SecurityConfig( .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음 .authorizeHttpRequests { auth -> auth + .requestMatchers(HttpMethod.GET,"/api/feed").permitAll() + .requestMatchers("/api/stock/**").permitAll() .requestMatchers("/api/ranks/**").permitAll() .requestMatchers("/api/stats/visitors").permitAll() + .requestMatchers(HttpMethod.GET,"/api/stock/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/images/**").permitAll() .requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용 .anyRequest().authenticated() // 나머지 API는 인증 필요 @@ -164,9 +167,11 @@ class SecurityConfig( .requestMatchers( "/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**" ).permitAll() + .requestMatchers(HttpMethod.GET,"/stock/**").permitAll() // 2. 공개 GET API 및 페이지 = permitAll .requestMatchers(HttpMethod.GET, "/api/images/**", + "/stock/**", "/", "/home.bs", "/bums/where.bs", "/user/login.bs", "/user/join.bs", "/blog/viewer/**", "/blog/posts", diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/web/GlobalControllerAdvice.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/web/GlobalControllerAdvice.kt index 7adce05..ab9bce4 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/web/GlobalControllerAdvice.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/web/GlobalControllerAdvice.kt @@ -23,12 +23,20 @@ class GlobalControllerAdvice( } // [추가] 로그인한 경우, User 엔티티(테마 정보 포함)를 모델에 "user"라는 이름으로 추가 +// @ModelAttribute("user") +// fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono { +// return if (userDetails != null) { +// userManager.findById(userDetails.username) +// } else { +// Mono.empty() +// } +// } + @ModelAttribute("user") - fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono { - return if (userDetails != null) { - userManager.findById(userDetails.username) - } else { - Mono.empty() - } + fun currentUser(@AuthenticationPrincipal userDetails: UserDetails?): User? { + if (userDetails == null) return null + + // [수정] Mono 객체를 그대로 반환하지 말고 .block()을 통해 실제 객체를 반환해야 합니다. + return userManager.findById(userDetails.username).block() } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt new file mode 100644 index 0000000..8ef8f91 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt @@ -0,0 +1,92 @@ +package kr.lunaticbum.back.lun.controllers + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpSession +import kotlinx.coroutines.reactor.awaitSingle +import kr.lunaticbum.back.lun.model.KisAuthSession +import kr.lunaticbum.back.lun.model.KisConfigRequest +import kr.lunaticbum.back.lun.model.ResponceResult +import kr.lunaticbum.back.lun.model.ResultMV +import kr.lunaticbum.back.lun.model.UserManager +import kr.lunaticbum.back.lun.service.KisApiService +import kr.lunaticbum.back.lun.service.KisMarketService +import kr.lunaticbum.back.lun.utils.LogService +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +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 +import org.springframework.web.reactive.function.client.WebClient + +// src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt (예시) + +@Controller +@RequestMapping("/stock") +class StockViewController { + @GetMapping("/config") + fun configPage(): ResultMV = ResultMV("content/stock/config").apply { setTitle("Stock API 설정") } + + + @GetMapping("/market") + fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") } + +} + +@RestController +@RequestMapping("/api/stock") +class StockApiController( + private val webClient: WebClient, // WebClientConfig.kt 활용 + private val userManager: UserManager, + private val kisApiService: KisApiService, + private val kisMarketService: KisMarketService, + private val logService: LogService +) { + @PostMapping("/config") + suspend fun saveConfig( + @RequestBody config: KisConfigRequest, + session: HttpSession // 세션 주입 + ): ResponseEntity { + return try { + // 1. KIS 서버로 토큰 발급 테스트 (유효성 검사) + val token = kisApiService.verifyAndGetToken(config).awaitSingle() + + // 2. DB 저장 없이 세션에만 저장 (브라우저 종료 시 삭제) + val authInfo = KisAuthSession( + appKey = config.appKey, + appSecret = config.appSecret, + accountNo = config.accountNo, + accessToken = token + ) + session.setAttribute("KIS_AUTH", authInfo) + + ResponseEntity.ok(ResponceResult().apply { resultCode = 0; resultMsg = "연결 성공" }) + } catch (e: Exception) { + ResponseEntity.ok(ResponceResult().apply { resultCode = 7001; resultMsg = "연결 실패: ${e.message}" }) + } + } + + @GetMapping("/market") + suspend fun getMarketIndicators(): ResponseEntity> { + // 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요) + val token = "..." // 발급받은 토큰 + val appKey = "..." + val appSecret = "..." + + val kospi = kisMarketService.getDomesticIndex("0001", token, appKey, appSecret).awaitSingle() + val kosdaq = kisMarketService.getDomesticIndex("1001", token, appKey, appSecret).awaitSingle() + + // 응답 데이터 정리 + val result = mapOf( + "kospi" to (kospi["output"] as Map<*, *>)["bstp_nmix_prpr"], // 현재가 + "kospi_change" to (kospi["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"], // 등락률 + "kosdaq" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prpr"], + "kosdaq_change" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"] + ) + + return ResponseEntity.ok(result) as ResponseEntity> + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt index ca56ac6..f6fe73c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt @@ -36,6 +36,10 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import kr.lunaticbum.back.lun.model.Message +import kr.lunaticbum.back.lun.service.CommentService +import kr.lunaticbum.back.lun.service.PostHistoryManager +import kr.lunaticbum.back.lun.service.PostManager +import kr.lunaticbum.back.lun.service.WebBookmarkService import org.springframework.stereotype.Controller @Controller diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/BookmarkApiController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/BookmarkApiController.kt index 4c97f59..bda8284 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/BookmarkApiController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/BookmarkApiController.kt @@ -7,13 +7,13 @@ import kr.lunaticbum.back.lun.model.BookmarkDataDto import kr.lunaticbum.back.lun.model.BookmarkImage import kr.lunaticbum.back.lun.model.BookmarkType import kr.lunaticbum.back.lun.model.BookmarkUpdateRequest -import kr.lunaticbum.back.lun.model.CommentService import kr.lunaticbum.back.lun.model.ImageMetaService import kr.lunaticbum.back.lun.model.ImageUrlRequest import kr.lunaticbum.back.lun.model.ImageVisibilityRequest import kr.lunaticbum.back.lun.model.Visibility import kr.lunaticbum.back.lun.model.WebBookmark -import kr.lunaticbum.back.lun.model.WebBookmarkService +import kr.lunaticbum.back.lun.service.CommentService +import kr.lunaticbum.back.lun.service.WebBookmarkService import kr.lunaticbum.back.lun.utils.LogService import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.Page diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt index 0582572..8227fa4 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt @@ -5,6 +5,9 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.model.* +import kr.lunaticbum.back.lun.service.CommentService +import kr.lunaticbum.back.lun.service.PostHistoryManager +import kr.lunaticbum.back.lun.service.PostManager import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.PayloadDecoder import org.springframework.http.HttpStatus diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt index fe4bb94..02a8fb3 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt @@ -5,11 +5,11 @@ import kotlinx.coroutines.reactive.awaitSingle import kr.lunaticbum.back.lun.model.BookmarkImage import kr.lunaticbum.back.lun.model.Comment import kr.lunaticbum.back.lun.model.CommentResponse -import kr.lunaticbum.back.lun.model.CommentService import kr.lunaticbum.back.lun.model.ImageMetaService import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.VoteResponse -import kr.lunaticbum.back.lun.model.WebBookmarkService +import kr.lunaticbum.back.lun.service.CommentService +import kr.lunaticbum.back.lun.service.WebBookmarkService import kr.lunaticbum.back.lun.utils.PayloadDecoder import org.springframework.data.domain.PageRequest import org.springframework.security.core.annotation.AuthenticationPrincipal diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BumsPrivate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BumsPrivate.kt index fed9808..a50bede 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BumsPrivate.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BumsPrivate.kt @@ -8,9 +8,9 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment import kr.lunaticbum.back.lun.model.LocationLog import kr.lunaticbum.back.lun.model.Post -import kr.lunaticbum.back.lun.model.PostManager import kr.lunaticbum.back.lun.model.ResponceResult import kr.lunaticbum.back.lun.model.ResultMV +import kr.lunaticbum.back.lun.service.PostManager import kr.lunaticbum.back.lun.services.LocationLogService import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.plainText diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt index 2d88dfc..37e44aa 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.model.* +import kr.lunaticbum.back.lun.model.dto.FeedResponse +import kr.lunaticbum.back.lun.service.FeedService +import kr.lunaticbum.back.lun.service.PostManager import kr.lunaticbum.back.lun.services.ImageService import kr.lunaticbum.back.lun.utils.LogService import net.coobird.thumbnailator.Thumbnails @@ -32,6 +35,7 @@ class PostViewController( private val visitorLogService: VisitorLogService, private val objectMapper: ObjectMapper, private val logService: LogService, + private val feedService: FeedService, private val imageService: ImageService // [주입 추가] ) { @Value("\${image.upload.path}") @@ -121,7 +125,11 @@ class PostViewController( } } - + @GetMapping("/api/feed") + @ResponseBody + suspend fun getFeedMore(@RequestParam cursor: Long): FeedResponse { + return feedService.getGlobalFeed(cursor, 10).awaitSingle() + } // --- View Endpoints --- @GetMapping("/", "/home.bs") @@ -148,7 +156,10 @@ class PostViewController( vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8") vm.modelMap["gibberishId"] = randomGibberish.id } + val feedData = feedService.getGlobalFeed(null, 10).awaitSingle() + vm.modelMap["feedItems"] = feedData.items + vm.modelMap["nextCursor"] = feedData.nextCursor // HTML에 hidden으로 숨겨둠 val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() vm.modelMap["Posts"] = postsList.map { processPostForView(it) } vm.modelMap["path"] = "/blog/viewer/" diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt new file mode 100644 index 0000000..52cf973 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt @@ -0,0 +1,18 @@ +package kr.lunaticbum.back.lun.model +// src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt + +// 클라이언트로부터 설정을 받을 때 사용 +data class KisConfigRequest( + val appKey: String, + val appSecret: String, + val accountNo: String +) + +// 세션에 저장하거나 API 응답에 사용할 토큰 정보 +data class KisAuthSession( + val appKey: String, + val appSecret: String, + val accountNo: String, + var accessToken: String? = null, + var tokenExpiredAt: Long = 0L +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt index 6a63360..7da4dab 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt @@ -1,5 +1,7 @@ package kr.lunaticbum.back.lun.model +import kr.lunaticbum.back.lun.repository.PostHistoryRepository +import kr.lunaticbum.back.lun.repository.PostRepository import org.springframework.data.domain.Sort import org.springframework.data.mongodb.core.ReactiveMongoTemplate import org.springframework.data.mongodb.core.aggregation.Aggregation diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index db043e2..3fa309b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -2,6 +2,7 @@ package kr.lunaticbum.back.lun.model import com.fasterxml.jackson.databind.ObjectMapper import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment +import kr.lunaticbum.back.lun.repository.PostHistoryRepository import kr.lunaticbum.back.lun.utils.LogService import lombok.AllArgsConstructor import lombok.Data @@ -75,25 +76,7 @@ data class PostHistory( var archivedAt: Long = System.currentTimeMillis() // 보관된 시간 ) -// 2. PostHistory를 위한 Repository 인터페이스 -@Repository -interface PostHistoryRepository : ReactiveMongoRepository { - // [추가] postId로 모든 히스토리를 최신순으로 조회 - fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux -} -// 3. PostHistory를 위한 Service 클래스 -@Service -class PostHistoryManager(private val repository: PostHistoryRepository) { - fun save(postHistory: PostHistory): Mono { - return repository.save(postHistory) - } - - // [추가] postId로 모든 히스토리를 조회하는 함수 - fun findByPostId(postId: String): Flux { - return repository.findByPostIdOrderByArchivedAtDesc(postId) - } -} enum class PostType { STANDARD, // 일반 블로그 글 @@ -178,487 +161,7 @@ class CommentsResult { data class AggregationCount(val totalCount: Long) -@Repository -interface CommentRepository : ReactiveMongoRepository { - fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux // 최상위 댓글 - fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux - fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] -} -@Service -class CommentService(private val commentRepository: CommentRepository) { - - /** - * [수정] 각 댓글의 content를 URL 디코딩하는 로직을 추가합니다. - */ - private fun decodeCommentContent(comment: Comment): Comment { - comment.content = comment.content?.let { URLDecoder.decode(it, "UTF-8") } - return comment - } - - fun getRepliesForComment(parentId: String): Flux { - return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 - } - fun addComment(comment: Comment): Mono { - // 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리 - return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 - } - - fun getCommentsForPost(postId: String): Flux { - return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 - } - fun findCommentsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] - return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 - } - // 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능 -} - -@Repository -interface PostRepository : ReactiveMongoRepository { - fun findAllByModifyTime(time : Long? = 0): Flux - // @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }") - fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux - fun countByOrderByModifyTimeDesc(): Mono - @Aggregation(pipeline = [ - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", - "{ \$sort: { \"modifyTime\": -1 } }" - ]) - fun findTop5ByOrderByReadCountDesc(): Flux - - @Aggregation(pipeline = [ - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", - "{ \$sort: { \"modifyTime\": -1 } }" - ]) - fun findTop5ByOrderByModifyTimeDesc(): Flux - fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux - - // [단순화] 공개된 글 목록 조회 (페이지네이션) - fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux - - // [단순화] 공개된 글 개수 카운트 - fun countByPostingIsTrue(): Mono - - // [단순화] 인기글 5개 조회 (공개된 글 대상) - fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux - - // [단순화] 최신글 5개 조회 (공개된 글 대상) - fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux - - // [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글) - fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux - fun countByPostingIsTrueOrWriter(writer: String): Mono - - - // [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상) - @Aggregation(pipeline = [ - // 1. 모든 글을 최신순으로 정렬 - "{ \$sort: { modifyTime: -1 } }", - // 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출 - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - // 3. 원래 Post 형태로 복원 - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - // 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링 - "{ \$match: { posting: true, isBlocked: false } }", - // 5. 최종 목록을 조회수(readCount) 순으로 정렬 - "{ \$sort: { readCount: -1 } }", - // 6. 상위 5개만 선택 - "{ \$limit: 5 }" - ]) - fun findTop5UniquePublishedByReadCountDesc(): Flux - - // [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상) - @Aggregation(pipeline = [ - // 1. 모든 글을 최신순으로 정렬 - "{ \$sort: { modifyTime: -1 } }", - // 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출 - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - // 3. 원래 Post 형태로 복원 - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - // 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링 - "{ \$match: { posting: true, isBlocked: false } }", - // 5. 최종 목록을 다시 최신순으로 정렬 - "{ \$sort: { modifyTime: -1 } }", - // 6. 상위 5개만 선택 - "{ \$limit: 5 }" - ]) - fun findTop5UniquePublishedByModifyTimeDesc(): Flux - - - /** - * 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다. - * [버그 수정] 2차 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경 - */ - @Aggregation(pipeline = [ - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - "{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨] - "{ \$replaceRoot: { newRoot: \"\$post\" } }" - ]) - fun findLatestUniqueOriginPaginated(pageable: Pageable): Flux - - /** - * '고유 최신 글'의 총 개수를 카운트합니다. (페이지네이션의 totalElements 계산용) - */ - @Aggregation(pipeline = [ - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화 - "{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈 - ]) - fun countLatestUniqueOrigin(): Mono // 헬퍼 클래스로 매핑 - - @Aggregation(pipeline = [ - "{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }", - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - "{ \$sort: { \"modifyTime\": -1 } }" - ]) - fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux - - @Aggregation(pipeline = [ - "{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", - "{ \$count: \"totalCount\" }" - ]) - fun countLatestUniqueForWriter(username: String): Mono - - - /** - * [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다. - */ - @Aggregation(pipeline = [ - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", - "{ \$sort: { \"modifyTime\": -1 } }" - ]) - fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux - - /** - * '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다. - * [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다. - */ - @Aggregation(pipeline = [ - "{ \$sort: { modifyTime: -1 } }", - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", - "{ \$replaceRoot: { newRoot: \"\$post\" } }", - "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", - "{ \$count: \"totalCount\" }" - ]) - fun countLatestUniquePublished(): Mono - - fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] - - // [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회 - @Aggregation(pipeline = [ - "{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링 - "{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링 - ]) - fun findRandomPublishedPostByType(postType: String): Mono - - // --- [신규 추가] 필터링을 위한 Repository 메소드 --- - fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux - fun countByCategoryAndPostingIsTrue(category: String): Mono - fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux - fun countByTagsRegexAndPostingIsTrue(tag: String): Mono - // [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회 - @Aggregation(pipeline = [ - // 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환 - "{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }", - // 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"}) - "{ \$unwind: \"\$tags\" }", - // 3. 각 태그의 앞뒤 공백 제거 - "{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }", - // 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출 - "{ \$group: { _id: \"\$tag\" } }", - // 5. 그룹화 결과 중 빈 값("")은 제외 - "{ \$match: { _id: { \$ne: \"\" } } }" - ]) - fun findDistinctTags(): Flux // 반환 타입을 Document로 변경 - - // [신규 추가] GIBBERISH 제외하고 조회 - fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux - fun countByPostTypeNot(postType: String): Mono -} - - -@Service -class PostManager( - private val postRepository: PostRepository, - private val reactiveMongoTemplate: ReactiveMongoTemplate -) { - @Autowired - private lateinit var logService: LogService - - @Autowired - private lateinit var bCryptPasswordEncoder: PasswordEncoder - - fun deletePost(postId: String): Mono { - return postRepository.deleteById(postId) - } - - // [수정] 익명 사용자용 목록 조회 (Aggregation 사용) - fun findLatestUniquePaginated(pageable: Pageable) : Mono> { - return postRepository.findLatestUniquePublishedPaginated(pageable) - .collectList() - } - - // [수정] 익명 사용자용 글 개수 (Aggregation 사용) - fun countLatestUnique(): Mono { - return postRepository.countLatestUniquePublished() - .map { it.totalCount } - .switchIfEmpty(Mono.just(0L)) - } - - // [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용) - fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { - return postRepository.findLatestUniqueForWriterPaginated(username, pageable) - .collectList() - } - - // [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용) - fun countLatestUniqueForWriter(username: String): Mono { - return postRepository.countLatestUniqueForWriter(username) - .map { it.totalCount } - .switchIfEmpty(Mono.just(0L)) - } - - // [수정] 익명 사용자용 인기글 - fun getTop5UniquePublishedByViews(): Flux { - return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p -> - p.title = URLDecoder.decode(p.title, "UTF-8") - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - p - } - } - - // [수정] 익명 사용자용 최신글 - fun getRecent5UniquePublished(): Flux { - return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map { - p -> - p.title = URLDecoder.decode(p.title, "UTF-8") - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - p - } - } - - // --- [신규 추가] 카테고리/태그 관련 서비스 메소드 --- - fun findAllDistinctCategories(): Flux { - // 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행 - val query = Query.query(Criteria.where("category").ne(null).ne("")) - return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java) - } - - fun findAllDistinctTags(): Flux { - return postRepository.findDistinctTags() - .mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출 - .filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링 - } - // --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 --- - fun findPostsByCategory(category: String, pageable: Pageable): Mono> { - return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList() - } - fun countPostsByCategory(category: String): Mono { - return postRepository.countByCategoryAndPostingIsTrue(category) - } - - fun findPostsByTag(tag: String, pageable: Pageable): Mono> { - // [수정] 한글 및 다국어를 지원하는 정규식으로 변경 - val regex = "(^|,)${Regex.escape(tag)}(,|$)" - return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList() - } - - fun countPostsByTag(tag: String): Mono { - // [수정] 위와 동일하게 정규식 변경 - val regex = "(^|,)${Regex.escape(tag)}(,|$)" - return postRepository.countByTagsRegexAndPostingIsTrue(regex) - } - - // [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드 - fun findRandomGibberish(): Mono { - return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name) - } - - // [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드 - fun findLatestAboutPost(): Mono { - // 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴 - return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name) - .next() // Flux에서 첫 번째 아이템(Mono)을 반환 - } - - // [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드 - fun findAboutPostHistory(): Flux { - return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name) - } - - // [신규] 게시물 차단 - fun blockPost(postId: String): Mono { - return postRepository.findById(postId).flatMap { post -> - post.isBlocked = true - postRepository.save(post) - } - } - - // [신규] 게시물 차단 해제 - fun unblockPost(postId: String): Mono { - return postRepository.findById(postId).flatMap { post -> - post.isBlocked = false - postRepository.save(post) - } - } - - fun findById(id: String): Mono { - return postRepository.findById(id) - } - - - fun findPostsByWriter(writer: String, pageable: Pageable): Flux { - return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable) - .map { post -> - post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" - if (post.title.isNullOrBlank()) { - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") - post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" - } - post - } - } - - - fun getPost(id: String): Mono { - val query = Query.query(Criteria.where("id").`is`(id)) - val update = Update().inc("readCount", 1) - - // 이 메서드는 기본값(returnNew=false)를 사용하여, 증가되기 *전*의 문서를 반환합니다. - // (뷰어 로딩과 동시에 DB 카운트만 1 증가시킴) - return reactiveMongoTemplate.findAndModify(query, update, Post::class.java) - .switchIfEmpty(Mono.error(NoSuchElementException("Post not found with id $id"))) - } - - - /** - * 인증된 사용자를 위한 메서드 (모든 버전 조회, GIBBERISH 제외) - */ - fun findAllVersionsPaginated(pageable :Pageable) : Mono> { - return postRepository.findByPostTypeNotOrderByModifyTimeDesc(PostType.GIBBERISH.name, pageable) - .map { post -> - // 1. 제목을 UTF-8로 디코딩합니다. - post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" - - // 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다. - if (post.title.isNullOrBlank()) { - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") - post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" - } - post // 수정된 post 객체를 반환 - } - .collectList() - } - - /** - * 인증된 사용자가 보는 글의 총 개수 (GIBBERISH 제외) - */ - fun countAllVersions(): Mono { - return postRepository.countByPostTypeNot(PostType.GIBBERISH.name) - } - - /** - * 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 수 있도록 *업데이트된* 문서를 반환합니다. - */ - fun incrementVote(postId: String): Mono { - val query = Query.query(Criteria.where("id").`is`(postId)) - val update = Update().inc("voteCount", 1) - // options().returnNew(true) : 업데이트된 후의 새 문서를 반환하도록 설정 - val options = FindAndModifyOptions.options().returnNew(true) - return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java) - } - - /** - * 싫어요 카운트를 1 증가시키고, *업데이트된* 문서를 반환합니다. - */ - fun incrementUnlike(postId: String): Mono { - val query = Query.query(Criteria.where("id").`is`(postId)) - val update = Update().inc("unlikeCount", 1) - val options = FindAndModifyOptions.options().returnNew(true) - return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java) - } - - - fun getTop10Posts(): Flux { - return postRepository.findTop5ByOrderByReadCountDesc().map { p -> - p.title = URLDecoder.decode(p.title) - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - println(p.title) - p - } - } - - fun getRecent10Posts(): Flux { - return postRepository.findTop5ByOrderByModifyTimeDesc().map { p -> - p.title = URLDecoder.decode(p.title) - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - println(p.title) - p - } - - } - - - /** - * 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다. - */ - fun find8() : Mono> { - val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8 - return this.findLatestUniquePaginated(pageRequest) - } - - fun save(post: Post): Mono { - println("saved user before ${post}") -// user.hashPassword(bCryptPasswordEncoder) - return postRepository.save(post) - .doOnSuccess { savedPost -> - // 저장이 완료되었을 때 실행될 로직 (로그 출력 등) - println("saved post success: ${savedPost.id}") - } - } - - // [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions) - fun getTop5AllVersionsByViews(): Flux { - return postRepository.findTop5ByOrderByReadCountDesc().map { p -> - p.title = URLDecoder.decode(p.title, "UTF-8") - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - p - } - } - - // [기존] 로그인 사용자용 최신글 (메서드 이름 명확화) - fun getRecent5AllVersions(): Flux { - return postRepository.findTop5ByOrderByModifyTimeDesc().map { p -> - p.title = URLDecoder.decode(p.title, "UTF-8") - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - p - } - } -} /** @@ -839,118 +342,3 @@ data class WebBookmark( } } -@Repository -interface WebBookmarkRepository : ReactiveMongoRepository { - fun findByUserIdOrderBySavedAtDesc(userId: String): Flux - fun findByVisibilityInOrderBySavedAtDesc(visibilities: List, pageable: Pageable): Flux - fun countByVisibilityIn(visibilities: List): Mono - - fun findByMetadataStatus(status: String): Flux - - // [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동) - @Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }") - fun findDistinctTags(): Flux> - - @Aggregation("{ \$group: { _id: '\$category' } }") - fun findDistinctCategories(): Flux> -} - -@Service -class WebBookmarkService(private val repository: WebBookmarkRepository, - private val reactiveMongoTemplate: ReactiveMongoTemplate - // [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다. -) { - // [이 메소드를 추가하세요] - fun findById(id: String): Mono { - return repository.findById(id) - } - // [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지 - fun findAllDistinctCategories(): Flux { - return repository.findDistinctCategories() - .flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) } - .filter { it.isNotBlank() } - } - - // [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지 - fun findAllDistinctTags(): Flux { - return repository.findDistinctTags() - .flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) } - .filter { it.isNotBlank() } - } - - - fun getBookmarksForUser(userId: String): Flux { - return repository.findByUserIdOrderBySavedAtDesc(userId) - } - - fun saveBookmark(bookmark: WebBookmark): Mono { - // 여기에 중복 저장 방지 로직 등을 추가할 수 있음 - return repository.save(bookmark) - } - - // 필요하다면 삭제, 수정 기능 추가 - fun deleteBookmark(id: String): Mono { - return repository.deleteById(id) - } - - // [수정] getVisibleBookmarks 메소드에 필터링 기능 추가 - fun getVisibleBookmarks( - userDetails: UserDetails?, - pageable: Pageable, - category: String?, // 카테고리 파라미터 추가 - tag: String? // 태그 파라미터 추가 - ): Mono> { - val visibleScopes = when { - userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name) - else -> listOf(Visibility.PUBLIC.name) - } - - // 동적 쿼리 생성 시작 - val query = Query(Criteria.where("visibility").`in`(visibleScopes)) - .with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요. - .with(pageable) - - // 카테고리 조건 추가 - if (!category.isNullOrBlank()) { - query.addCriteria(Criteria.where("category").`is`(category)) - } - - // 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인) - if (!tag.isNullOrBlank()) { - query.addCriteria(Criteria.where("tags").`in`(tag)) - } - - // 데이터 조회 및 카운트 - val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList() - val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java) - - return Mono.zip(bookmarks, totalCount).map { tuple -> - PageImpl(tuple.t1, pageable, tuple.t2) - } - } - - /** - * 북마크의 좋아요 카운트를 1 증가시킵니다. - * @param bookmarkId 대상 북마크의 ID - * @return 업데이트된 WebBookmark 객체 - */ - fun incrementVote(bookmarkId: String): Mono { - val query = Query.query(Criteria.where("id").`is`(bookmarkId)) - val update = Update().inc("voteCount", 1) - val options = FindAndModifyOptions.options().returnNew(true) - return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java) - } - - /** - * 북마크의 싫어요 카운트를 1 증가시킵니다. - * @param bookmarkId 대상 북마크의 ID - * @return 업데이트된 WebBookmark 객체 - */ - fun incrementUnlike(bookmarkId: String): Mono { - val query = Query.query(Criteria.where("id").`is`(bookmarkId)) - val update = Update().inc("unlikeCount", 1) - val options = FindAndModifyOptions.options().returnNew(true) - return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java) - } - -} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt new file mode 100644 index 0000000..576f227 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt @@ -0,0 +1,36 @@ +package kr.lunaticbum.back.lun.model.dto + +enum class ContentType { POST, GIBBERISH, BOOKMARK } + +//interface FeedItem { +// val id: String? +// val type: ContentType +// val title: String? +// val content: String? // 요약 내용 또는 본문 +// val thumbnail: String? // 대표 이미지 +// val createdAt: Long // 정렬 기준 시간 +// val writer: String? +// val url: String // 클릭 시 이동할 경로 +//} + +// 피드 아이템 통합 DTO +data class FeedItemDto( + val id: String?, // 원본 게시물/북마크 ID + val type: ContentType, // 콘텐츠 타입 (POST, GIBBERISH, BOOKMARK) + val title: String?, // 제목 (Gibberish는 없을 수 있음) + val content: String?, // 내용 요약 또는 전체 (HTML 제거된 텍스트 권장) + val thumbnail: String?, // 썸네일 이미지 URL + val createdAt: Long, // 작성일/저장일 (정렬 기준) + val writer: String?, // 작성자 + val url: String, // 클릭 시 이동할 주소 (내부 글보기 또는 외부 링크) + + // 필요하다면 추가할 필드들 + val voteCount: Long = 0, // 좋아요 수 + val commentCount: Int = 0, // 댓글 수 (나중에 추가 가능) + val tags: List? = null // 태그 목록 +) + +data class FeedResponse( + val items: List, + val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다. +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt new file mode 100644 index 0000000..3c7d522 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt @@ -0,0 +1,15 @@ +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.Comment +import org.springframework.data.domain.Pageable +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface CommentRepository : ReactiveMongoRepository { + fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux // 최상위 댓글 + fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux + fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] +} + diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostHistoryRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostHistoryRepository.kt new file mode 100644 index 0000000..2f9c958 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostHistoryRepository.kt @@ -0,0 +1,14 @@ +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.PostHistory +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + + +// 2. PostHistory를 위한 Repository 인터페이스 +@Repository +interface PostHistoryRepository : ReactiveMongoRepository { + // [추가] postId로 모든 히스토리를 최신순으로 조회 + fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt new file mode 100644 index 0000000..6acd908 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt @@ -0,0 +1,197 @@ +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.AggregationCount +import kr.lunaticbum.back.lun.model.Post +import org.springframework.data.domain.Pageable +import org.springframework.data.mongodb.repository.Aggregation +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Repository +interface PostRepository : ReactiveMongoRepository { + + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + // [핵심] GIBBERISH 포함, 공개글, 차단안됨, 그리고 입력받은 시간(?0)보다 과거의 글만 조회 + "{ \$match: { posting: true, isBlocked: false, modifyTime: { \$lt: ?0 } } }", + "{ \$sort: { \"modifyTime\": -1 } }" + ]) + fun findFeedPostsBefore(timestamp: Long, pageable: Pageable): Flux + + + fun findAllByModifyTime(time : Long? = 0): Flux + // @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }") + fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux + fun countByOrderByModifyTimeDesc(): Mono + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", + "{ \$sort: { \"modifyTime\": -1 } }" + ]) + fun findTop5ByOrderByReadCountDesc(): Flux + + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", + "{ \$sort: { \"modifyTime\": -1 } }" + ]) + fun findTop5ByOrderByModifyTimeDesc(): Flux + fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux + + // [단순화] 공개된 글 목록 조회 (페이지네이션) + fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux + + // [단순화] 공개된 글 개수 카운트 + fun countByPostingIsTrue(): Mono + + // [단순화] 인기글 5개 조회 (공개된 글 대상) + fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux + + // [단순화] 최신글 5개 조회 (공개된 글 대상) + fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux + + // [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글) + fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux + fun countByPostingIsTrueOrWriter(writer: String): Mono + + + // [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상) + @Aggregation(pipeline = [ + // 1. 모든 글을 최신순으로 정렬 + "{ \$sort: { modifyTime: -1 } }", + // 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출 + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + // 3. 원래 Post 형태로 복원 + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + // 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링 + "{ \$match: { posting: true, isBlocked: false } }", + // 5. 최종 목록을 조회수(readCount) 순으로 정렬 + "{ \$sort: { readCount: -1 } }", + // 6. 상위 5개만 선택 + "{ \$limit: 5 }" + ]) + fun findTop5UniquePublishedByReadCountDesc(): Flux + + // [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상) + @Aggregation(pipeline = [ + // 1. 모든 글을 최신순으로 정렬 + "{ \$sort: { modifyTime: -1 } }", + // 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출 + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + // 3. 원래 Post 형태로 복원 + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + // 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링 + "{ \$match: { posting: true, isBlocked: false } }", + // 5. 최종 목록을 다시 최신순으로 정렬 + "{ \$sort: { modifyTime: -1 } }", + // 6. 상위 5개만 선택 + "{ \$limit: 5 }" + ]) + fun findTop5UniquePublishedByModifyTimeDesc(): Flux + + + /** + * 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다. + * [버그 수정] 2차 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경 + */ + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨] + "{ \$replaceRoot: { newRoot: \"\$post\" } }" + ]) + fun findLatestUniqueOriginPaginated(pageable: Pageable): Flux + + /** + * '고유 최신 글'의 총 개수를 카운트합니다. (페이지네이션의 totalElements 계산용) + */ + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화 + "{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈 + ]) + fun countLatestUniqueOrigin(): Mono // 헬퍼 클래스로 매핑 + + @Aggregation(pipeline = [ + "{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }", + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$sort: { \"modifyTime\": -1 } }" + ]) + fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux + + @Aggregation(pipeline = [ + "{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", + "{ \$count: \"totalCount\" }" + ]) + fun countLatestUniqueForWriter(username: String): Mono + + + /** + * [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다. + */ + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", + "{ \$sort: { \"modifyTime\": -1 } }" + ]) + fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux + + /** + * '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다. + * [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다. + */ + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + "{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }", + "{ \$count: \"totalCount\" }" + ]) + fun countLatestUniquePublished(): Mono + + fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] + + // [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회 + @Aggregation(pipeline = [ + "{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링 + "{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링 + ]) + fun findRandomPublishedPostByType(postType: String): Mono + + // --- [신규 추가] 필터링을 위한 Repository 메소드 --- + fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux + fun countByCategoryAndPostingIsTrue(category: String): Mono + fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux + fun countByTagsRegexAndPostingIsTrue(tag: String): Mono + // [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회 + @Aggregation(pipeline = [ + // 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환 + "{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }", + // 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"}) + "{ \$unwind: \"\$tags\" }", + // 3. 각 태그의 앞뒤 공백 제거 + "{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }", + // 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출 + "{ \$group: { _id: \"\$tag\" } }", + // 5. 그룹화 결과 중 빈 값("")은 제외 + "{ \$match: { _id: { \$ne: \"\" } } }" + ]) + fun findDistinctTags(): Flux // 반환 타입을 Document로 변경 + + // [신규 추가] GIBBERISH 제외하고 조회 + fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux + fun countByPostTypeNot(postType: String): Mono +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/WebBookmarkRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/WebBookmarkRepository.kt new file mode 100644 index 0000000..198237a --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/WebBookmarkRepository.kt @@ -0,0 +1,37 @@ +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.WebBookmark +import org.springframework.data.domain.Pageable +import org.springframework.data.mongodb.repository.Aggregation +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Repository +interface WebBookmarkRepository : ReactiveMongoRepository { + + + // WebBookmarkRepository 인터페이스 내부에 추가 +// savedAt이 특정 시간(?1)보다 작은 것들 중 최신순 조회 + fun findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc( + visibilities: List, + maxTime: Long, + pageable: Pageable + ): Flux + + + fun findByUserIdOrderBySavedAtDesc(userId: String): Flux + fun findByVisibilityInOrderBySavedAtDesc(visibilities: List, pageable: Pageable): Flux + fun countByVisibilityIn(visibilities: List): Mono + + fun findByMetadataStatus(status: String): Flux + + // [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동) + @Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }") + fun findDistinctTags(): Flux> + + @Aggregation("{ \$group: { _id: '\$category' } }") + fun findDistinctCategories(): Flux> +} + diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt index 415137e..264bfca 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/BookmarkProcessorService.kt @@ -4,7 +4,7 @@ package kr.lunaticbum.back.lun.service import kr.lunaticbum.back.lun.model.BookmarkType import kr.lunaticbum.back.lun.model.MetadataStatus import kr.lunaticbum.back.lun.model.WebBookmark -import kr.lunaticbum.back.lun.model.WebBookmarkRepository +import kr.lunaticbum.back.lun.repository.WebBookmarkRepository import kr.lunaticbum.back.lun.utils.LogService import org.jsoup.Jsoup import org.springframework.data.mongodb.repository.ReactiveMongoRepository diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt new file mode 100644 index 0000000..83f06a2 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt @@ -0,0 +1,38 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.Comment +import kr.lunaticbum.back.lun.repository.CommentRepository +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.net.URLDecoder + + +@Service +class CommentService(private val commentRepository: CommentRepository) { + + /** + * [수정] 각 댓글의 content를 URL 디코딩하는 로직을 추가합니다. + */ + private fun decodeCommentContent(comment: Comment): Comment { + comment.content = comment.content?.let { URLDecoder.decode(it, "UTF-8") } + return comment + } + + fun getRepliesForComment(parentId: String): Flux { + return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + } + fun addComment(comment: Comment): Mono { + // 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리 + return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + } + + fun getCommentsForPost(postId: String): Flux { + return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + } + fun findCommentsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] + return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + } + // 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능 +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt new file mode 100644 index 0000000..cad67e9 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt @@ -0,0 +1,77 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.dto.ContentType +import kr.lunaticbum.back.lun.model.dto.FeedItemDto +import kr.lunaticbum.back.lun.model.dto.FeedResponse +import kr.lunaticbum.back.lun.repository.PostRepository +import kr.lunaticbum.back.lun.repository.WebBookmarkRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +// service/FeedService.kt + +@Service +class FeedService( + private val postRepository: PostRepository, + private val bookmarkRepository: WebBookmarkRepository +) { + /** + * @param cursorTime 클라이언트가 가지고 있는 마지막 글의 시간 (첫 요청시엔 현재시간 or 아주 큰 값) + * @param size 한 번에 불러올 개수 (예: 10개) + */ + fun getGlobalFeed(cursorTime: Long?, size: Int): Mono { + // 커서가 없으면 현재 시간으로 설정 (첫 로딩) + val lastTime = cursorTime ?: System.currentTimeMillis() + + // 각 저장소에서 'size' 만큼만 가져옴 (부하 최소화) + val pageable = PageRequest.of(0, size) + + // 1. Post 조회 (lastTime 이전 글) + val postsFlux = postRepository.findFeedPostsBefore(lastTime, pageable) + .map { post -> + val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST + val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거 + + FeedItemDto( + id = post.id, + type = type, + title = post.title, + content = if (type == ContentType.GIBBERISH) post.content else rawContent, + thumbnail = post.thumb, + createdAt = post.modifyTime, + writer = post.writer, + url = "/blog/viewer/${post.id}" + ) + } + + // 2. Bookmark 조회 (lastTime 이전 글) + val bookmarksFlux = bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc( + listOf("PUBLIC"), lastTime, pageable + ).map { bookmark -> + FeedItemDto( + id = bookmark.id, + type = ContentType.BOOKMARK, + title = bookmark.title ?: bookmark.url, + content = bookmark.userComment ?: bookmark.description, + thumbnail = bookmark.displayImageUrl, + createdAt = bookmark.savedAt, + writer = bookmark.userId, + url = bookmark.url ?: "" + ) + } + + // 3. 병합 후 다시 정렬하고 size 만큼 자르기 + return Flux.merge(postsFlux, bookmarksFlux) + .sort(Comparator.comparing(FeedItemDto::createdAt).reversed()) // 최신순 정렬 + .take(size.toLong()) // 전체 중 상위 size 개만 선택 + .collectList() + .map { items -> + // 마지막 아이템의 시간을 다음 커서로 설정 + val nextCursor = if (items.isNotEmpty()) items.last().createdAt else null + FeedResponse(items, nextCursor) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt new file mode 100644 index 0000000..ed19ce6 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt @@ -0,0 +1,66 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.KisConfigRequest +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + +@Service +class KisApiService() { + private val baseUrl = "https://openapi.koreainvestment.com:9443" // 실전투자용 + private val webClient: WebClient = WebClient.create(baseUrl) + + fun verifyAndGetToken(config: KisConfigRequest): Mono { + val body = mapOf( + "grant_type" to "client_credentials", + "appkey" to config.appKey, + "appsecret" to config.appSecret + ) + + return webClient.post() + .uri("$baseUrl/oauth2/tokenP") + .bodyValue(body) + .retrieve() + .bodyToMono(Map::class.java) + .map { it["access_token"]?.toString() ?: throw Exception("토큰 발급 실패") } + } +} + +@Service +class KisMarketService() { + private val baseUrl = "https://openapi.koreainvestment.com:9443" + private val webClient: WebClient = WebClient.create(baseUrl) + // 1. 국내 지수 조회 (KOSPI: "0001", KOSDAQ: "1001") + fun getDomesticIndex(indexCode: String, token: String, appKey: String, appSecret: String): Mono> { + return WebClient.create(baseUrl).get() + .uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-index-price") + .queryParam("FID_COND_MRKT_DIV_CODE", "U") // 업종 + .queryParam("FID_INPUT_ISCD", indexCode) + .build() + } + .header("authorization", "Bearer $token") + .header("appkey", appKey) + .header("appsecret", appSecret) + .header("tr_id", "FHPST01010000") // 업종 현재가 조회 TR + .retrieve() + .bodyToMono(Map::class.java) + } + + // 2. 환율 및 해외 지수 조회 (환율: "FX@KRW", 나스닥: "NAS@IXIC") + // ※ 해외 지수는 '해외주식 현재가 상세' API 등을 활용합니다. + fun getMarketIndicator(symbol: String, token: String, appKey: String, appSecret: String): Mono> { + return webClient.get() + .uri { it.path("/uapi/overseas-stock/v1/quotations/price") + .queryParam("AUTH", "") + .queryParam("EXCD", symbol.split("@")[0]) // 거래소 코드 + .queryParam("SYMB", symbol.split("@")[1]) // 심볼 + .build() + } + .header("authorization", "Bearer $token") + .header("appkey", appKey) + .header("appsecret", appSecret) + .header("tr_id", "HHDFS00000300") // 해외주식 현재가 상세 TR + .retrieve() + .bodyToMono(Map::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/PostHistoryManager.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/PostHistoryManager.kt new file mode 100644 index 0000000..d4e3ae4 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/PostHistoryManager.kt @@ -0,0 +1,21 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.PostHistory +import kr.lunaticbum.back.lun.repository.PostHistoryRepository +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + + +// 3. PostHistory를 위한 Service 클래스 +@Service +class PostHistoryManager(private val repository: PostHistoryRepository) { + fun save(postHistory: PostHistory): Mono { + return repository.save(postHistory) + } + + // [추가] postId로 모든 히스토리를 조회하는 함수 + fun findByPostId(postId: String): Flux { + return repository.findByPostIdOrderByArchivedAtDesc(postId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/PostManager.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/PostManager.kt new file mode 100644 index 0000000..058d74e --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/PostManager.kt @@ -0,0 +1,293 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.Post +import kr.lunaticbum.back.lun.model.PostType +import kr.lunaticbum.back.lun.repository.PostRepository +import kr.lunaticbum.back.lun.utils.LogService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.mongodb.core.FindAndModifyOptions +import org.springframework.data.mongodb.core.ReactiveMongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.net.URLDecoder +import java.text.SimpleDateFormat +import java.util.Date + + +@Service +class PostManager( + private val postRepository: PostRepository, + private val reactiveMongoTemplate: ReactiveMongoTemplate +) { + @Autowired + private lateinit var logService: LogService + + @Autowired + private lateinit var bCryptPasswordEncoder: PasswordEncoder + + fun deletePost(postId: String): Mono { + return postRepository.deleteById(postId) + } + + // [수정] 익명 사용자용 목록 조회 (Aggregation 사용) + fun findLatestUniquePaginated(pageable: Pageable) : Mono> { + return postRepository.findLatestUniquePublishedPaginated(pageable) + .collectList() + } + + // [수정] 익명 사용자용 글 개수 (Aggregation 사용) + fun countLatestUnique(): Mono { + return postRepository.countLatestUniquePublished() + .map { it.totalCount } + .switchIfEmpty(Mono.just(0L)) + } + + // [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용) + fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { + return postRepository.findLatestUniqueForWriterPaginated(username, pageable) + .collectList() + } + + // [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용) + fun countLatestUniqueForWriter(username: String): Mono { + return postRepository.countLatestUniqueForWriter(username) + .map { it.totalCount } + .switchIfEmpty(Mono.just(0L)) + } + + // [수정] 익명 사용자용 인기글 + fun getTop5UniquePublishedByViews(): Flux { + return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // [수정] 익명 사용자용 최신글 + fun getRecent5UniquePublished(): Flux { + return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map { + p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // --- [신규 추가] 카테고리/태그 관련 서비스 메소드 --- + fun findAllDistinctCategories(): Flux { + // 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행 + val query = Query.query(Criteria.where("category").ne(null).ne("")) + return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java) + } + + fun findAllDistinctTags(): Flux { + return postRepository.findDistinctTags() + .mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출 + .filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링 + } + // --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 --- + fun findPostsByCategory(category: String, pageable: Pageable): Mono> { + return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList() + } + fun countPostsByCategory(category: String): Mono { + return postRepository.countByCategoryAndPostingIsTrue(category) + } + + fun findPostsByTag(tag: String, pageable: Pageable): Mono> { + // [수정] 한글 및 다국어를 지원하는 정규식으로 변경 + val regex = "(^|,)${Regex.escape(tag)}(,|$)" + return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList() + } + + fun countPostsByTag(tag: String): Mono { + // [수정] 위와 동일하게 정규식 변경 + val regex = "(^|,)${Regex.escape(tag)}(,|$)" + return postRepository.countByTagsRegexAndPostingIsTrue(regex) + } + + // [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드 + fun findRandomGibberish(): Mono { + return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name) + } + + // [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드 + fun findLatestAboutPost(): Mono { + // 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴 + return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name) + .next() // Flux에서 첫 번째 아이템(Mono)을 반환 + } + + // [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드 + fun findAboutPostHistory(): Flux { + return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name) + } + + // [신규] 게시물 차단 + fun blockPost(postId: String): Mono { + return postRepository.findById(postId).flatMap { post -> + post.isBlocked = true + postRepository.save(post) + } + } + + // [신규] 게시물 차단 해제 + fun unblockPost(postId: String): Mono { + return postRepository.findById(postId).flatMap { post -> + post.isBlocked = false + postRepository.save(post) + } + } + + fun findById(id: String): Mono { + return postRepository.findById(id) + } + + + fun findPostsByWriter(writer: String, pageable: Pageable): Flux { + return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable) + .map { post -> + post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + if (post.title.isNullOrBlank()) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") + post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" + } + post + } + } + + + fun getPost(id: String): Mono { + val query = Query.query(Criteria.where("id").`is`(id)) + val update = Update().inc("readCount", 1) + + // 이 메서드는 기본값(returnNew=false)를 사용하여, 증가되기 *전*의 문서를 반환합니다. + // (뷰어 로딩과 동시에 DB 카운트만 1 증가시킴) + return reactiveMongoTemplate.findAndModify(query, update, Post::class.java) + .switchIfEmpty(Mono.error(NoSuchElementException("Post not found with id $id"))) + } + + + /** + * 인증된 사용자를 위한 메서드 (모든 버전 조회, GIBBERISH 제외) + */ + fun findAllVersionsPaginated(pageable :Pageable) : Mono> { + return postRepository.findByPostTypeNotOrderByModifyTimeDesc(PostType.GIBBERISH.name, pageable) + .map { post -> + // 1. 제목을 UTF-8로 디코딩합니다. + post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + + // 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다. + if (post.title.isNullOrBlank()) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") + post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" + } + post // 수정된 post 객체를 반환 + } + .collectList() + } + + /** + * 인증된 사용자가 보는 글의 총 개수 (GIBBERISH 제외) + */ + fun countAllVersions(): Mono { + return postRepository.countByPostTypeNot(PostType.GIBBERISH.name) + } + + /** + * 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 수 있도록 *업데이트된* 문서를 반환합니다. + */ + fun incrementVote(postId: String): Mono { + val query = Query.query(Criteria.where("id").`is`(postId)) + val update = Update().inc("voteCount", 1) + // options().returnNew(true) : 업데이트된 후의 새 문서를 반환하도록 설정 + val options = FindAndModifyOptions.options().returnNew(true) + return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java) + } + + /** + * 싫어요 카운트를 1 증가시키고, *업데이트된* 문서를 반환합니다. + */ + fun incrementUnlike(postId: String): Mono { + val query = Query.query(Criteria.where("id").`is`(postId)) + val update = Update().inc("unlikeCount", 1) + val options = FindAndModifyOptions.options().returnNew(true) + return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java) + } + + + fun getTop10Posts(): Flux { + return postRepository.findTop5ByOrderByReadCountDesc().map { p -> + p.title = URLDecoder.decode(p.title) + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + println(p.title) + p + } + } + + fun getRecent10Posts(): Flux { + return postRepository.findTop5ByOrderByModifyTimeDesc().map { p -> + p.title = URLDecoder.decode(p.title) + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + println(p.title) + p + } + + } + + + /** + * 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다. + */ + fun find8() : Mono> { + val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8 + return this.findLatestUniquePaginated(pageRequest) + } + + fun save(post: Post): Mono { + println("saved user before ${post}") +// user.hashPassword(bCryptPasswordEncoder) + return postRepository.save(post) + .doOnSuccess { savedPost -> + // 저장이 완료되었을 때 실행될 로직 (로그 출력 등) + println("saved post success: ${savedPost.id}") + } + } + + // [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions) + fun getTop5AllVersionsByViews(): Flux { + return postRepository.findTop5ByOrderByReadCountDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // [기존] 로그인 사용자용 최신글 (메서드 이름 명확화) + fun getRecent5AllVersions(): Flux { + return postRepository.findTop5ByOrderByModifyTimeDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/WebBookmarkService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/WebBookmarkService.kt new file mode 100644 index 0000000..a1e3d34 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/WebBookmarkService.kt @@ -0,0 +1,121 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.Visibility +import kr.lunaticbum.back.lun.model.WebBookmark +import kr.lunaticbum.back.lun.repository.WebBookmarkRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.mongodb.core.FindAndModifyOptions +import org.springframework.data.mongodb.core.ReactiveMongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Service +class WebBookmarkService(private val repository: WebBookmarkRepository, + private val reactiveMongoTemplate: ReactiveMongoTemplate + // [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다. +) { + + + + // [이 메소드를 추가하세요] + fun findById(id: String): Mono { + return repository.findById(id) + } + // [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지 + fun findAllDistinctCategories(): Flux { + return repository.findDistinctCategories() + .flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) } + .filter { it.isNotBlank() } + } + + // [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지 + fun findAllDistinctTags(): Flux { + return repository.findDistinctTags() + .flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) } + .filter { it.isNotBlank() } + } + + + fun getBookmarksForUser(userId: String): Flux { + return repository.findByUserIdOrderBySavedAtDesc(userId) + } + + fun saveBookmark(bookmark: WebBookmark): Mono { + // 여기에 중복 저장 방지 로직 등을 추가할 수 있음 + return repository.save(bookmark) + } + + // 필요하다면 삭제, 수정 기능 추가 + fun deleteBookmark(id: String): Mono { + return repository.deleteById(id) + } + + // [수정] getVisibleBookmarks 메소드에 필터링 기능 추가 + fun getVisibleBookmarks( + userDetails: UserDetails?, + pageable: Pageable, + category: String?, // 카테고리 파라미터 추가 + tag: String? // 태그 파라미터 추가 + ): Mono> { + val visibleScopes = when { + userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name) + else -> listOf(Visibility.PUBLIC.name) + } + + // 동적 쿼리 생성 시작 + val query = Query(Criteria.where("visibility").`in`(visibleScopes)) + .with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요. + .with(pageable) + + // 카테고리 조건 추가 + if (!category.isNullOrBlank()) { + query.addCriteria(Criteria.where("category").`is`(category)) + } + + // 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인) + if (!tag.isNullOrBlank()) { + query.addCriteria(Criteria.where("tags").`in`(tag)) + } + + // 데이터 조회 및 카운트 + val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList() + val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java) + + return Mono.zip(bookmarks, totalCount).map { tuple -> + PageImpl(tuple.t1, pageable, tuple.t2) + } + } + + /** + * 북마크의 좋아요 카운트를 1 증가시킵니다. + * @param bookmarkId 대상 북마크의 ID + * @return 업데이트된 WebBookmark 객체 + */ + fun incrementVote(bookmarkId: String): Mono { + val query = Query.query(Criteria.where("id").`is`(bookmarkId)) + val update = Update().inc("voteCount", 1) + val options = FindAndModifyOptions.options().returnNew(true) + return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java) + } + + /** + * 북마크의 싫어요 카운트를 1 증가시킵니다. + * @param bookmarkId 대상 북마크의 ID + * @return 업데이트된 WebBookmark 객체 + */ + fun incrementUnlike(bookmarkId: String): Mono { + val query = Query.query(Criteria.where("id").`is`(bookmarkId)) + val update = Update().inc("unlikeCount", 1) + val options = FindAndModifyOptions.options().returnNew(true) + return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/StringUtils.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/StringUtils.kt index c6fe141..e65f048 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/utils/StringUtils.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/StringUtils.kt @@ -57,27 +57,40 @@ import java.util.Base64 //} fun String.plainText() = String(Base64.getMimeDecoder().decode(this)) -fun String.extractModelData(calback : (Exception?,String)->Unit) { +//fun String.extractModelData(calback : (Exception?,String)->Unit) { +// try { +// val decodedBytes: ByteArray = Base64.getDecoder().decode(this) +// String(decodedBytes).let { resultString -> +// try { +// Gson().fromJson(resultString, RequestModel::class.java).let { model -> +// model.data?.let { jsonString -> +// try { +// println("RequestModel ${jsonString}") +// calback.invoke(null,model.extractData()) +// } catch (e: Exception) { +// calback.invoke(ExtractDataRequestModelException("Exception on extractData with ${Gson().toJson(model)}", e.cause), jsonString) +// } +// } +// } +// } catch (e: Exception) { +// calback.invoke(MakeRequestModelException("Exception on make RequestModel with $resultString", e.cause),this@extractModelData) +// } +// } +// } catch (e: Exception) { +// calback.invoke(Base64DecodeException("Exception on Base64 decode", e.cause),this@extractModelData) +// } +//} +fun String.extractModelData(completion: (Exception?, String) -> Unit) { try { - val decodedBytes: ByteArray = Base64.getDecoder().decode(this) - String(decodedBytes).let { resultString -> - try { - Gson().fromJson(resultString, RequestModel::class.java).let { model -> - model.data?.let { jsonString -> - try { - println("RequestModel ${jsonString}") - calback.invoke(null,model.extractData()) - } catch (e: Exception) { - calback.invoke(ExtractDataRequestModelException("Exception on extractData with ${Gson().toJson(model)}", e.cause), jsonString) - } - } - } - } catch (e: Exception) { - calback.invoke(MakeRequestModelException("Exception on make RequestModel with $resultString", e.cause),this@extractModelData) - } - } + // 1. Base64 디코딩 (클라이언트가 btoa로 보낸 경우) + val decodedBytes = java.util.Base64.getDecoder().decode(this) + val jsonString = String(decodedBytes, Charsets.UTF_8) + + // 2. 만약 페이로드가 { "data": "...", "key": "..." } 구조라면 필요한 부분만 추출 + // 여기서는 단순화를 위해 전체 스트링을 그대로 넘깁니다. + completion(null, jsonString) } catch (e: Exception) { - calback.invoke(Base64DecodeException("Exception on Base64 decode", e.cause),this@extractModelData) + completion(e, "") } } class Base64DecodeException(message: String, cause : Throwable? = null) : Exception(message, cause) diff --git a/src/main/resources/static/css/pages/game.css b/src/main/resources/static/css/pages/game.css index 35e7c60..8ec400b 100644 --- a/src/main/resources/static/css/pages/game.css +++ b/src/main/resources/static/css/pages/game.css @@ -1,70 +1,24 @@ -/* game.css - 게임 공통 테마 및 레이아웃 */ - -:root { - /* Game Specific Colors */ - --color-felt-green: #008000; - --color-felt-border: #004d00; - --color-grid-bg-2048: #b0bec5; - --color-tile-empty: #eceff1; - --color-incorrect-bg: #ffdddd; - --color-incorrect-text: #d8000c; -} - -/* 게임 페이지 전체 래퍼 */ +/* src/main/resources/static/css/pages/game.css */ .game-body-wrapper { - text-align: center; - padding: 20px 10px; /* 모바일 여백 확보 */ + padding: 20px; display: flex; flex-direction: column; align-items: center; - min-height: 60vh; /* 최소 높이 확보 */ } -/* [핵심] 게임 공통 컨테이너 (카드 UI) */ -.game-play-box { - background: var(--bg-element); - padding: clamp(15px, 4vw, 30px); - border-radius: var(--border-radius-main); - box-shadow: var(--shadow-default); - box-sizing: border-box; - border: 1px solid var(--border-color); - - width: 100%; - max-width: 500px; /* 기본 너비 (2048, 스도쿠용) */ - margin: 0 auto 30px auto; - - /* 내부 요소 중앙 정렬 */ - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -/* 넓은 화면이 필요한 게임용 (스파이더, 노노그램) */ -.game-play-box.wide { - max-width: 1200px; -} - -/* 게임 제목 */ -h1 { - font-size: clamp(2.0em, 5vw, 2.5em); - margin: 0 0 1.5em 0; - color: var(--text-main); - word-break: keep-all; -} - -/* 점수판 공통 스타일 */ -.score-board { - display: flex; - gap: 20px; +.game-body-wrapper h1 { margin-bottom: 20px; - font-size: 1.2em; - font-weight: bold; - color: var(--text-main); - background: var(--bg-element-alt); - padding: 10px 20px; - border-radius: 50px; /* 둥근 알약 모양 */ + font-size: 1.8rem; + color: #333; } -.score-board span { - color: var(--color-primary); + +#game-container { + background-color: #ffffff; + border: 1px solid #ddd; + transition: width 0.3s ease; /* 반응형 리사이즈 시 부드럽게 */ +} + +.ads-container { + min-height: 100px; /* 광고 로딩 전 영역 확보 */ + background: #fafafa; } \ No newline at end of file diff --git a/src/main/resources/static/css/pages/game_2048.css b/src/main/resources/static/css/pages/game_2048.css index c090484..8f0794c 100644 --- a/src/main/resources/static/css/pages/game_2048.css +++ b/src/main/resources/static/css/pages/game_2048.css @@ -23,6 +23,7 @@ #game-board { grid-gap: 10px; padding: 10px; } } + .tile { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-weight: bold; border-radius: 3px; diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index db4d512..9489636 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -384,4 +384,34 @@ window.openBookmarkEditPopup = function(btn) { UI.showAlert("알림", "북마크 수정 기능 준비 중"); }; window.openBookmarkCategoryPopup = () => UI.showAlert("알림", "카테고리 기능 준비 중"); -window.openBookmarkTagPopup = () => UI.showAlert("알림", "태그 기능 준비 중"); \ No newline at end of file +window.openBookmarkTagPopup = () => UI.showAlert("알림", "태그 기능 준비 중"); + +async function submitStockConfig() { + const data = { + appKey: document.getElementById('kis_app_key').value, + appSecret: document.getElementById('kis_app_secret').value, + accountNo: document.getElementById('kis_account_no').value + }; + + try { + // CSRF 토큰을 포함한 POST 요청 + const res = await Api.request('/api/stock/config', 'POST', data); + if (res.resultCode === 0) { + UI.showAlert("연결 성공", "세션이 유지되는 동안 API를 사용할 수 있습니다."); + location.href = "/stock/dashboard.bs"; + } else { + UI.showAlert("실패", res.resultMsg); + } + } catch (e) { + UI.showAlert("오류", "서버 통신 중 에러가 발생했습니다."); + } +} + +async function checkKisSession() { + // 세션에 KIS_AUTH가 있는지 확인하는 간단한 API 호출 + const res = await Api.request('/api/stock/session-check'); + if (res.resultCode !== 0) { + UI.showAlert("알림", "API 키 설정이 필요합니다."); + location.href = "/stock/config.bs"; + } +} \ No newline at end of file diff --git a/src/main/resources/static/js/modules/api.js b/src/main/resources/static/js/modules/api.js index fb6ebc0..3b167ef 100644 --- a/src/main/resources/static/js/modules/api.js +++ b/src/main/resources/static/js/modules/api.js @@ -14,65 +14,47 @@ export let Api = { return meta ? meta.getAttribute('content') : ''; }, - /** - * 공통 Fetch Wrapper (GET/POST/PUT/DELETE) - */ async request(url, method = 'GET', body = null, headers = {}) { - const defaultHeaders = { - 'X-CSRF-TOKEN': this.getCsrfToken() - }; + // [수정] URL이 /로 시작하지 않으면 자동으로 붙여줌 + const targetUrl = url.startsWith('/') ? url : '/' + url; - const config = { - method: method, - headers: { ...defaultHeaders, ...headers } - }; + const defaultHeaders = { 'X-CSRF-TOKEN': this.getCsrfToken() }; + const config = { method, headers: { ...defaultHeaders, ...headers } }; if (body) { - // FormData는 Content-Type을 설정하지 않음 (브라우저 자동 설정) - if (body instanceof FormData) { - config.body = body; - } else { + if (body instanceof FormData) config.body = body; + else { config.headers['Content-Type'] = 'application/json'; config.body = JSON.stringify(body); } } try { - const response = await fetch(url, config); - if (!response.ok) { - throw new Error(`HTTP Error: ${response.status}`); + const response = await fetch(targetUrl, config); + if (!response.ok) throw new Error(`HTTP Error: ${response.status}`); + + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return await response.json(); } - // 응답이 없는 경우(204 No Content 등) 대비 - const text = await response.text(); - return text ? JSON.parse(text) : {}; + return await response.text(); } catch (error) { - console.error(`API Request Failed [${method} ${url}]:`, error); + console.error(`API Fail [${method} ${targetUrl}]:`, error); throw error; } }, - /** - * [Legacy 호환] 암호화된 POST 요청 - * (기존 post() 함수 대체 - fetch 사용) - */ async postEncrypted(url, type, dataObj, key) { - // 데이터 암호화 (unformat 로직) + // [수정] 암호화(encrypt)를 호출하지 않고 바로 JSON을 Base64로 인코딩만 합니다. const dataStr = JSON.stringify(dataObj); - const encryptedData = this.encrypt(type, dataStr, key); - const payload = { - data: encryptedData, - key: key, - type: type - }; - - // Base64 인코딩 - const base64Payload = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); + // 백엔드 extractModelData 구조와 맞추기 위해 Base64 인코딩만 수행 + const base64Payload = btoa(unescape(encodeURIComponent(dataStr))); const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'text/plain', + 'Content-Type': 'text/plain', // 기존 규격 유지 'X-CSRF-TOKEN': this.getCsrfToken() }, body: base64Payload @@ -80,19 +62,5 @@ export let Api = { if (!response.ok) throw new Error('Network response was not ok'); return await response.json(); - }, - - // 암호화(난독화) 로직 (기존 unformat 함수) - encrypt(type, data, key) { - let even = [], odd = []; - data.split("").forEach((v, idx) => (idx % 2 === 0 ? even.push(v) : odd.push(v))); - const dividerStr = ["|*-*|", key, "|*-*|"].join(""); - - switch (type) { - case "T0": return [odd.join(""), dividerStr, even.join("")].join(""); - case "T1": return [odd.reverse().join(""), dividerStr, even.join("")].join(""); - case "T2": return [odd.join(""), dividerStr, even.reverse().join("")].join(""); - default: return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join(""); - } } }; \ No newline at end of file diff --git a/src/main/resources/static/js/modules/canvas_utils.js b/src/main/resources/static/js/modules/canvas_utils.js new file mode 100644 index 0000000..7e3a237 --- /dev/null +++ b/src/main/resources/static/js/modules/canvas_utils.js @@ -0,0 +1,164 @@ +/** + * CommonCanvas.js + * - 고해상도(DPR) 대응, 모바일 터치 최적화 + * - 성공 오버레이 & 폭죽(Particles) 효과 통합 + */ +export const CommonCanvas = { + init(canvas, logicalWidth, logicalHeight) { + if (!canvas) return null; + const ctx = canvas.getContext('2d'); + const container = canvas.parentElement; + const dpr = window.devicePixelRatio || 1; + + let particles = []; + let animationId = null; + + // 모바일 스크롤 방지 + canvas.style.touchAction = 'none'; + + // 1. 리사이징 함수 + const resize = () => { + const style = getComputedStyle(container); + const availableWidth = container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + + // 비율 유지 계산 + const scale = availableWidth / logicalWidth; + const displayHeight = logicalHeight * scale; + + canvas.style.width = `${availableWidth}px`; + canvas.style.height = `${displayHeight}px`; + + // 실제 픽셀 해상도 (선명하게) + canvas.width = availableWidth * dpr; + canvas.height = displayHeight * dpr; + + // 컨텍스트 스케일링 + ctx.setTransform(1, 0, 0, 1, 0, 0); // 초기화 + ctx.scale(dpr * scale, dpr * scale); + + return { scale }; + }; + + // 2. 좌표 계산 (마우스/터치 통합) + const getCoords = (e) => { + const rect = canvas.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + + // 현재 스케일 역산 + const scaleX = logicalWidth / rect.width; + const scaleY = logicalHeight / rect.height; + + return { + x: (clientX - rect.left) * scaleX, + y: (clientY - rect.top) * scaleY + }; + }; + + // 3. UI 유틸리티 + const isInside = (pos, rect) => { + return pos.x >= rect.x && pos.x <= rect.x + rect.w && + pos.y >= rect.y && pos.y <= rect.y + rect.h; + }; + + const fillRoundRect = (x, y, w, h, r, color) => { + ctx.save(); + ctx.fillStyle = color; + if (ctx.roundRect) { + ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); + } else { + // 구형 브라우저 호환 + ctx.fillRect(x, y, w, h); + } + ctx.restore(); + }; + + // 4. 폭죽(Particles) 효과 로직 + const createParticles = () => { + for (let i = 0; i < 80; i++) { + particles.push({ + x: logicalWidth / 2, + y: logicalHeight / 2, + vx: (Math.random() - 0.5) * 15, + vy: (Math.random() - 0.5) * 15 - 5, + size: Math.random() * 5 + 3, + color: `hsl(${Math.random() * 360}, 80%, 60%)`, + life: 1.0, + decay: 0.01 + Math.random() * 0.02 + }); + } + }; + + const showSuccessOverlay = (data) => { + const { title = "SUCCESS!", scoreLabel = "SCORE", scoreValue, timeValue } = data; + createParticles(); + + const renderFrame = () => { + // 배경 (약간 투명한 검정) + ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; + ctx.fillRect(0, 0, logicalWidth, logicalHeight); + + // 폭죽 그리기 + particles.forEach((p, i) => { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.2; // 중력 + p.life -= p.decay; + + if (p.life <= 0) particles.splice(i, 1); + + ctx.save(); + ctx.globalAlpha = p.life; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }); + + // 메시지 박스 + const boxW = Math.min(logicalWidth * 0.8, 500); + const boxH = 300; + const boxX = (logicalWidth - boxW) / 2; + const boxY = (logicalHeight - boxH) / 2; + + fillRoundRect(boxX, boxY, boxW, boxH, 20, "#ffffff"); + + // 텍스트 + ctx.fillStyle = "#4CAF50"; + ctx.font = "bold 50px Arial"; + ctx.textAlign = "center"; + ctx.fillText(title, logicalWidth / 2, boxY + 80); + + ctx.fillStyle = "#333"; + ctx.font = "bold 24px Arial"; + let textY = boxY + 150; + + if (timeValue !== undefined) { + const m = String(Math.floor(timeValue / 60)).padStart(2, '0'); + const s = String(timeValue % 60).padStart(2, '0'); + ctx.fillText(`TIME: ${m}:${s}`, logicalWidth / 2, textY); + textY += 40; + } + + if (scoreValue !== undefined) { + ctx.fillText(`${scoreLabel}: ${scoreValue}`, logicalWidth / 2, textY); + textY += 40; + } + + ctx.fillStyle = "#666"; + ctx.font = "16px Arial"; + ctx.fillText("잠시 후 결과가 저장됩니다...", logicalWidth / 2, boxY + 260); + + if (particles.length > 0) { + animationId = requestAnimationFrame(renderFrame); + } + }; + + if (animationId) cancelAnimationFrame(animationId); + renderFrame(); + }; + + return { ctx, resize, getCoords, isInside, fillRoundRect, showSuccessOverlay }; + } +}; \ No newline at end of file diff --git a/src/main/resources/static/js/pages/game_2048_canvas.js b/src/main/resources/static/js/pages/game_2048_canvas.js new file mode 100644 index 0000000..7ca30ab --- /dev/null +++ b/src/main/resources/static/js/pages/game_2048_canvas.js @@ -0,0 +1,168 @@ +import { Game } from '../modules/game.js'; +import { CommonCanvas } from '../modules/canvas_utils.js'; + +document.addEventListener('DOMContentLoaded', () => { + const canvas = document.getElementById('game2048Canvas'); + // 공통 템플릿 사용 시 ID 확인 필요, 없을 시 HTML에서 ID 수정 필요 + // 여기서는 파일명에 맞게 game_2048.js 로 작성하지만, HTML의 canvas id="game2048Canvas" 여야 함. + if (!canvas) return; + + const V = 600, PAD = 15, CELL = (600 - PAD*5)/4; + const common = CommonCanvas.init(canvas, V, V+100); + const { ctx, resize, showSuccessOverlay, fillRoundRect } = common; + + let grid = Array(16).fill(0); + let score = 0, isGameOver = false; + + // 타일 색상 + const colors = { + 0: "#cdc1b4", 2: "#eee4da", 4: "#ede0c8", 8: "#f2b179", 16: "#f59563", + 32: "#f67c5f", 64: "#f65e3b", 128: "#edcf72", 256: "#edcc61", 1024: "#edc53f", 2048: "#edc22e" + }; + + function draw() { + ctx.clearRect(0, 0, V, V+100); + + // 보드 배경 + fillRoundRect(0, 0, V, V, 10, "#bbada0"); + + // 타일 + grid.forEach((v, i) => { + const x = PAD + (i%4)*(CELL+PAD); + const y = PAD + Math.floor(i/4)*(CELL+PAD); + const bgColor = colors[v] || "#3c3a32"; + fillRoundRect(x, y, CELL, CELL, 5, bgColor); + + if (v > 0) { + ctx.fillStyle = (v <= 4) ? "#776e65" : "#f9f6f2"; + ctx.font = `bold ${v < 100 ? 50 : v < 1000 ? 40 : 30}px Arial`; + ctx.textAlign = "center"; + ctx.fillText(v, x+CELL/2, y+CELL/2+15); + } + }); + + // 점수 + ctx.fillStyle = "#333"; + ctx.font = "bold 30px Arial"; + ctx.textAlign = "center"; + ctx.fillText(`SCORE: ${score}`, V/2, V + 60); + } + + function addRandom() { + const avail = grid.map((v, i) => v === 0 ? i : -1).filter(i => i !== -1); + if (avail.length > 0) grid[avail[Math.floor(Math.random()*avail.length)]] = Math.random()<0.9 ? 2 : 4; + } + + function init() { + grid.fill(0); score = 0; isGameOver = false; + addRandom(); addRandom(); + draw(); + } + + // 게임 로직 (이동 및 병합) + function move(dir) { + if (isGameOver) return; + let rotated = [...grid]; // 복사 + + // 방향에 따라 회전하여 '왼쪽으로 이동' 문제로 변환 + // (간소화를 위해 로직 직접 구현) + const getVal = (r, c) => rotated[r*4 + c]; + const setVal = (r, c, v) => rotated[r*4 + c] = v; + + let moved = false; + let scoreAdd = 0; + + // 각 행/열 처리 로직은 복잡하므로, 가장 직관적인 '벡터' 방식 사용 + // 여기서는 기존 DOM 버전의 로직을 Canvas 데이터 구조(1차원 배열)에 맞게 변환 + const vector = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 }[dir]; + if (!vector) return; + + // ... (전통적인 2048 알고리즘 구현 생략 - 너무 길어짐. + // 대신 기존 DOM 버전의 moveRow 로직을 차용하여 구현) + + // 행 단위 처리 (좌우) + if (dir === 'ArrowLeft' || dir === 'ArrowRight') { + for (let r=0; r<4; r++) { + let row = [grid[r*4], grid[r*4+1], grid[r*4+2], grid[r*4+3]]; + if (dir === 'ArrowRight') row.reverse(); + + // 병합 로직 + let filtered = row.filter(v => v); + for(let i=0; i v); // 0 제거 + while(filtered.length < 4) filtered.push(0); + + if (dir === 'ArrowRight') filtered.reverse(); + + for(let c=0; c<4; c++) { + if (grid[r*4+c] !== filtered[c]) moved = true; + grid[r*4+c] = filtered[c]; + } + } + } + // 열 단위 처리 (상하) + else { + for (let c=0; c<4; c++) { + let col = [grid[c], grid[c+4], grid[c+8], grid[c+12]]; + if (dir === 'ArrowDown') col.reverse(); + + let filtered = col.filter(v => v); + for(let i=0; i v); + while(filtered.length < 4) filtered.push(0); + + if (dir === 'ArrowDown') filtered.reverse(); + + for(let r=0; r<4; r++) { + if (grid[r*4+c] !== filtered[r]) moved = true; + grid[r*4+c] = filtered[r]; + } + } + } + + if (moved) { + addRandom(); + draw(); + if (grid.every(v => v !== 0) && !canMove()) { + isGameOver = true; + showSuccessOverlay({ title: "GAME OVER", scoreValue: score }); + // 실패 처리는 모달 없이 오버레이만 띄움 + } else if (grid.includes(2048)) { // 2048 도달 시 승리 + showSuccessOverlay({ title: "2048 CLEAR!", scoreValue: score }); + setTimeout(() => Game.showSuccessModal({ gameType: 'GAME_2048', primaryScore: score }), 2500); + } + } + } + + function canMove() { + for(let i=0; i<16; i++) { + if (i%4 < 3 && grid[i] === grid[i+1]) return true; // 가로 인접 + if (i < 12 && grid[i] === grid[i+4]) return true; // 세로 인접 + } + return false; + } + + window.addEventListener('keydown', e => move(e.key)); + + // 터치 지원 + let tsx, tsy; + canvas.addEventListener('touchstart', e => { tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; }); + canvas.addEventListener('touchend', e => { + let dx = e.changedTouches[0].clientX - tsx; + let dy = e.changedTouches[0].clientY - tsy; + if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx)>30) move(dx>0 ? 'ArrowRight' : 'ArrowLeft'); + else if (Math.abs(dy)>30) move(dy>0 ? 'ArrowDown' : 'ArrowUp'); + }); + + resize(); init(); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/pages/game_nonogram_canvas.js b/src/main/resources/static/js/pages/game_nonogram_canvas.js new file mode 100644 index 0000000..821dd6c --- /dev/null +++ b/src/main/resources/static/js/pages/game_nonogram_canvas.js @@ -0,0 +1,117 @@ +import { UI } from '../modules/ui.js'; +import { Api } from '../modules/api.js'; +import { CommonCanvas } from '../modules/canvas_utils.js'; + +document.addEventListener('DOMContentLoaded', () => { + const canvas = document.getElementById('nonogramCanvas'); + if (!canvas || !window.puzzleData) return; + const p = window.puzzleData; + const V = 800, CELL = 40, H = 220; + const common = CommonCanvas.init(canvas, V, V+100); + const { ctx, resize, getCoords, fillRoundRect, showSuccessOverlay } = common; + + let board = Array.from({length: p.rowClues.length}, () => Array(p.colClues.length).fill(0)); + let lives = 5, isWin = false, playMode = 'fill'; + + // 배경 이미지 로드 + const revealImg = new Image(); + if (p.originalImageFile) revealImg.src = '/uploads/' + p.originalImageFile; + revealImg.onload = () => draw(); + + const ui = { + fill: { x: 20, y: V+20, w: 100, h: 50, label: "■ 채우기" }, + mark: { x: 140, y: V+20, w: 100, h: 50, label: "X 표시" } + }; + + function draw() { + ctx.clearRect(0,0,V,V+100); ctx.fillStyle = "#fff"; ctx.fillRect(0,0,V,V+100); + + // 배경 이미지 (투명도 조절) + if (revealImg.complete && revealImg.naturalWidth) { + ctx.globalAlpha = isWin ? 1.0 : 0.15; + ctx.drawImage(revealImg, H, H, V-H, V-H); + ctx.globalAlpha = 1.0; + } + + // 힌트 및 그리드 + drawHints(); + drawBoard(); + + // 하단 UI + fillRoundRect(ui.fill.x, ui.fill.y, ui.fill.w, ui.fill.h, 10, playMode==='fill'?"#4a90e2":"#eee"); + fillRoundRect(ui.mark.x, ui.mark.y, ui.mark.w, ui.mark.h, 10, playMode==='mark'?"#4a90e2":"#eee"); + ctx.fillStyle = "#333"; ctx.font = "18px Arial"; ctx.textAlign = "center"; + ctx.fillText(ui.fill.label, ui.fill.x+50, ui.fill.y+32); + ctx.fillText(ui.mark.label, ui.mark.x+50, ui.mark.y+32); + + ctx.textAlign = "right"; ctx.fillText(`LIFE: ${lives}`, V-30, V+50); + } + + function drawHints() { + ctx.fillStyle = "#333"; ctx.font = "bold 14px monospace"; ctx.textAlign = "right"; + p.rowClues.forEach((hints, r) => { + hints.forEach((n, i) => ctx.fillText(n, H-10 - i*20, H + r*CELL + 25)); + }); + ctx.textAlign = "center"; + p.colClues.forEach((hints, c) => { + hints.forEach((n, i) => ctx.fillText(n, H + c*CELL + 20, H - 10 - i*20)); + }); + } + + function drawBoard() { + board.forEach((row, r) => row.forEach((val, c) => { + const x = H + c*CELL, y = H + r*CELL; + ctx.strokeStyle = "#bbb"; ctx.strokeRect(x, y, CELL, CELL); + if (val === 1) { ctx.fillStyle = "#333"; ctx.fillRect(x+2, y+2, CELL-4, CELL-4); } + else if (val === 2) { + ctx.strokeStyle = "red"; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x+5,y+5); ctx.lineTo(x+35,y+35); ctx.moveTo(x+35,y+5); ctx.lineTo(x+5,y+35); ctx.stroke(); + } + })); + } + + canvas.addEventListener('mousedown', (e) => { + if (isWin) return; + const pos = getCoords(e); + + // UI 클릭 + if (common.isInside(pos, ui.fill)) { playMode = 'fill'; draw(); return; } + if (common.isInside(pos, ui.mark)) { playMode = 'mark'; draw(); return; } + + // 보드 클릭 (우클릭 마크 지원) + const c = Math.floor((pos.x - H)/CELL), r = Math.floor((pos.y - H)/CELL); + if (r >= 0 && c >= 0) { + const mode = (e.button === 2) ? 'mark' : playMode; + processMove(r, c, mode); + } + draw(); + }); + + // 우클릭 메뉴 방지 + canvas.addEventListener('contextmenu', e => e.preventDefault()); + + function processMove(r, c, mode) { + if (board[r][c] !== 0) return; + if (mode === 'fill') { + if (p.solutionGrid[r][c] === 1) { + board[r][c] = 1; + if (checkClear()) { + isWin = true; + showSuccessOverlay({ title: "NONOGRAM CLEAR!", scoreValue: lives }); + setTimeout(() => Api.request('/api/puzzle/clear', 'POST', { puzzleId: p.id }), 2500); + } + } else { + board[r][c] = 2; lives--; + if(lives <= 0) location.reload(); + } + } else { + board[r][c] = 2; + } + } + + function checkClear() { + return p.solutionGrid.every((row, r) => row.every((val, c) => (val === 1 ? board[r][c] === 1 : true))); + } + + resize(); draw(); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/pages/game_spider.js b/src/main/resources/static/js/pages/game_spider.js index fa5a90f..5eb7278 100644 --- a/src/main/resources/static/js/pages/game_spider.js +++ b/src/main/resources/static/js/pages/game_spider.js @@ -1,610 +1,257 @@ import { Api } from '../modules/api.js'; import { Game } from '../modules/game.js'; +import { CommonCanvas } from '../modules/canvas_utils.js'; import { UI } from '../modules/ui.js'; document.addEventListener('DOMContentLoaded', () => { - // 1. 상수 및 변수 선언 - const canvas = document.getElementById('gameCanvas'); - const ctx = canvas.getContext('2d'); - const SAVED_GAME_ID_KEY = 'spider_saved_game_id'; + const canvas = document.getElementById('spiderCanvas'); + if (!canvas) return; + // 1000x1000 논리 크기 + const V = 1000, CARD_W = 80, CARD_H = 112, GAP_X = 90, OVER_Y = 30; + const common = CommonCanvas.init(canvas, V, V); + const { ctx, resize, getCoords, isInside, showSuccessOverlay, fillRoundRect } = common; + + let currentGame = null, draggedCards = [], dragOff = {x:0, y:0}; let isProcessing = false; - const UI_ELEMENTS = {}; - const CARD_WIDTH_RATIO = 1 / 11.5, CARD_HEIGHT_RATIO = 1.4, CARD_GAP_X_RATIO = 0.15, CARD_OVERLAP_Y_RATIO = 0.3; - const CARD_RANK_LEFT_PADDING = 0.1, CARD_RANK_TOP_PADDING = 0.1, CARD_SYMBOL_TOP_PADDING = 0.25, CARD_SYMBOL_BOTTOM_PADDING = 0.15; - const FOUNDATION_CARD_SPACING = 0.2, FOUNDATION_WIDTH_RATIO = 0.45; + const cardBack = new Image(); cardBack.src = '/css/images/card-back.png'; - let currentGame = null; - let isGameCompleted = false; - let gameStartTime = 0, completionTimeSeconds = 0; - const currentGameType = 'SPIDER'; - let currentContextId = ''; - - let cardWidth = 0, cardHeight = 0, cardGapX = 0, cardOverlapY = 0, totalTableauWidth = 0, tableauStartX = 0; - let isDragging = false, draggedCards = [], dragOffsetX = 0, dragOffsetY = 0; - let completedStackCards = [], isAnimatingCompletion = false; - - const BOTTOM_ROW_Y_RATIO = 0.9; - let dpr = 1; - const MAX_UNDO_COUNT = 5; - - const cardBackImage = new Image(); - cardBackImage.src = '/css/images/card-back.png'; // 경로 확인 - let assetsLoaded = false; - cardBackImage.onload = () => { assetsLoaded = true; resizeCanvas(); }; - - const cardDistributionOptions = { - '1': [{ value: '4,3', text: '쉬움' }, { value: '5,4', text: '보통' }, { value: '6,5', text: '어려움' }], - '2': [{ value: '5,4', text: '쉬움' }, { value: '6,5', text: '보통' }, { value: '7,6', text: '어려움' }], - '4': [{ value: '6,5', text: '쉬움' }, { value: '7,6', text: '보통' }, { value: '8,7', text: '어려움' }] + const ui = { + start: { x: 400, y: 480, w: 200, h: 60, label: "새 게임 시작" }, + stock: { x: 880, y: 850, w: CARD_W, h: CARD_H }, + undo: { x: 50, y: 900, w: 120, h: 50, label: "실행 취소" } }; - let selectedSuit = 1; - let selectedCardCount = '4,3'; - - // 2. 렌더링 함수들 - function getCssVar(varName) { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); } - - function resizeCanvas() { - // [수정] 윈도우가 아닌 '부모 컨테이너'를 기준으로 크기 계산 - const container = document.getElementById('game-container'); - if (!container) return; - - // 컨테이너의 내부 너비 (패딩 제외) - const style = getComputedStyle(container); - const availableWidth = container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); - - // 높이는 화면 높이의 70% 정도 혹은 너비와 1:1 비율 중 작은 값 선택 (모바일/PC 대응) - const availableHeight = window.innerHeight * 0.75; - - const size = Math.min(availableWidth, availableHeight); - - canvas.style.width = `${size}px`; - canvas.style.height = `${size}px`; - - dpr = window.devicePixelRatio || 1; - canvas.width = size * dpr; - canvas.height = size * dpr; - ctx.scale(dpr, dpr); - - const logicalWidth = size, logicalHeight = size; - cardWidth = logicalWidth * CARD_WIDTH_RATIO; cardHeight = cardWidth * CARD_HEIGHT_RATIO; - cardGapX = cardWidth * CARD_GAP_X_RATIO; cardOverlapY = cardHeight * CARD_OVERLAP_Y_RATIO; - totalTableauWidth = cardWidth * 10 + cardGapX * 9; tableauStartX = (logicalWidth - totalTableauWidth) / 2; - - const buttonWidth = logicalWidth * 0.2, buttonHeight = logicalHeight * 0.05, buttonGap = 10; - const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2; - const startY = logicalHeight * 0.05; - - UI_ELEMENTS.suitSelect = { x: startX, y: startY, width: buttonWidth, height: buttonHeight }; - UI_ELEMENTS.cardCountSelect = { x: startX + buttonWidth + buttonGap, y: startY, width: buttonWidth, height: buttonHeight }; - UI_ELEMENTS.startButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY, width: buttonWidth, height: buttonHeight }; - UI_ELEMENTS.loadButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY + buttonHeight + buttonGap, width: buttonWidth, height: buttonHeight }; - - const bottomY = logicalHeight * BOTTOM_ROW_Y_RATIO; - const itemSpacing = 20; - const foundationX = logicalWidth * 0.05; - const foundationAreaWidth = logicalWidth * FOUNDATION_WIDTH_RATIO; - UI_ELEMENTS.foundationArea = { x: foundationX, y: bottomY, width: foundationAreaWidth, height: cardHeight }; - - const undoButtonWidth = cardWidth * 0.8, undoButtonHeight = cardHeight * 0.5; - const undoCountDisplayWidth = cardWidth * 0.5; - const saveButtonWidth = cardWidth * 0.8; - const bottomControlsTotalWidth = undoButtonWidth + undoCountDisplayWidth + saveButtonWidth + (itemSpacing * 2); - const undoButtonX = logicalWidth * 0.5 - bottomControlsTotalWidth / 2; - - UI_ELEMENTS.undoButton = { x: undoButtonX, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoButtonWidth, height: undoButtonHeight }; - UI_ELEMENTS.undoCountDisplay = { x: undoButtonX + undoButtonWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoCountDisplayWidth, height: undoButtonHeight }; - UI_ELEMENTS.saveButton = { x: UI_ELEMENTS.undoCountDisplay.x + undoCountDisplayWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: saveButtonWidth, height: undoButtonHeight }; - - const stockX = logicalWidth * 0.95 - cardWidth; - UI_ELEMENTS.stockArea = { x: stockX, y: bottomY, width: cardWidth, height: cardHeight }; - UI_ELEMENTS.restartButton = { x: logicalWidth / 2 - buttonWidth / 2, y: logicalHeight / 2 + 50, width: buttonWidth, height: buttonHeight }; - } - - window.addEventListener('resize', resizeCanvas); function draw() { - if (!assetsLoaded) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - if (currentGame) drawGame(currentGame); - drawUI(); - if (isProcessing) { - const logicalWidth = canvas.width / dpr, logicalHeight = canvas.height / dpr; - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(0, 0, logicalWidth, logicalHeight); - ctx.fillStyle = '#ffffff'; ctx.font = '24px Arial'; ctx.textAlign = 'center'; - ctx.fillText('처리 중...', logicalWidth / 2, logicalHeight / 2); - } + ctx.clearRect(0, 0, V, V); + ctx.fillStyle = "#006633"; ctx.fillRect(0, 0, V, V); // 펠트색 + if (!currentGame) drawMenu(); else drawGame(); } - function drawUI() { - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - if (!currentGame) { - const { suitSelect, cardCountSelect, startButton, loadButton } = UI_ELEMENTS; - - // Draw Suit Select - ctx.fillStyle = '#f0f0f0'; ctx.fillRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); - ctx.strokeStyle = '#333'; ctx.strokeRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); - ctx.fillStyle = '#000'; ctx.font = '16px Arial'; - ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2); - - // Draw Card Count Select - ctx.fillStyle = '#f0f0f0'; ctx.fillRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); - ctx.strokeRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); - ctx.fillStyle = '#000'; - const countText = cardDistributionOptions[selectedSuit.toString()].find(opt => opt.value === selectedCardCount)?.text || selectedCardCount; - ctx.fillText(`카드: ${countText}`, cardCountSelect.x + cardCountSelect.width / 2, cardCountSelect.y + cardCountSelect.height / 2); - - // Draw Start Button - ctx.fillStyle = getCssVar('--color-primary') || '#4CAF50'; - ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height); - ctx.strokeRect(startButton.x, startButton.y, startButton.width, startButton.height); - ctx.fillStyle = '#fff'; ctx.fillText('새 게임 시작', startButton.x + startButton.width / 2, startButton.y + startButton.height / 2); - - // Draw Load Button - if (localStorage.getItem(SAVED_GAME_ID_KEY)) { - ctx.fillStyle = '#2196F3'; - ctx.fillRect(loadButton.x, loadButton.y, loadButton.width, loadButton.height); - ctx.strokeRect(loadButton.x, loadButton.y, loadButton.width, loadButton.height); - ctx.fillStyle = '#fff'; ctx.fillText('이어하기', loadButton.x + loadButton.width / 2, loadButton.y + loadButton.height / 2); - } - } else { - const { undoButton, undoCountDisplay, saveButton } = UI_ELEMENTS; - const isUndoPossible = currentGame.undoHistory.length > 0; - const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible; - const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT; - - if (isUndoEnabled) { - ctx.fillStyle = '#ff9800'; - ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.strokeStyle = '#333'; ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; - ctx.fillText('실행 취소', undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); - ctx.fillText(`${MAX_UNDO_COUNT - currentGame.undoCount}`, undoCountDisplay.x + undoCountDisplay.width / 2, undoCountDisplay.y + undoCountDisplay.height / 2); - } else if (isSurrender) { - ctx.fillStyle = '#f44336'; - ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.strokeStyle = '#333'; ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('게임 포기', undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); - } - - ctx.fillStyle = '#007bff'; - ctx.fillRect(saveButton.x, saveButton.y, saveButton.width, saveButton.height); - ctx.strokeStyle = '#333'; ctx.strokeRect(saveButton.x, saveButton.y, saveButton.width, saveButton.height); - ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('게임 저장', saveButton.x + saveButton.width / 2, saveButton.y + saveButton.height / 2); - } + function drawMenu() { + ctx.fillStyle = "#fff"; ctx.font = "bold 60px Arial"; ctx.textAlign = "center"; + ctx.fillText("SPIDER SOLITAIRE", V/2, 250); + fillRoundRect(ui.start.x, ui.start.y, ui.start.w, ui.start.h, 10, "#4CAF50"); + ctx.fillStyle = "#fff"; ctx.font = "bold 24px Arial"; + ctx.fillText(ui.start.label, ui.start.x + 100, ui.start.y + 38); } - function drawGame(game) { - drawBackground(); - drawTableau(game.tableau); - drawStockAndFoundation(game.stock, game.foundation); - drawDraggedCards(draggedCards); - drawCompletionAnimation(); - } - - function drawBackground() { - ctx.fillStyle = getCssVar('--color-felt-green') || '#008000'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - - function drawTableau(tableau) { - const startY = cardHeight * 0.5; - const draggingCards = isDragging ? new Set(draggedCards) : null; - tableau.forEach((stack, stackIndex) => { - stack.forEach((card, cardIndex) => { - if (draggingCards && draggingCards.has(card)) return; - const x = tableauStartX + stackIndex * (cardWidth + cardGapX); - const y = startY + cardIndex * cardOverlapY; - card.touchHeight = (cardIndex === stack.length - 1) ? cardHeight : cardOverlapY; - drawSingleCard(card, x, y); + function drawGame() { + // Tableau (테이블 카드) + currentGame.tableau.forEach((stack, sIdx) => { + stack.forEach((card, cIdx) => { + if (draggedCards.includes(card)) return; // 드래그 중인 카드는 나중에 + const x = 50 + sIdx*GAP_X, y = 120 + cIdx*OVER_Y; + card.currentX = x; card.currentY = y; // 좌표 저장 + drawCard(card, x, y); }); }); - } - function drawDraggedCards(cards) { - if (!isDragging || !Array.isArray(cards) || cards.length === 0) return; - cards.forEach((card, index) => { - const x = cards[0].x, y = cards[0].y + index * cardOverlapY; - drawSingleCard(card, x, y); + // Stock (덱) + if (currentGame.stock.length > 0) ctx.drawImage(cardBack, ui.stock.x, ui.stock.y, CARD_W, CARD_H); + + // Foundation (완성된 세트) + currentGame.foundation.forEach((set, i) => { + drawCard(set[set.length-1], 20 + i*35, 850); }); - } - function drawCompletionAnimation() { - if (isAnimatingCompletion) { - const now = Date.now(); - completedStackCards = completedStackCards.filter(card => { - if (now < card.animEndTime) { - const progress = (now - (card.animEndTime - 500)) / 500; - const currentX = card.animStartX + (card.animTargetX - card.animStartX) * progress; - const currentY = card.animStartY + (card.animTargetY - card.animStartY) * progress; - drawSingleCard(card, currentX, currentY); - return true; - } - return false; - }); - if (completedStackCards.length === 0) isAnimatingCompletion = false; + // UI 버튼 + fillRoundRect(ui.undo.x, ui.undo.y, ui.undo.w, ui.undo.h, 5, "#ff9800"); + ctx.fillStyle = "#fff"; ctx.font = "16px Arial"; ctx.textAlign = "center"; + ctx.fillText(ui.undo.label, ui.undo.x + 60, ui.undo.y + 30); + + // 드래그 중인 카드 (최상단) + if (draggedCards.length > 0) { + draggedCards.forEach((c, i) => drawCard(c, c.drawX, c.drawY + i*OVER_Y)); } } - function drawSingleCard(card, x, y) { - card.x = x; card.y = y; card.width = cardWidth; card.height = cardHeight; - if (card.isFaceUp) { - ctx.fillStyle = '#ffffff'; ctx.fillRect(x, y, cardWidth, cardHeight); - ctx.strokeStyle = '#333333'; ctx.strokeRect(x, y, cardWidth, cardHeight); - const isRed = (card.suit === 'heart' || card.suit === 'diamond'); - ctx.fillStyle = isRed ? '#ff0000' : '#000000'; - ctx.font = `${cardWidth * 0.25}px Arial`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - ctx.fillText(getRankText(card.rank), x + cardWidth * CARD_RANK_LEFT_PADDING, y + cardHeight * CARD_RANK_TOP_PADDING); - drawSuitSymbols(card, x, y); - } else { - ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight); - } + function drawCard(card, x, y) { + if (!card.isFaceUp) { ctx.drawImage(cardBack, x, y, CARD_W, CARD_H); return; } + fillRoundRect(x, y, CARD_W, CARD_H, 5, "#fff"); + ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.strokeRect(x, y, CARD_W, CARD_H); + + const isRed = (card.suit === 'heart' || card.suit === 'diamond'); + ctx.fillStyle = isRed ? "#d32f2f" : "#000"; + ctx.font = "bold 18px Arial"; ctx.textAlign = "left"; + ctx.fillText(getRankStr(card.rank), x+6, y+22); + + ctx.font = "24px Arial"; ctx.textAlign = "center"; + ctx.fillText(getSuitStr(card.suit), x+CARD_W/2, y+CARD_H/2+5); } - function drawSuitSymbols(card, x, y) { - const symbol = getSuitSymbol(card.suit); - // (심볼 그리기 로직은 너무 길어서 간략화함 - 기존 로직 유지) - ctx.font = `${cardWidth * 0.6}px Arial`; - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(symbol, x + cardWidth / 2, y + cardHeight / 2); + function getRankStr(r) { return r==1?'A':r==11?'J':r==12?'Q':r==13?'K':r; } + function getSuitStr(s) { return {spade:'♠',heart:'♥',club:'♣',diamond:'♦'}[s]||''; } + + // --- 게임 로직 --- + async function start() { + isProcessing = true; + try { + // 필수 파라미터 포함 요청 + currentGame = await Api.request('/puzzle/spider/new?numSuits=1&numCards=4,3'); + if(!currentGame.foundation) currentGame.foundation = []; + if(!currentGame.moves) currentGame.moves = 0; + draw(); + } catch(e) { console.error(e); } + isProcessing = false; } - function drawStockAndFoundation(stock, foundation) { - const stockArea = UI_ELEMENTS.stockArea; - const foundationArea = UI_ELEMENTS.foundationArea; - ctx.strokeStyle = getCssVar('--color-felt-border') || '#004d00'; - ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; - ctx.fillRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); - ctx.strokeRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); - foundation.forEach((stack, index) => { - const foundationX = foundationArea.x + index * (cardWidth * FOUNDATION_CARD_SPACING); - if (stack.length > 0) drawSingleCard(stack[stack.length - 1], foundationX, UI_ELEMENTS.foundationArea.y); - }); - if (stock.length > 0) { - ctx.drawImage(cardBackImage, stockArea.x, stockArea.y, cardWidth, cardHeight); - ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight); - const remainingDeals = Math.floor(stock.length / 10); - ctx.fillStyle = '#fff'; ctx.font = '20px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(`${remainingDeals}`, stockArea.x + cardWidth / 2, stockArea.y + cardHeight / 2); - } else { - ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight); - } - } - - // 3. 이벤트 핸들러 - canvas.addEventListener('mousedown', handlePointerDown); - canvas.addEventListener('mousemove', handlePointerMove); - canvas.addEventListener('mouseup', handlePointerUp); - canvas.addEventListener('dblclick', handleDoubleClick); - canvas.addEventListener('touchstart', handlePointerDown); - canvas.addEventListener('touchmove', e => { e.preventDefault(); handlePointerMove(e); }); - canvas.addEventListener('touchend', handlePointerUp); - - function getCanvasCoordinates(event) { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height; - let clientX, clientY; - if (event.touches && event.touches.length > 0) { clientX = event.touches[0].clientX; clientY = event.touches[0].clientY; } - else if (event.changedTouches && event.changedTouches.length > 0) { clientX = event.changedTouches[0].clientX; clientY = event.changedTouches[0].clientY; } - else { clientX = event.clientX; clientY = event.clientY; } - if (typeof clientX === 'undefined') return null; - return { x: (clientX - rect.left) * scaleX / dpr, y: (clientY - rect.top) * scaleY / dpr }; - } - - function findElementAt(x, y) { - if (isGameCompleted) { - if (isInside(x, y, UI_ELEMENTS.restartButton)) return { type: 'ui', name: 'submitButton' }; // 이름은 그대로 둠 - } - if (currentGame) { - if (isInside(x, y, UI_ELEMENTS.stockArea)) return { type: 'stock' }; - if (isInside(x, y, UI_ELEMENTS.undoButton)) return { type: 'ui', name: 'undoButton' }; - if (isInside(x, y, UI_ELEMENTS.saveButton)) return { type: 'ui', name: 'saveButton' }; - } - if (!currentGame) { - if (isInside(x, y, UI_ELEMENTS.suitSelect)) return { type: 'ui', name: 'suitSelect' }; - if (isInside(x, y, UI_ELEMENTS.cardCountSelect)) return { type: 'ui', name: 'cardCountSelect' }; - if (isInside(x, y, UI_ELEMENTS.startButton)) return { type: 'ui', name: 'startButton' }; - if (isInside(x, y, UI_ELEMENTS.loadButton) && localStorage.getItem(SAVED_GAME_ID_KEY)) return { type: 'ui', name: 'loadButton' }; - } - if (currentGame) { - for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { - const stackCards = currentGame.tableau[stackIndex]; - for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { - const card = stackCards[cardIndex]; - if (!card.isFaceUp) continue; - if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { - return { type: 'card', card, stackIndex, cardIndex }; - } - } - } - } - return null; - } - - function isInside(x, y, rect) { - return rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; - } - - // 4. 게임 로직 - async function handlePointerDown(event) { - if (isProcessing || isAnimatingCompletion) return; - if (event.type.startsWith('touch')) event.preventDefault(); - const coords = getCanvasCoordinates(event); - const element = findElementAt(coords.x, coords.y); - if (!element) return; - - if (element.type === 'ui') { - switch (element.name) { - case 'startButton': startNewGame(false); break; - case 'loadButton': startNewGame(true); break; - case 'saveButton': saveGameToServer(); break; - case 'undoButton': await handleUndo(); break; // await 추가 - case 'submitButton': startNewGame(false); break; // 완료 후 클릭 시 새 게임 - case 'suitSelect': - selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1; - selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value; - break; - case 'cardCountSelect': - const opts = cardDistributionOptions[selectedSuit.toString()]; - const curIdx = opts.findIndex(o => o.value === selectedCardCount); - selectedCardCount = opts[(curIdx + 1) % opts.length].value; - break; - } - } else if (element.type === 'card' && !isGameCompleted) { - const { card, stackIndex, cardIndex } = element; - const movableStack = getCardStackForMove(card, stackIndex, cardIndex); - if (movableStack && movableStack.length > 0) { - draggedCards = movableStack; - draggedCards.sourceStackIndex = stackIndex; - dragOffsetX = coords.x - card.x; dragOffsetY = coords.y - card.y; - } - } else if (element.type === 'stock') { - dealFromStock(); - } - } - - function handlePointerMove(event) { - if (!isDragging && draggedCards.length > 0) isDragging = true; - if (isDragging) { - event.preventDefault(); - const coords = getCanvasCoordinates(event); - draggedCards[0].x = coords.x - dragOffsetX; - draggedCards[0].y = coords.y - dragOffsetY; - } - } - - function handlePointerUp(event) { - if (!isDragging) { draggedCards = []; return; } - const coords = getCanvasCoordinates(event); - if (!coords) { isDragging = false; draggedCards = []; return; } - const dropTargetStackId = findStackAt(coords.x, coords.y); - const sourceStackIndex = draggedCards.sourceStackIndex; - - if (dropTargetStackId) { - const destIndex = parseInt(dropTargetStackId.split('-')[1]) - 1; - if (isValidMove(draggedCards, destIndex)) { - addUndoState(); - moveCardLocally(draggedCards, sourceStackIndex, destIndex); - checkCompletedStacks(); - } - } - isDragging = false; draggedCards = []; - } - - function handleDoubleClick(event) { - if (isProcessing || isGameCompleted) return; - const coords = getCanvasCoordinates(event); - const clicked = findCardAt(coords.x, coords.y); - if (clicked) { - const movable = getCardStackForMove(clicked.card, clicked.stackIndex, clicked.cardIndex); - if (movable) { - const destId = getBestMoveForStack(movable); - if (destId) { - addUndoState(); - moveCardLocally(movable, clicked.stackIndex, parseInt(destId.split('-')[1])-1); - checkCompletedStacks(); - } - } - } - } - - async function handleUndo() { - if (currentGame.undoCount >= MAX_UNDO_COUNT || currentGame.undoHistory.length === 0) { - if (currentGame.undoCount >= MAX_UNDO_COUNT) { - if (await UI.showConfirm("확인", '실행 취소 횟수를 모두 사용했습니다. 게임을 포기하시겠습니까?')) { - currentGame = null; - } - } - return; - } - const prevState = currentGame.undoHistory.pop(); - currentGame.tableau = prevState.tableau; - currentGame.stock = prevState.stock; - currentGame.foundation = prevState.foundation; - currentGame.moves = prevState.moves; - currentGame.undoCount++; - } - - // ... (dealFromStock, addUndoState, moveCardLocally, isValidMove, getCardStackForMove, findStackAt, findCardAt, getRankText, getSuitSymbol, getBestMoveForStack 함수들은 기존 로직과 동일하므로 생략하지 않고 그대로 사용) ... - // (분량 관계상 핵심 부분만 작성합니다. 실제 파일에는 기존 spider.html의 해당 함수들을 그대로 복사해 넣으세요.) - function dealFromStock() { - if (currentGame.stock.length === 0 || isGameCompleted) return; - addUndoState(); - const cardsToDeal = currentGame.stock.splice(0, 10); - cardsToDeal.forEach((card, index) => { card.isFaceUp = true; currentGame.tableau[index].push(card); }); + // 카드 딜링 + function deal() { + if (currentGame.stock.length === 0) return; + saveUndoState(); + const dealCards = currentGame.stock.splice(0, 10); + dealCards.forEach((c, i) => { c.isFaceUp = true; currentGame.tableau[i].push(c); }); currentGame.moves++; - checkCompletedStacks(); + checkFoundation(); + draw(); } - function addUndoState() { - const stateToSave = { - tableau: JSON.parse(JSON.stringify(currentGame.tableau)), - stock: JSON.parse(JSON.stringify(currentGame.stock)), - foundation: JSON.parse(JSON.stringify(currentGame.foundation)), - moves: currentGame.moves - }; - currentGame.undoHistory.push(stateToSave); - if(currentGame.undoHistory.length > 10) currentGame.undoHistory.shift(); - } - function moveCardLocally(cards, fromIndex, toIndex) { - const sourceStack = currentGame.tableau[fromIndex]; - sourceStack.splice(sourceStack.length - cards.length, cards.length); - currentGame.tableau[toIndex].push(...cards); - if (sourceStack.length > 0) sourceStack[sourceStack.length-1].isFaceUp = true; - currentGame.moves++; - } - function isValidMove(cardsToMove, destIndex) { - if (cardsToMove.length === 0) return false; - const firstCard = cardsToMove[0]; - const destStack = currentGame.tableau[destIndex]; - if (destStack.length === 0) return true; - const destTopCard = destStack[destStack.length - 1]; - return firstCard.rank === destTopCard.rank - 1; - } - function getCardStackForMove(card, stackIndex, cardIndex) { - const stack = currentGame.tableau[stackIndex]; - if (cardIndex === -1 || !card.isFaceUp) return null; - const movableStack = []; - for (let i = cardIndex; i < stack.length; i++) { - if (stack[i].isFaceUp) movableStack.push(stack[i]); else break; - } - if (movableStack.length === 0) return null; - for (let i = 0; i < movableStack.length - 1; i++) { - if (movableStack[i].rank !== movableStack[i + 1].rank + 1 || movableStack[i].suit !== movableStack[i + 1].suit) return null; - } - return movableStack; - } - function findStackAt(x, y) { - const startY = cardHeight * 0.5; - for (let i = 0; i < 10; i++) { - const stackX = tableauStartX + i * (cardWidth + cardGapX); - const stackCards = currentGame.tableau[i]; - if (stackCards.length === 0) { - if (x >= stackX && x <= stackX + cardWidth && y >= startY) return `tableau-${i + 1}`; - } else { - const lastCardIndex = stackCards.length - 1; - const lastCardY = startY + lastCardIndex * cardOverlapY; - if (x >= stackX && x <= stackX + cardWidth && y >= lastCardY) return `tableau-${i + 1}`; - } - } - return null; - } - function findCardAt(x, y) { - if (!currentGame) return null; - for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { - const stackCards = currentGame.tableau[stackIndex]; - for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { - const card = stackCards[cardIndex]; + + // 드래그 시작 판정 + function findDrag(p) { + // 역순 탐색 (위쪽 카드부터) + for (let sIdx=9; sIdx>=0; sIdx--) { + const stack = currentGame.tableau[sIdx]; + for (let cIdx=stack.length-1; cIdx>=0; cIdx--) { + const card = stack[cIdx]; if (!card.isFaceUp) continue; - if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) return { card, stackIndex, cardIndex }; + // 카드 영역 확인 + if (p.x >= card.currentX && p.x <= card.currentX+CARD_W && + p.y >= card.currentY && p.y <= card.currentY+CARD_H) { // 하단 겹침 고려 단순화 + + // 이동 가능 여부 체크 + const moving = getMovableStack(stack, cIdx); + if (moving) { + draggedCards = moving; + draggedCards.sIdx = sIdx; + dragOff.x = p.x - card.currentX; + dragOff.y = p.y - card.currentY; + moving.forEach(c => { c.drawX = c.currentX; c.drawY = c.currentY; }); + } + return; + } } } - return null; } - function getRankText(rank) { - if (rank === 1) return 'A'; if (rank === 11) return 'J'; if (rank === 12) return 'Q'; if (rank === 13) return 'K'; return String(rank); + + // 규칙: 같은 무늬, 연속된 숫자만 묶음 이동 가능 + function getMovableStack(stack, cIdx) { + const sub = stack.slice(cIdx); + for(let i=0; i 0) srcStack[srcStack.length-1].isFaceUp = true; + + destStack.push(...draggedCards); + currentGame.moves++; + checkFoundation(); } } - return null; + draggedCards = []; } - function checkCompletedStacks() { - for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) { - const stack = currentGame.tableau[stackIndex]; - if (stack.length < 13) continue; - const last13Cards = stack.slice(stack.length - 13); - let isCompleted = true; - for (let i = 0; i < 12; i++) { - if (last13Cards[i].rank !== last13Cards[i+1].rank + 1 || last13Cards[i].suit !== last13Cards[i+1].suit) { isCompleted = false; break; } + // 세트 완성 체크 (K...A) + function checkFoundation() { + currentGame.tableau.forEach(stack => { + if (stack.length < 13) return; + // 끝에서 13장 검사 + const suffix = stack.slice(stack.length-13); + let isSeq = true; + for(let i=0; i<12; i++) { + if (!suffix[i].isFaceUp || suffix[i].suit !== suffix[i+1].suit || suffix[i].rank !== suffix[i+1].rank+1) { + isSeq = false; break; + } } - if (isCompleted) { - isAnimatingCompletion = true; - const cardsToRemove = stack.slice(stack.length - 13); - const originalStackLength = stack.length; - cardsToRemove.forEach((card, index) => { - const cardIndexInStack = originalStackLength - 13 + index; - card.animStartX = tableauStartX + stackIndex * (cardWidth + cardGapX); - card.animStartY = (cardHeight * 0.5) + cardIndexInStack * cardOverlapY; - card.animEndTime = Date.now() + 500; - card.animTargetX = (UI_ELEMENTS.foundationArea.x + currentGame.foundation.length * (cardWidth * FOUNDATION_CARD_SPACING)); - card.animTargetY = UI_ELEMENTS.foundationArea.y; - completedStackCards.push(card); - }); - stack.splice(stack.length - 13, 13); - if (stack.length > 0) stack[stack.length - 1].isFaceUp = true; - currentGame.foundation.push(cardsToRemove); + if (isSeq) { // 완성! + stack.splice(stack.length-13, 13); + if (stack.length>0) stack[stack.length-1].isFaceUp = true; + currentGame.foundation.push(suffix); + + // 게임 클리어 체크 + if (currentGame.foundation.length === 8) { + showSuccessOverlay({ + title: "SPIDER CLEAR!", scoreLabel: "MOVES", scoreValue: currentGame.moves + }); + setTimeout(() => Game.showSuccessModal({ gameType: 'SPIDER', primaryScore: currentGame.moves }), 2500); + } } - } - const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0); - if (totalFoundationCards === 104 && !isGameCompleted) { - isGameCompleted = true; - completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000); - - // [수정] Game 모듈 사용 - Game.showSuccessModal({ - gameType: currentGameType, contextId: currentContextId, - successMessage: `게임 완료! (이동: ${currentGame.moves}회, 시간: ${Math.floor(completionTimeSeconds/60)}분 ${completionTimeSeconds%60}초)`, - primaryScore: currentGame.moves, secondaryScore: completionTimeSeconds - }); - } + }); } - // 5. 서버 통신 (Api 모듈 사용) - async function startNewGame(loadFromSaved) { - isProcessing = true; - try { - let gameData; - if (loadFromSaved) { - const savedId = localStorage.getItem(SAVED_GAME_ID_KEY); - if (!savedId) throw new Error("저장된 게임이 없습니다."); - gameData = await Api.request(`/puzzle/spider/${savedId}`); - } else { - const numSuits = selectedSuit, numCards = selectedCardCount; - currentContextId = `${numSuits}_SUITS_${numCards.replace(',', '-')}`; - // updateGameRanking('SPIDER', currentContextId); // 필요시 추가 - gameData = await Api.request(`/puzzle/spider/new?numSuits=${numSuits}&numCards=${numCards}`); - } - currentGame = gameData; - if (!currentGame.undoHistory) currentGame.undoHistory = []; - if (typeof currentGame.undoCount !== 'number') currentGame.undoCount = 0; - isGameCompleted = false; - gameStartTime = Date.now(); - } catch (error) { - UI.showAlert("알림", error.message); - currentGame = null; - } finally { - isProcessing = false; - } + // Undo 관련 + function saveUndoState() { + if (!currentGame.history) currentGame.history = []; + const state = JSON.parse(JSON.stringify({ + t: currentGame.tableau, s: currentGame.stock, f: currentGame.foundation, m: currentGame.moves + })); + currentGame.history.push(state); + if (currentGame.history.length > 10) currentGame.history.shift(); } - async function saveGameToServer() { - if (!currentGame || isProcessing) return; - isProcessing = true; - try { - const savedGame = await Api.request(`/puzzle/spider/update`, 'POST', currentGame); - currentGame.id = savedGame.id; - localStorage.setItem(SAVED_GAME_ID_KEY, currentGame.id); - UI.showAlert("알림", "게임이 저장되었습니다."); - } catch (error) { - UI.showAlert("알림", "게임 저장 실패"); - } finally { - isProcessing = false; - } + function handleUndo() { + if (!currentGame || !currentGame.history || currentGame.history.length===0) return; + const prev = currentGame.history.pop(); + currentGame.tableau = prev.t; + currentGame.stock = prev.s; + currentGame.foundation = prev.f; + currentGame.moves = prev.m; } - resizeCanvas(); - function gameLoop() { draw(); requestAnimationFrame(gameLoop); } - gameLoop(); + // 입력 이벤트 + canvas.addEventListener('mousedown', e => { + const p = getCoords(e); + if (!currentGame) { if(isInside(p, ui.start)) start(); } + else { + if (isInside(p, ui.stock)) deal(); + else if (isInside(p, ui.undo)) handleUndo(); + else findDrag(p); + } + draw(); + }); + + canvas.addEventListener('mousemove', e => { + if (draggedCards.length > 0) { + const p = getCoords(e); + draggedCards.forEach(c => { c.drawX = p.x - dragOff.x; c.drawY = p.y - dragOff.y; }); + draw(); + } + }); + + window.addEventListener('mouseup', e => { + if (draggedCards.length > 0) { + handleDrop(getCoords(e)); + draw(); + } + }); + + cardBack.onload = () => { resize(); draw(); }; }); \ No newline at end of file diff --git a/src/main/resources/static/js/pages/game_sudoku.js b/src/main/resources/static/js/pages/game_sudoku.js index e8ed58f..d2e2b71 100644 --- a/src/main/resources/static/js/pages/game_sudoku.js +++ b/src/main/resources/static/js/pages/game_sudoku.js @@ -1,319 +1,40 @@ -import { Api } from '../modules/api.js'; -import { Game } from '../modules/game.js'; -import { UI } from '../modules/ui.js'; +import { CommonCanvas } from '../modules/canvas_utils.js'; -document.addEventListener('DOMContentLoaded', () => { - const currentGameType = 'SUDOKU'; +const V_SIZE = 600; // 논리적 고정 크기 +const CELL_SIZE = V_SIZE / 9; - // DOM 요소 참조 - const setupContainer = document.getElementById('setup-container'); - const gameControls = document.getElementById('game-controls-container'); - const boardEl = document.getElementById('sudoku-board'); - const timerEl = document.getElementById('timer'); - const scoreEl = document.getElementById('score'); - const numberInputButtons = document.getElementById('number-input-buttons'); - const undoBtn = document.getElementById('undo-btn'); - const hintBtn = document.getElementById('hint-btn'); - const completeBtn = document.getElementById('complete-btn'); +function drawSudoku(ctx, boardData, state) { + ctx.clearRect(0, 0, V_SIZE, V_SIZE); - // 게임 상태 변수 - let currentPuzzleId, solvedPuzzle, timerInterval, secondsElapsed = 0; - let selectedNumber = null, focusedCell = null, score = 5, history = []; + // 1. 그리드 그리기 + for (let i = 0; i <= 9; i++) { + ctx.lineWidth = (i % 3 === 0) ? 4 : 1; + ctx.beginPath(); + ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, V_SIZE); + ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(V_SIZE, i * CELL_SIZE); + ctx.stroke(); + } - // 1. 게임 시작 버튼 핸들러 - document.getElementById('start-btn').addEventListener('click', async () => { - const diff = document.getElementById('difficulty-select').value; - try { - const data = await Api.request(`/puzzle/sudoku/start?difficulty=${diff}`); - currentPuzzleId = data.puzzleId; - solvedPuzzle = data.solution; - history = []; - score = 5; + // 2. 숫자 및 하이라이트 그리기 + boardData.forEach((val, i) => { + const r = Math.floor(i / 9); + const c = i % 9; + const x = c * CELL_SIZE; + const y = r * CELL_SIZE; - renderBoard(data.question); - startTimer(); - updateScore(); - updateButtonStates(); // [복구됨] + // 선택된 셀 하이라이트 + if (state.focusedIndex === i) { + ctx.fillStyle = 'rgba(0, 123, 255, 0.2)'; + ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE); + } - setupContainer.classList.add('hidden'); - boardEl.classList.remove('hidden'); - gameControls.classList.remove('hidden'); - } catch (e) { - UI.showAlert("오류", "게임 로딩 실패: " + e.message); + // 숫자 렌더링 + if (val !== 0) { + ctx.fillStyle = state.isEditable[i] ? '#007bff' : '#000'; + ctx.font = `bold ${CELL_SIZE * 0.6}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(val, x + CELL_SIZE/2, y + CELL_SIZE/2); } }); - - // 2. 보드 렌더링 함수 - function renderBoard(str) { - boardEl.innerHTML = ''; - for (let i = 0; i < 81; i++) { - const cell = document.createElement('div'); - cell.className = 'cell'; - cell.dataset.index = i; - if (str[i] !== '0') { - cell.textContent = str[i]; - } else { - cell.classList.add('editable'); - } - boardEl.appendChild(cell); - } - } - - // 3. 타이머 함수 - function startTimer() { - secondsElapsed = 0; - timerEl.textContent = '00:00'; - clearInterval(timerInterval); - timerInterval = setInterval(() => { - secondsElapsed++; - const m = Math.floor(secondsElapsed / 60).toString().padStart(2,'0'); - const s = (secondsElapsed % 60).toString().padStart(2,'0'); - timerEl.textContent = `${m}:${s}`; - }, 1000); - } - - // 4. 점수 업데이트 함수 - function updateScore() { - scoreEl.textContent = `SCORE: ${score}`; - if (score <= 0) { - clearInterval(timerInterval); - UI.showAlert("게임 오버", "포인트가 소진되었습니다."); - resetGame(); - } - } - - // 5. [복구됨] 숫자 버튼 상태 업데이트 (9개 다 채우면 비활성화) - function updateButtonStates() { - const counts = {}; - for (let i = 1; i <= 9; i++) counts[i] = 0; - - // 현재 보드에 있는 숫자 카운트 - boardEl.querySelectorAll('.cell').forEach(cell => { - const num = cell.textContent; - if (num && counts[num] !== undefined) counts[num]++; - }); - - // 버튼 스타일 적용 - for (let i = 1; i <= 9; i++) { - const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`); - if (btn) { - if (counts[i] >= 9) { - btn.classList.add('completed'); - // 만약 현재 선택된 숫자가 완료된 숫자라면 선택 해제 - if (selectedNumber == i) { - selectedNumber = null; - btn.classList.remove('selected'); - } - } else { - btn.classList.remove('completed'); - } - } - } - } - - // 6. [복구됨] 숫자 버튼 클릭 핸들러 - numberInputButtons.addEventListener('click', (event) => { - const target = event.target.closest('button'); - if (!target) return; - - if (target === undoBtn) { - undoAction(); - return; - } - - if (target.classList.contains('completed')) return; - - document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected')); - - if (target.classList.contains('num-btn')) { - const num = target.dataset.number; - // 이미 선택된 숫자면 해제, 아니면 선택 - selectedNumber = (selectedNumber === num) ? null : num; - if (selectedNumber) target.classList.add('selected'); - } - highlightCells(); - }); - - // 7. [복구됨] 보드 셀 클릭 핸들러 - boardEl.addEventListener('click', (event) => { - const targetCell = event.target.closest('.cell.editable'); - - // 빈 곳이나 편집 불가능한 셀 클릭 시 포커스 해제 - if (!targetCell) { - if (focusedCell) focusedCell = null; - highlightCells(); - return; - } - - focusedCell = targetCell; - - // 숫자가 선택된 상태라면 해당 숫자를 입력 - if (selectedNumber) { - const previousValue = targetCell.textContent; - // 같은 숫자를 다시 누르면 지우기(toggle) - let newValue = (previousValue === selectedNumber) ? '' : selectedNumber; - - targetCell.textContent = newValue; - recordAction(targetCell, previousValue, newValue); - validateCell(targetCell); - updateButtonStates(); - checkIfBoardIsFull(); - } - highlightCells(); - }); - - // 8. [복구됨] 힌트 버튼 핸들러 - hintBtn.addEventListener('click', () => { - if (score <= 0) return; - - const emptyCells = Array.from(boardEl.querySelectorAll('.cell.editable')) - .filter(cell => !cell.textContent); - - if (emptyCells.length === 0) { - return UI.showAlert("알림", '빈 칸이 없습니다.'); - } - - const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; - const cellIndex = parseInt(randomCell.dataset.index); - const correctAnswer = solvedPuzzle[cellIndex]; - const previousValue = randomCell.textContent; - - score--; - updateScore(); - - recordAction(randomCell, previousValue, correctAnswer, true); - randomCell.textContent = correctAnswer; - // 힌트로 채워진 셀은 더 이상 수정 불가 및 정답 처리 - randomCell.classList.remove('editable', 'incorrect'); - - updateButtonStates(); - highlightCells(); - checkIfBoardIsFull(); - }); - - // 9. [복구됨] 되돌리기 (Undo) - function undoAction() { - if (history.length === 0) return; - const lastAction = history.pop(); - const cell = boardEl.querySelector(`.cell[data-index="${lastAction.index}"]`); - - if (cell) { - cell.textContent = lastAction.previousValue; - if (lastAction.wasHint) { - cell.classList.add('editable'); - } - validateCell(cell, false); // 되돌리기 시에는 점수 차감 안 함 - updateButtonStates(); - highlightCells(); - } - } - - // 10. [복구됨] 셀 검증 (오답 체크) - function validateCell(cell, deductPoint = true) { - if (!cell.textContent) { - cell.classList.remove('incorrect'); - return; - } - const cellIndex = parseInt(cell.dataset.index); - const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]); - - if (!isCorrect) { - cell.classList.add('incorrect'); - if (deductPoint && score > 0) { - score--; - updateScore(); - } - } else { - cell.classList.remove('incorrect'); - } - } - - // 11. [복구됨] 하이라이트 (포커스, 같은 숫자 등) - function highlightCells() { - document.querySelectorAll('.cell').forEach(cell => { - cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); - }); - - // 포커스된 셀 하이라이트 - if (focusedCell) { - focusedCell.classList.add('highlight-focused'); - const focusedValue = focusedCell.textContent; - if (focusedValue) { - document.querySelectorAll('.cell').forEach(cell => { - if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number'); - }); - } - } - - // 선택된 숫자 하이라이트 - if (selectedNumber) { - document.querySelectorAll('.cell').forEach(cell => { - if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number'); - }); - } - } - - // 12. [복구됨] 모든 칸이 찼는지 확인 - function checkIfBoardIsFull() { - const emptyEditableCells = boardEl.querySelector('.cell.editable:empty'); - if (!emptyEditableCells) { - // 빈 칸이 없으면 자동으로 정답 확인 - checkSolution(); - } - } - - // 13. 정답 확인 및 게임 완료 처리 - async function checkSolution() { - let answer = ""; - boardEl.childNodes.forEach(c => answer += c.textContent || '0'); - - if (answer.includes('0')) { - return UI.showAlert("알림", "모든 칸을 채워주세요."); - } - - try { - const res = await Api.request('/puzzle/sudoku/validate', 'POST', { - puzzleId: currentPuzzleId, - answer: answer - }); - - if (res.correct) { - clearInterval(timerInterval); - Game.showSuccessModal({ - gameType: currentGameType, - contextId: currentPuzzleId, - successMessage: `성공! 기록: ${Math.floor(secondsElapsed/60)}분 ${secondsElapsed%60}초`, - primaryScore: secondsElapsed - }); - resetGame(); - } else { - UI.showAlert("실패", "틀린 부분이 있습니다."); - } - } catch (e) { - console.error(e); - } - } - - // 정답 확인 버튼 - completeBtn.addEventListener('click', checkSolution); - - // 14. 유틸리티: 액션 기록 - function recordAction(cell, previousValue, newValue, wasHint = false) { - history.push({ index: cell.dataset.index, previousValue, newValue, wasHint }); - } - - // 15. 게임 리셋 (초기 화면으로) - function resetGame() { - setupContainer.classList.remove('hidden'); - boardEl.classList.add('hidden'); - gameControls.classList.add('hidden'); - clearInterval(timerInterval); - selectedNumber = null; - focusedCell = null; - - document.querySelectorAll('.cell').forEach(cell => { - cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); - }); - document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed')); - } -}); \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/resources/static/js/pages/game_sudoku_canvas.js b/src/main/resources/static/js/pages/game_sudoku_canvas.js new file mode 100644 index 0000000..0ad4adf --- /dev/null +++ b/src/main/resources/static/js/pages/game_sudoku_canvas.js @@ -0,0 +1,203 @@ +import { Api } from '../modules/api.js'; +import { Game } from '../modules/game.js'; +import { UI } from '../modules/ui.js'; +import { CommonCanvas } from '../modules/canvas_utils.js'; + +document.addEventListener('DOMContentLoaded', () => { + const canvas = document.getElementById('sudokuCanvas'); + if (!canvas) return; + + // 설정: 600x820 (보드 600 + 하단 UI 220) + const V_W = 600, V_H = 820, CELL = 600/9; + const common = CommonCanvas.init(canvas, V_W, V_H); + const { ctx, resize, getCoords, isInside, showSuccessOverlay, fillRoundRect } = common; + + let gameState = 'START'; + let board = [], solution = [], isEditable = [], isIncorrect = []; + let history = []; // 실행 취소 스택 + let selectedNumber = null; + let lives = 5, seconds = 0, timerInterval = null, currentPuzzleId = null; + + const layout = { + menu: { + easy: { x: 100, y: 320, w: 400, h: 60, label: "쉬움 (Easy)" }, + medium: { x: 100, y: 400, w: 400, h: 60, label: "보통 (Medium)" }, + hard: { x: 100, y: 480, w: 400, h: 60, label: "어려움 (Hard)" } + }, + controls: { + undo: { x: 20, y: 610, w: 100, h: 40, label: "↺ 취소" }, + hint: { x: 140, y: 610, w: 100, h: 40, label: "💡 힌트" }, + memo: { x: 480, y: 610, w: 100, h: 40, label: "SCORE" } // 점수 표시용 + }, + numPadY: 680, numBtnW: V_W / 9 + }; + + function draw() { + ctx.clearRect(0, 0, V_W, V_H); + ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, V_W, V_H); + + if (gameState === 'START') drawMenu(); + else drawGame(); + } + + function drawMenu() { + ctx.fillStyle = "#333"; ctx.font = "bold 50px Arial"; ctx.textAlign = "center"; + ctx.fillText("SUDOKU", V_W/2, 220); + Object.values(layout.menu).forEach(btn => { + fillRoundRect(btn.x, btn.y, btn.w, btn.h, 10, "#4a90e2"); + ctx.fillStyle = "#fff"; ctx.font = "bold 24px Arial"; + ctx.fillText(btn.label, btn.x + btn.w/2, btn.y + btn.h/2 + 8); + }); + } + + function drawGame() { + // 1. 보드 + const counts = getNumberCounts(); + board.forEach((val, i) => { + const r = Math.floor(i/9), c = i%9, x = c*CELL, y = r*CELL; + // 배경 + if (isIncorrect[i]) { ctx.fillStyle = "#ffe3e3"; ctx.fillRect(x,y,CELL,CELL); } + else if (selectedNumber && val === selectedNumber) { ctx.fillStyle = "#e2f0ff"; ctx.fillRect(x,y,CELL,CELL); } + + // 격자 + ctx.strokeStyle = "#ddd"; ctx.lineWidth = 1; ctx.strokeRect(x,y,CELL,CELL); + + // 숫자 + if (val !== 0) { + ctx.fillStyle = isIncorrect[i] ? "#e03131" : (isEditable[i] ? "#4a90e2" : "#333"); + ctx.font = `bold ${CELL*0.6}px Arial`; ctx.textAlign = "center"; + ctx.fillText(val, x+CELL/2, y+CELL/2+10); + } + }); + + // 3x3 굵은 선 + ctx.strokeStyle = "#333"; ctx.lineWidth = 3; + for(let i=0; i<=9; i+=3) { + ctx.beginPath(); ctx.moveTo(i*CELL, 0); ctx.lineTo(i*CELL, 600); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, i*CELL); ctx.lineTo(600, i*CELL); ctx.stroke(); + } + + // 2. 컨트롤 버튼 (Undo, Hint) + [layout.controls.undo, layout.controls.hint].forEach(btn => { + fillRoundRect(btn.x, btn.y, btn.w, btn.h, 5, "#6c757d"); + ctx.fillStyle = "#fff"; ctx.font = "16px Arial"; ctx.textAlign = "center"; + ctx.fillText(btn.label, btn.x+btn.w/2, btn.y+btn.h/2+6); + }); + // 점수/생명 표시 + ctx.fillStyle = "#333"; ctx.font = "20px Arial"; ctx.textAlign = "right"; + ctx.fillText(`LIFE: ${lives} TIME: ${seconds}s`, V_W-20, 635); + + // 3. 숫자 패드 + for(let i=1; i<=9; i++) { + const bx = (i-1)*layout.numBtnW; + const isDone = counts[i] >= 9; + const bgColor = isDone ? "#e0e0e0" : (selectedNumber == i ? "#4a90e2" : "#f8f9fa"); + const textColor = isDone ? "#aaa" : (selectedNumber == i ? "#fff" : "#333"); + + fillRoundRect(bx+4, layout.numPadY, layout.numBtnW-8, 80, 8, bgColor); + ctx.fillStyle = textColor; ctx.font = "bold 30px Arial"; ctx.textAlign = "center"; + ctx.fillText(i, bx+layout.numBtnW/2, layout.numPadY+50); + } + } + + function getNumberCounts() { + const counts = {}; for(let i=1; i<=9; i++) counts[i]=0; + board.forEach((v, i) => { if(v!==0 && !isIncorrect[i]) counts[v]++; }); + return counts; + } + + // --- 게임 로직 --- + async function startGame(diff) { + try { + const data = await Api.request(`/puzzle/sudoku/start?difficulty=${diff}`); + board = data.question.split('').map(Number); + solution = data.solution.split('').map(Number); + isEditable = board.map(v => v === 0); + isIncorrect = Array(81).fill(false); + currentPuzzleId = data.puzzleId; + history = []; lives = 5; seconds = 0; gameState = 'PLAYING'; + if (timerInterval) clearInterval(timerInterval); + timerInterval = setInterval(() => { seconds++; draw(); }, 1000); + draw(); + } catch(e) { console.error(e); } + } + + function fillCell(idx, num) { + if (!isEditable[idx]) return; + const prev = board[idx]; + if (prev === num) return; // 변경 없음 + + // 히스토리 저장 + history.push({ idx, prev, wasIncorrect: isIncorrect[idx] }); + + board[idx] = num; + + if (solution[idx] === num) { + isIncorrect[idx] = false; + // 숫자 9개 다 찼으면 선택 해제 + if (getNumberCounts()[num] >= 9) selectedNumber = null; + checkWin(); + } else { + isIncorrect[idx] = true; + lives--; + if (lives <= 0) { + alert("Game Over"); location.reload(); + } + } + } + + function handleUndo() { + if (history.length === 0) return; + const last = history.pop(); + board[last.idx] = last.prev; + isIncorrect[last.idx] = last.wasIncorrect; + } + + function handleHint() { + const emptyIndices = board.map((v, i) => v === 0 || isIncorrect[i] ? i : -1).filter(i => i !== -1); + if (emptyIndices.length === 0) return; + + const idx = emptyIndices[Math.floor(Math.random() * emptyIndices.length)]; + // 힌트는 히스토리 없이 정답 처리 + board[idx] = solution[idx]; + isIncorrect[idx] = false; + // 힌트 사용 시 점수나 생명 차감 등 추가 가능 + checkWin(); + } + + function checkWin() { + if (board.every((v, i) => v === solution[i])) { + clearInterval(timerInterval); + showSuccessOverlay({ title: "SUDOKU CLEAR!", scoreLabel: "LIFE", scoreValue: lives, timeValue: seconds }); + setTimeout(() => Game.showSuccessModal({ + gameType: 'SUDOKU', contextId: currentPuzzleId, primaryScore: seconds + }), 2500); + } + } + + // --- 입력 핸들러 --- + canvas.addEventListener('mousedown', (e) => { + const p = getCoords(e); + if (gameState === 'START') { + for(const [k, btn] of Object.entries(layout.menu)) if(isInside(p, btn)) startGame(k); + } else { + // 컨트롤 버튼 + if (isInside(p, layout.controls.undo)) { handleUndo(); draw(); return; } + if (isInside(p, layout.controls.hint)) { handleHint(); draw(); return; } + + // 숫자 패드 + if (p.y > layout.numPadY) { + const num = Math.floor(p.x / layout.numBtnW) + 1; + if (getNumberCounts()[num] < 9) selectedNumber = (selectedNumber == num) ? null : num; + } + // 보드 클릭 + else if (p.y < 600 && selectedNumber) { + const c = Math.floor(p.x/CELL), r = Math.floor(p.y/CELL); + if (c>=0 && c<9 && r>=0 && r<9) fillCell(r*9+c, parseInt(selectedNumber)); + } + } + draw(); + }); + + resize(); draw(); +}); \ No newline at end of file diff --git a/src/main/resources/templates/content/home.html b/src/main/resources/templates/content/home.html index 5a5392d..b7defbe 100644 --- a/src/main/resources/templates/content/home.html +++ b/src/main/resources/templates/content/home.html @@ -35,49 +35,82 @@
-
-
-
-
-
-
- - Post Thumbnail - +
+
-
-

- - - (읽음: 0) - -

-

- -

-
-
- -
-
-

- Advertisement -

- - -
-
-
-
-
+
+ + Thumbnail + Default Thumbnail + +
+

제목

+

+

내용 요약

+
+ +
+
+
+ + + +
+

+
+
+ +
+
+
+ +
+
+

+ + 북마크 제목 + + +

+

+

+ Saved on +

+
+
+
+ +
+ - Advertisement - + + +
+
+ + +
+ +
@@ -131,5 +164,113 @@ + \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/2048.html b/src/main/resources/templates/content/puzzle/2048.html index 38eabb4..9754dcd 100644 --- a/src/main/resources/templates/content/puzzle/2048.html +++ b/src/main/resources/templates/content/puzzle/2048.html @@ -9,23 +9,9 @@ -
-

2048 Puzzle

-

화살표나 터치로 타일을 합쳐 2048을 만드세요!

-
-
-
SCORE: 0
-
-
-
-
+
- - -
- - -
+
\ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/nonogram.html b/src/main/resources/templates/content/puzzle/nonogram.html index c138a14..47f3dba 100644 --- a/src/main/resources/templates/content/puzzle/nonogram.html +++ b/src/main/resources/templates/content/puzzle/nonogram.html @@ -1,36 +1,14 @@ - - - - + -
-

Nonogram Logic

-
-
-
- - -
-
❤️ 5
- -
-
-
- - -
-
-
- - +
+ +
- \ No newline at end of file + diff --git a/src/main/resources/templates/content/puzzle/spider.html b/src/main/resources/templates/content/puzzle/spider.html index 0f961e0..11dca09 100644 --- a/src/main/resources/templates/content/puzzle/spider.html +++ b/src/main/resources/templates/content/puzzle/spider.html @@ -8,12 +8,8 @@ -
-

Spider Solitaire

-
- -
-
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/sudoku.html b/src/main/resources/templates/content/puzzle/sudoku.html index 9a2505f..6ab062b 100644 --- a/src/main/resources/templates/content/puzzle/sudoku.html +++ b/src/main/resources/templates/content/puzzle/sudoku.html @@ -9,46 +9,9 @@ -
-

Sudoku Daily

-
-
-
- - -
- -
+
- -
-
- +
\ No newline at end of file diff --git a/src/main/resources/templates/content/stock/config.html b/src/main/resources/templates/content/stock/config.html new file mode 100644 index 0000000..e1b55d7 --- /dev/null +++ b/src/main/resources/templates/content/stock/config.html @@ -0,0 +1,52 @@ + + +
+
+
+

한국투자증권 API 설정

+

보안을 위해 입력하신 키는 DB에 저장되지 않으며, 세션 종료 시 즉시 파기됩니다.

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
    +
  • +
+
+
+
+
+ + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/content/stock/dashboard.html b/src/main/resources/templates/content/stock/dashboard.html new file mode 100644 index 0000000..7aec2fe --- /dev/null +++ b/src/main/resources/templates/content/stock/dashboard.html @@ -0,0 +1,59 @@ + + +
+
+
+

나의 투자 대시보드

+
+ +
+
+
+

총 자산

+

0 원

+

수익률: 0.00%

+
+
+
+
+ + + + + + + + + + + + + +
종목명보유수량매입가현재가수익률
데이터를 불러오는 중...
+
+
+
+
+ + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/content/stock/market.html b/src/main/resources/templates/content/stock/market.html new file mode 100644 index 0000000..4a09adf --- /dev/null +++ b/src/main/resources/templates/content/stock/market.html @@ -0,0 +1,56 @@ + + + +
+
+
+

글로벌 시장 지표

+
+
+
+
+ + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/fragments/game_template.html b/src/main/resources/templates/fragments/game_template.html new file mode 100644 index 0000000..42389e8 --- /dev/null +++ b/src/main/resources/templates/fragments/game_template.html @@ -0,0 +1,71 @@ + + + +
+

Game Title

+ +
+ + + +
+
+ + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 5b5ff2e..e8f5614 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -15,6 +15,14 @@
    +