This commit is contained in:
lunaticbum 2025-12-26 17:31:21 +09:00
parent 9b29b623c2
commit e8355b3048
46 changed files with 2510 additions and 1765 deletions

View File

@ -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",

View File

@ -23,12 +23,20 @@ class GlobalControllerAdvice(
}
// [추가] 로그인한 경우, User 엔티티(테마 정보 포함)를 모델에 "user"라는 이름으로 추가
// @ModelAttribute("user")
// fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> {
// return if (userDetails != null) {
// userManager.findById(userDetails.username)
// } else {
// Mono.empty()
// }
// }
@ModelAttribute("user")
fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> {
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()
}
}

View File

@ -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<ResponceResult> {
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<Map<String, Any>> {
// 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요)
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<Map<String, Any>>
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
vm.modelMap["Posts"] = postsList.map { processPostForView(it) }
vm.modelMap["path"] = "/blog/viewer/"

View File

@ -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
)

View File

@ -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

View File

@ -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<PostHistory, String> {
// [추가] postId로 모든 히스토리를 최신순으로 조회
fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux<PostHistory>
}
// 3. PostHistory를 위한 Service 클래스
@Service
class PostHistoryManager(private val repository: PostHistoryRepository) {
fun save(postHistory: PostHistory): Mono<PostHistory> {
return repository.save(postHistory)
}
// [추가] postId로 모든 히스토리를 조회하는 함수
fun findByPostId(postId: String): Flux<PostHistory> {
return repository.findByPostIdOrderByArchivedAtDesc(postId)
}
}
enum class PostType {
STANDARD, // 일반 블로그 글
@ -178,487 +161,7 @@ class CommentsResult {
data class AggregationCount(val totalCount: Long)
@Repository
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment>
fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux<Comment> // [신규 추가]
}
@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<Comment> {
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
fun addComment(comment: Comment): Mono<Comment> {
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
fun getCommentsForPost(postId: String): Flux<Comment> {
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
}
@Repository
interface PostRepository : ReactiveMongoRepository<Post, String> {
fun findAllByModifyTime(time : Long? = 0): Flux<Post>
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
fun countByOrderByModifyTimeDesc(): Mono<Long>
@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<Post>
@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<Post>
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
// [단순화] 공개된 글 목록 조회 (페이지네이션)
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
// [단순화] 공개된 글 개수 카운트
fun countByPostingIsTrue(): Mono<Long>
// [단순화] 인기글 5개 조회 (공개된 글 대상)
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
// [단순화] 최신글 5개 조회 (공개된 글 대상)
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
@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<Post>
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
@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<Post>
/**
* 익명 사용자를 위한 '고유 최신 ' 목록을 페이지네이션으로 조회합니다.
* [버그 수정] 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<Post>
/**
* '고유 최신 ' 개수를 카운트합니다. (페이지네이션의 totalElements 계산용)
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화
"{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈
])
fun countLatestUniqueOrigin(): Mono<AggregationCount> // 헬퍼 클래스로 매핑
@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<Post>
@Aggregation(pipeline = [
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }",
"{ \$count: \"totalCount\" }"
])
fun countLatestUniqueForWriter(username: String): Mono<AggregationCount>
/**
* [수정] 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<Post>
/**
* '고유 최신 ' 공개된 글의 개수를 카운트합니다.
* [수정] 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<AggregationCount>
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
@Aggregation(pipeline = [
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
])
fun findRandomPublishedPostByType(postType: String): Mono<Post>
// --- [신규 추가] 필터링을 위한 Repository 메소드 ---
fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux<Post>
fun countByCategoryAndPostingIsTrue(category: String): Mono<Long>
fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux<Post>
fun countByTagsRegexAndPostingIsTrue(tag: String): Mono<Long>
// [추가] 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<org.bson.Document> // 반환 타입을 Document로 변경
// [신규 추가] GIBBERISH 제외하고 조회
fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux<Post>
fun countByPostTypeNot(postType: String): Mono<Long>
}
@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<Void> {
return postRepository.deleteById(postId)
}
// [수정] 익명 사용자용 목록 조회 (Aggregation 사용)
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniquePublishedPaginated(pageable)
.collectList()
}
// [수정] 익명 사용자용 글 개수 (Aggregation 사용)
fun countLatestUnique(): Mono<Long> {
return postRepository.countLatestUniquePublished()
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
// [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용)
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
.collectList()
}
// [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용)
fun countLatestUniqueForWriter(username: String): Mono<Long> {
return postRepository.countLatestUniqueForWriter(username)
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
// [수정] 익명 사용자용 인기글
fun getTop5UniquePublishedByViews(): Flux<Post> {
return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [수정] 익명 사용자용 최신글
fun getRecent5UniquePublished(): Flux<Post> {
return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map {
p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// --- [신규 추가] 카테고리/태그 관련 서비스 메소드 ---
fun findAllDistinctCategories(): Flux<String> {
// '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<String> {
return postRepository.findDistinctTags()
.mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출
.filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링
}
// --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 ---
fun findPostsByCategory(category: String, pageable: Pageable): Mono<List<Post>> {
return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList()
}
fun countPostsByCategory(category: String): Mono<Long> {
return postRepository.countByCategoryAndPostingIsTrue(category)
}
fun findPostsByTag(tag: String, pageable: Pageable): Mono<List<Post>> {
// [수정] 한글 및 다국어를 지원하는 정규식으로 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList()
}
fun countPostsByTag(tag: String): Mono<Long> {
// [수정] 위와 동일하게 정규식 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.countByTagsRegexAndPostingIsTrue(regex)
}
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
fun findRandomGibberish(): Mono<Post> {
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
}
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
fun findLatestAboutPost(): Mono<Post> {
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
}
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
fun findAboutPostHistory(): Flux<Post> {
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
}
// [신규] 게시물 차단
fun blockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = true
postRepository.save(post)
}
}
// [신규] 게시물 차단 해제
fun unblockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = false
postRepository.save(post)
}
}
fun findById(id: String): Mono<Post> {
return postRepository.findById(id)
}
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
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<Post> {
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<List<Post>> {
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<Long> {
return postRepository.countByPostTypeNot(PostType.GIBBERISH.name)
}
/**
* 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 있도록 *업데이트된* 문서를 반환합니다.
*/
fun incrementVote(postId: String): Mono<Post> {
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<Post> {
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<Post> {
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<Post> {
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<List<Post>> {
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
return this.findLatestUniquePaginated(pageRequest)
}
fun save(post: Post): Mono<Post> {
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<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [기존] 로그인 사용자용 최신글 (메서드 이름 명확화)
fun getRecent5AllVersions(): Flux<Post> {
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<WebBookmark, String> {
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
fun findByMetadataStatus(status: String): Flux<WebBookmark>
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
fun findDistinctTags(): Flux<Map<String, Any>>
@Aggregation("{ \$group: { _id: '\$category' } }")
fun findDistinctCategories(): Flux<Map<String, Any>>
}
@Service
class WebBookmarkService(private val repository: WebBookmarkRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
) {
// [이 메소드를 추가하세요]
fun findById(id: String): Mono<WebBookmark> {
return repository.findById(id)
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctCategories(): Flux<String> {
return repository.findDistinctCategories()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctTags(): Flux<String> {
return repository.findDistinctTags()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
return repository.findByUserIdOrderBySavedAtDesc(userId)
}
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
return repository.save(bookmark)
}
// 필요하다면 삭제, 수정 기능 추가
fun deleteBookmark(id: String): Mono<Void> {
return repository.deleteById(id)
}
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
fun getVisibleBookmarks(
userDetails: UserDetails?,
pageable: Pageable,
category: String?, // 카테고리 파라미터 추가
tag: String? // 태그 파라미터 추가
): Mono<Page<WebBookmark>> {
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<WebBookmark> {
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<WebBookmark> {
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)
}
}

View File

@ -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<String>? = null // 태그 목록
)
data class FeedResponse(
val items: List<FeedItemDto>,
val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
)

View File

@ -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<Comment, String> {
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment>
fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux<Comment> // [신규 추가]
}

View File

@ -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<PostHistory, String> {
// [추가] postId로 모든 히스토리를 최신순으로 조회
fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux<PostHistory>
}

View File

@ -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<Post, String> {
@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<Post>
fun findAllByModifyTime(time : Long? = 0): Flux<Post>
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
fun countByOrderByModifyTimeDesc(): Mono<Long>
@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<Post>
@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<Post>
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
// [단순화] 공개된 글 목록 조회 (페이지네이션)
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
// [단순화] 공개된 글 개수 카운트
fun countByPostingIsTrue(): Mono<Long>
// [단순화] 인기글 5개 조회 (공개된 글 대상)
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
// [단순화] 최신글 5개 조회 (공개된 글 대상)
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
@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<Post>
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
@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<Post>
/**
* 익명 사용자를 위한 '고유 최신 ' 목록을 페이지네이션으로 조회합니다.
* [버그 수정] 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<Post>
/**
* '고유 최신 ' 개수를 카운트합니다. (페이지네이션의 totalElements 계산용)
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화
"{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈
])
fun countLatestUniqueOrigin(): Mono<AggregationCount> // 헬퍼 클래스로 매핑
@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<Post>
@Aggregation(pipeline = [
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }",
"{ \$count: \"totalCount\" }"
])
fun countLatestUniqueForWriter(username: String): Mono<AggregationCount>
/**
* [수정] 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<Post>
/**
* '고유 최신 ' 공개된 글의 개수를 카운트합니다.
* [수정] 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<AggregationCount>
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
@Aggregation(pipeline = [
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
])
fun findRandomPublishedPostByType(postType: String): Mono<Post>
// --- [신규 추가] 필터링을 위한 Repository 메소드 ---
fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux<Post>
fun countByCategoryAndPostingIsTrue(category: String): Mono<Long>
fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux<Post>
fun countByTagsRegexAndPostingIsTrue(tag: String): Mono<Long>
// [추가] 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<org.bson.Document> // 반환 타입을 Document로 변경
// [신규 추가] GIBBERISH 제외하고 조회
fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux<Post>
fun countByPostTypeNot(postType: String): Mono<Long>
}

View File

@ -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<WebBookmark, String> {
// WebBookmarkRepository 인터페이스 내부에 추가
// savedAt이 특정 시간(?1)보다 작은 것들 중 최신순 조회
fun findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
visibilities: List<String>,
maxTime: Long,
pageable: Pageable
): Flux<WebBookmark>
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
fun findByMetadataStatus(status: String): Flux<WebBookmark>
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
fun findDistinctTags(): Flux<Map<String, Any>>
@Aggregation("{ \$group: { _id: '\$category' } }")
fun findDistinctCategories(): Flux<Map<String, Any>>
}

View File

@ -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

View File

@ -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<Comment> {
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
fun addComment(comment: Comment): Mono<Comment> {
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
fun getCommentsForPost(postId: String): Flux<Comment> {
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
}
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
}

View File

@ -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<FeedResponse> {
// 커서가 없으면 현재 시간으로 설정 (첫 로딩)
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)
}
}
}

View File

@ -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<String> {
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<Map<*, *>> {
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<Map<*, *>> {
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)
}
}

View File

@ -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<PostHistory> {
return repository.save(postHistory)
}
// [추가] postId로 모든 히스토리를 조회하는 함수
fun findByPostId(postId: String): Flux<PostHistory> {
return repository.findByPostIdOrderByArchivedAtDesc(postId)
}
}

View File

@ -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<Void> {
return postRepository.deleteById(postId)
}
// [수정] 익명 사용자용 목록 조회 (Aggregation 사용)
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniquePublishedPaginated(pageable)
.collectList()
}
// [수정] 익명 사용자용 글 개수 (Aggregation 사용)
fun countLatestUnique(): Mono<Long> {
return postRepository.countLatestUniquePublished()
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
// [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용)
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
.collectList()
}
// [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용)
fun countLatestUniqueForWriter(username: String): Mono<Long> {
return postRepository.countLatestUniqueForWriter(username)
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
// [수정] 익명 사용자용 인기글
fun getTop5UniquePublishedByViews(): Flux<Post> {
return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [수정] 익명 사용자용 최신글
fun getRecent5UniquePublished(): Flux<Post> {
return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map {
p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// --- [신규 추가] 카테고리/태그 관련 서비스 메소드 ---
fun findAllDistinctCategories(): Flux<String> {
// '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<String> {
return postRepository.findDistinctTags()
.mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출
.filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링
}
// --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 ---
fun findPostsByCategory(category: String, pageable: Pageable): Mono<List<Post>> {
return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList()
}
fun countPostsByCategory(category: String): Mono<Long> {
return postRepository.countByCategoryAndPostingIsTrue(category)
}
fun findPostsByTag(tag: String, pageable: Pageable): Mono<List<Post>> {
// [수정] 한글 및 다국어를 지원하는 정규식으로 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList()
}
fun countPostsByTag(tag: String): Mono<Long> {
// [수정] 위와 동일하게 정규식 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.countByTagsRegexAndPostingIsTrue(regex)
}
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
fun findRandomGibberish(): Mono<Post> {
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
}
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
fun findLatestAboutPost(): Mono<Post> {
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
}
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
fun findAboutPostHistory(): Flux<Post> {
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
}
// [신규] 게시물 차단
fun blockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = true
postRepository.save(post)
}
}
// [신규] 게시물 차단 해제
fun unblockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = false
postRepository.save(post)
}
}
fun findById(id: String): Mono<Post> {
return postRepository.findById(id)
}
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
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<Post> {
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<List<Post>> {
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<Long> {
return postRepository.countByPostTypeNot(PostType.GIBBERISH.name)
}
/**
* 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 있도록 *업데이트된* 문서를 반환합니다.
*/
fun incrementVote(postId: String): Mono<Post> {
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<Post> {
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<Post> {
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<Post> {
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<List<Post>> {
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
return this.findLatestUniquePaginated(pageRequest)
}
fun save(post: Post): Mono<Post> {
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<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [기존] 로그인 사용자용 최신글 (메서드 이름 명확화)
fun getRecent5AllVersions(): Flux<Post> {
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
}

View File

@ -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<WebBookmark> {
return repository.findById(id)
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctCategories(): Flux<String> {
return repository.findDistinctCategories()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctTags(): Flux<String> {
return repository.findDistinctTags()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
return repository.findByUserIdOrderBySavedAtDesc(userId)
}
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
return repository.save(bookmark)
}
// 필요하다면 삭제, 수정 기능 추가
fun deleteBookmark(id: String): Mono<Void> {
return repository.deleteById(id)
}
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
fun getVisibleBookmarks(
userDetails: UserDetails?,
pageable: Pageable,
category: String?, // 카테고리 파라미터 추가
tag: String? // 태그 파라미터 추가
): Mono<Page<WebBookmark>> {
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<WebBookmark> {
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<WebBookmark> {
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)
}
}

View File

@ -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<RequestModel>(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<RequestModel>(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)

View File

@ -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;
}

View File

@ -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;

View File

@ -384,4 +384,34 @@ window.openBookmarkEditPopup = function(btn) {
UI.showAlert("알림", "북마크 수정 기능 준비 중");
};
window.openBookmarkCategoryPopup = () => UI.showAlert("알림", "카테고리 기능 준비 중");
window.openBookmarkTagPopup = () => UI.showAlert("알림", "태그 기능 준비 중");
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";
}
}

View File

@ -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("");
}
}
};

View File

@ -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 };
}
};

View File

@ -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<filtered.length-1; i++) {
if (filtered[i] === filtered[i+1]) {
filtered[i] *= 2; score += filtered[i];
filtered[i+1] = 0;
}
}
filtered = filtered.filter(v => 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<filtered.length-1; i++) {
if (filtered[i] === filtered[i+1]) {
filtered[i] *= 2; score += filtered[i];
filtered[i+1] = 0;
}
}
filtered = filtered.filter(v => 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();
});

View File

@ -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();
});

View File

@ -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<sub.length-1; i++) {
if (sub[i].suit !== sub[i+1].suit || sub[i].rank !== sub[i+1].rank + 1) return null;
}
return sub;
}
function getSuitSymbol(suit) {
if (suit === 'spade') return '♠️'; if (suit === 'heart') return '♥️'; if (suit === 'club') return '♣️'; if (suit === 'diamond') return '♦️';
}
function getBestMoveForStack(cardsToMove) {
if (cardsToMove.length === 0) return null;
const firstCardToMove = cardsToMove[0];
for (let i = 0; i < 10; i++) {
const destStackCards = currentGame.tableau[i];
if (destStackCards.length === 0) return `tableau-${i + 1}`;
// 드롭 처리
function handleDrop(p) {
let bestDist = 9999, targetIdx = -1;
// 가장 가까운 컬럼 찾기 (X축 기준)
for (let i=0; i<10; i++) {
const cx = 50 + i*GAP_X + CARD_W/2;
const dist = Math.abs(p.x - cx);
if (dist < CARD_W && dist < bestDist) { bestDist = dist; targetIdx = i; }
}
if (targetIdx !== -1) {
const destStack = currentGame.tableau[targetIdx];
// 이동 규칙: 비어있거나, 타겟 카드가 이동할 카드보다 1 커야 함
let valid = false;
if (destStack.length === 0) valid = true;
else {
const destTopCard = destStackCards[destStackCards.length - 1];
if (firstCardToMove.rank === destTopCard.rank - 1) return `tableau-${i + 1}`;
const top = destStack[destStack.length-1];
const moving = draggedCards[0];
if (top.rank === moving.rank + 1) valid = true;
}
if (valid) {
saveUndoState();
const srcStack = currentGame.tableau[draggedCards.sIdx];
srcStack.splice(srcStack.length - draggedCards.length, draggedCards.length);
if (srcStack.length > 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(); };
});

View File

@ -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'));
}
});
}

View File

@ -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();
});

View File

@ -35,49 +35,82 @@
<section class="wrapper style1">
<div class="container">
<div class="row">
<div class="col-12">
<div id="content_inner">
<article>
<section th:each="post , iterStat : ${Posts}">
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
<span class="image left">
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
alt="Post Thumbnail"
th:onerror="|this.onerror=null; this.src='@{/images/bum.png}';|" />
</span>
<article id="feed-container">
<section th:each="item, iterStat : ${feedItems}" class="feed-item">
<div class="inner">
<h3 style="display: flex; justify-content: space-between; align-items: center;">
<span th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></span>
<span style="font-size: 0.75em; color: #888; font-weight: normal; white-space: nowrap; margin-left: 1em;">
(읽음: <span th:text="${post.readCount}">0</span>)
</span>
</h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p>
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
</div>
</div>
<th:block th:if="${iterStat.count % 3 == 0}">
<section>
<div class="box ad-container" style="padding: 2em; text-align: center;">
<p style="margin-bottom: 1em; color: #888;">- Advertisement -</p>
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
</section>
</th:block>
</section>
</article>
<div th:if="${item.type.name() == 'POST'}" class="box post"
th:onclick="|location.href='@{${item.url}}'|" style="cursor: pointer;">
<span class="image left">
<img th:if="${item.thumbnail}" th:src="${apiBaseUrl + item.thumbnail}"
alt="Thumbnail" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
<img th:unless="${item.thumbnail}" th:src="@{/images/pic01.jpg}" alt="Default Thumbnail" />
</span>
<div class="inner">
<h3 th:text="${item.title}">제목</h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;"
th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')} + ' by ' + ${item.writer}"></p>
<p th:text="${#strings.abbreviate(item.content, 150)}">내용 요약</p>
</div>
</div>
<div th:if="${item.type.name() == 'GIBBERISH'}" class="box post gibberish-card"
th:onclick="|location.href='@{${item.url}}'|"
style="cursor: pointer; background-color: #fff9c4; border-left: 5px solid #fbc02d;">
<div class="inner">
<blockquote>
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i>
<span th:text="${item.content}" style="font-size: 1.1em; font-weight: bold; color: #333;"></span>
<i class="icon fa-quote-right" style="color:#fbc02d; margin-left:10px;"></i>
</blockquote>
<p style="text-align: right; font-size: 0.8em; color: #777; margin-top: 10px;"
th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')}"></p>
</div>
</div>
<div th:if="${item.type.name() == 'BOOKMARK'}" class="box post bookmark-card"
style="border: 1px dashed #3498db;">
<div class="inner" style="display: flex; align-items: center;">
<div style="flex-shrink: 0; margin-right: 20px;" th:if="${item.thumbnail}">
<img th:src="${apiBaseUrl + item.thumbnail}"
style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;" />
</div>
<div style="flex-grow: 1;">
<h4>
<a th:href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;">
<i class="icon solid fa-bookmark"></i> <span th:text="${item.title}">북마크 제목</span>
<i class="icon solid fa-external-link-alt" style="font-size: 0.7em;"></i>
</a>
</h4>
<p th:if="${item.content}" th:text="${item.content}" style="font-size: 0.9em; color: #666; margin-bottom: 0.5em;"></p>
<p style="font-size: 0.8em; color: #999;">
Saved on <span th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd')}"></span>
</p>
</div>
</div>
</div>
<div th:if="${iterStat.count % 3 == 0}" class="box ad-container" style="padding: 1em; text-align: center; margin-bottom: 2em; background: #f4f4f4;">
<span style="font-size: 0.8em; color: #aaa;">- Advertisement -</span>
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
</section>
</article>
<div id="load-more-container" style="text-align: center; margin-top: 2em; margin-bottom: 2em;">
<button id="btn-load-more" class="button alt"
th:if="${nextCursor != null}"
th:data-cursor="${nextCursor}"
onclick="loadMoreFeed()">
More Stories <i class="icon solid fa-chevron-down"></i>
</button>
<div id="loading-spinner" style="display:none;">
<i class="icon solid fa-spinner fa-spin fa-2x"></i>
</div>
</div>
</div>
@ -131,5 +164,113 @@
</header>
</div>
</section>
<script th:inline="javascript">
const apiBaseUrl = /*[[${apiBaseUrl}]]*/ ''; // Thymeleaf 변수 바인딩
function loadMoreFeed() {
const btn = document.getElementById('btn-load-more');
const spinner = document.getElementById('loading-spinner');
const container = document.getElementById('feed-container');
// 현재 커서 값 가져오기
const cursor = btn.getAttribute('data-cursor');
if (!cursor) return;
// UI 상태 변경 (로딩 중)
btn.style.display = 'none';
spinner.style.display = 'inline-block';
// API 호출
fetch(`/api/feed?cursor=${cursor}`)
.then(response => response.json())
.then(data => {
// 데이터가 없으면 버튼 숨기고 종료
if (!data.items || data.items.length === 0) {
spinner.style.display = 'none';
return;
}
// 받아온 데이터를 HTML로 변환하여 추가
data.items.forEach(item => {
const html = createFeedItemHtml(item);
// section 태그로 감싸서 추가
const section = document.createElement('section');
section.className = 'feed-item';
section.innerHTML = html;
container.appendChild(section);
});
// 다음 커서 업데이트
if (data.nextCursor) {
btn.setAttribute('data-cursor', data.nextCursor);
btn.style.display = 'inline-block';
} else {
// 더 이상 불러올 게 없으면 버튼 제거
btn.remove();
}
})
.catch(err => {
console.error('Feed load error:', err);
alert('추가 콘텐츠를 불러오는 중 오류가 발생했습니다.');
btn.style.display = 'inline-block';
})
.finally(() => {
spinner.style.display = 'none';
});
}
// JSON 데이터를 HTML 문자열로 변환하는 헬퍼 함수
function createFeedItemHtml(item) {
const dateStr = new Date(item.createdAt).toISOString().split('T')[0];
if (item.type === 'POST') {
const thumbSrc = item.thumbnail ? (apiBaseUrl + item.thumbnail) : '/images/pic01.jpg';
return `
<div class="box post" onclick="location.href='${item.url}'" style="cursor: pointer;">
<span class="image left"><img src="${thumbSrc}" onerror="this.onerror=null; this.src='/images/pic01.jpg';" /></span>
<div class="inner">
<h3>${item.title || 'Untitled'}</h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;">${dateStr} by ${item.writer || 'Bum'}</p>
<p>${item.content ? item.content.substring(0, 150) + '...' : ''}</p>
</div>
</div>`;
}
else if (item.type === 'GIBBERISH') {
return `
<div class="box post gibberish-card" onclick="location.href='${item.url}'"
style="cursor: pointer; background-color: #fff9c4; border-left: 5px solid #fbc02d;">
<div class="inner">
<blockquote>
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i>
<span style="font-size: 1.1em; font-weight: bold; color: #333;">${item.content}</span>
</blockquote>
<p style="text-align: right; font-size: 0.8em; color: #777;">${dateStr}</p>
</div>
</div>`;
}
else if (item.type === 'BOOKMARK') {
const thumbImg = item.thumbnail ?
`<div style="flex-shrink: 0; margin-right: 20px;"><img src="${apiBaseUrl + item.thumbnail}" style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;" /></div>` : '';
return `
<div class="box post bookmark-card" style="border: 1px dashed #3498db;">
<div class="inner" style="display: flex; align-items: center;">
${thumbImg}
<div style="flex-grow: 1;">
<h4>
<a href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;">
<i class="icon solid fa-bookmark"></i> ${item.title}
<i class="icon solid fa-external-link-alt" style="font-size: 0.7em;"></i>
</a>
</h4>
<p style="font-size: 0.9em; color: #666; margin-bottom: 0.5em;">${item.content || ''}</p>
<p style="font-size: 0.8em; color: #999;">Saved on ${dateStr}</p>
</div>
</div>
</div>`;
}
return '';
}
</script>
</th:block>
</html>

View File

@ -9,23 +9,9 @@
</head>
<body>
<th:block layout:fragment="content">
<div class="game-body-wrapper">
<h1>2048 Puzzle</h1>
<p>화살표나 터치로 타일을 합쳐 2048을 만드세요!</p>
<div class="game-play-box">
<div class="score-container score-board">
<div>SCORE: <span id="score">0</span></div>
</div>
<div id="game-board"></div>
</div>
</div>
<div th:replace="~{fragments/game_template :: gameBody('2048', 'game2048Canvas')}"></div>
<script type="module" th:src="@{/js/pages/game_2048.js}"></script>
<div class="container" style="text-align:center;">
<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-9504446465764716" data-ad-slot="5334609005" data-ad-format="auto" data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<script type="module" th:src="@{/js/pages/game_2048_canvas.js}"></script>
</th:block>
</body>
</html>

View File

@ -1,36 +1,14 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
</th:block >
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="content">
<div class="game-body-wrapper">
<h1>Nonogram Logic</h1>
<div class="game-play-box wide">
<div id="game-controls" style="margin: 0 0 20px 0; width:100%; display:flex; justify-content:space-between;">
<div id="mode-selector">
<label><input type="radio" name="play-mode" value="fill" checked><span>Fill</span></label>
<label><input type="radio" name="play-mode" value="mark"><span>Mark</span></label>
</div>
<div id="points-info" class="score-board">❤️ <span id="points-display">5</span></div>
<button id="hint-btn">Hint</button>
</div>
<div id="board-viewport">
<div id="game-board"></div>
<img id="grayscale-reveal" class="reveal-img" src="" alt="">
<img id="original-reveal" class="reveal-img" src="" alt="">
</div>
</div>
</div>
<script th:inline="javascript">
window.puzzleData = /*[[${puzzle}]]*/ null;
window.puzzleData = [[${puzzle}]];
</script>
<script type="module" th:src="@{/js/pages/game_nonogram.js}"></script>
<div th:replace="~{fragments/game_template :: gameBody('nonogram', 'nonogramCanvas')}"></div>
<script type="module" th:src="@{/js/pages/game_nonogram_canvas.js}"></script>
</th:block>
</html>
</html>

View File

@ -8,12 +8,8 @@
<th:block layout:fragment="head" id="head">
</th:block >
<th:block layout:fragment="content">
<div class="game-body-wrapper">
<h1>Spider Solitaire</h1>
<div class="game-play-box wide" id="game-container">
<canvas id="gameCanvas"></canvas>
</div>
</div>
<div th:replace="~{fragments/game_template :: gameBody('Spider', 'spiderCanvas')}"></div>
<script type="module" th:src="@{/js/pages/game_spider.js}"></script>
</th:block>
</html>

View File

@ -9,46 +9,9 @@
</head>
<body>
<th:block layout:fragment="content">
<div class="game-body-wrapper">
<h1>Sudoku Daily</h1>
<div class="game-play-box">
<div id="board-area">
<div id="setup-container">
<select id="difficulty-select">
<option value="easy">쉬움</option>
<option value="medium">중간</option>
<option value="hard">어려움</option>
</select>
<button id="start-btn">게임 시작</button>
</div>
<div id="sudoku-board" class="hidden"></div>
</div>
<div th:replace="~{fragments/game_template :: gameBody('Sudoku Daily', 'sudokuCanvas')}"></div>
<div id="game-controls-container" class="hidden">
<div class="game-info score-board">
<div id="score">SCORE: 5</div>
<div id="timer">00:00</div>
</div>
<div id="number-input-buttons">
<button class="num-btn" data-number="1">1</button>
<button class="num-btn" data-number="2">2</button>
<button class="num-btn" data-number="3">3</button>
<button class="num-btn" data-number="4">4</button>
<button class="num-btn" data-number="5">5</button>
<button class="num-btn" data-number="6">6</button>
<button class="num-btn" data-number="7">7</button>
<button class="num-btn" data-number="8">8</button>
<button class="num-btn" data-number="9">9</button>
<button id="undo-btn" class="clear-btn"></button>
</div>
<div class="action-buttons">
<button id="hint-btn">힌트</button>
<button id="complete-btn">정답 확인</button>
</div>
</div>
</div>
</div>
<script type="module" th:src="@{/js/pages/game_sudoku.js}"></script>
<script type="module" th:src="@{/js/pages/game_sudoku_canvas.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<section layout:fragment="content">
<div class="container" style="max-width: 800px; margin: 50px auto;">
<header class="major">
<h2>한국투자증권 API 설정</h2>
<p>보안을 위해 입력하신 키는 DB에 저장되지 않으며, 세션 종료 시 즉시 파기됩니다.</p>
</header>
<form id="kisConfigForm" class="box">
<div class="row gtr-uniform">
<div class="col-12">
<label for="kis_app_key">App Key (실전투자)</label>
<input type="text" id="kis_app_key" placeholder="App Key를 입력하세요" required />
</div>
<div class="col-12">
<label for="kis_app_secret">App Secret</label>
<input type="password" id="kis_app_secret" placeholder="App Secret을 입력하세요" required />
</div>
<div class="col-12">
<label for="kis_account_no">계좌번호</label>
<input type="text" id="kis_account_no" placeholder="'-' 제외 10자리 (예: 1234567801)" maxlength="10" required />
</div>
<div class="col-12">
<ul class="actions fit">
<li><button type="button" id="btn_save_kis" class="button primary fit">연결 및 시작하기</button></li>
</ul>
</div>
</div>
</form>
</div>
<script type="module">
document.getElementById('btn_save_kis').addEventListener('click', async () => {
const data = {
appKey: document.getElementById('kis_app_key').value,
appSecret: document.getElementById('kis_app_secret').value,
accountNo: document.getElementById('kis_account_no').value
};
const res = await Api.request('/api/stock/config', 'POST', data);
if (res.resultCode === 0) {
location.href = "/stock/dashboard.bs";
} else {
UI.showAlert("연결 실패", res.resultMsg);
}
});
</script>
</section>
</html>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<section layout:fragment="content">
<div class="container">
<header class="major">
<h2>나의 투자 대시보드</h2>
</header>
<div class="row">
<div class="col-4 col-12-medium">
<div class="box">
<h3>총 자산</h3>
<h2 id="total_asset">0 원</h2>
<p>수익률: <span id="total_profit_rate">0.00%</span></p>
</div>
</div>
<div class="col-8 col-12-medium">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>종목명</th>
<th>보유수량</th>
<th>매입가</th>
<th>현재가</th>
<th>수익률</th>
</tr>
</thead>
<tbody id="stock_list">
<tr><td colspan="5" class="text-center">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script type="module">
window.addEventListener('DOMContentLoaded', async () => {
try {
const res = await Api.request('/api/stock/balance');
if (res.resultCode === 0) {
renderBalance(res.data);
} else {
location.href = "/stock/config.bs";
}
} catch (e) {
UI.showAlert("오류", "데이터를 가져올 수 없습니다.");
}
});
function renderBalance(data) {
// 자산 및 종목 렌더링 로직
}
</script>
</section>
</html>

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<section layout:fragment="content">
<div class="container">
<header class="major">
<h2>글로벌 시장 지표</h2>
</header>
<div class="row">
</div>
</div>
<script th:inline="javascript">
async function loadMarketData() {
try {
// Api 모듈이 window에 등록될 때까지 기다리거나 체크
if (typeof Api === 'undefined') {
console.error("Api 모듈이 아직 로드되지 않았습니다.");
return;
}
console.log("시장 지표 로드 시작");
const data = await Api.request('/api/stock/market', 'GET');
const kospiEl = document.getElementById('idx_kospi');
if(kospiEl && data.kospi) {
kospiEl.innerText = `${parseFloat(data.kospi).toLocaleString()}`;
renderChange(kospiEl, data.kospi_change);
}
// KOSDAQ 등 나머지 업데이트...
} catch (e) {
console.error("시장 지표 로드 실패", e);
}
}
function renderChange(el, change) {
if (!change) return;
const val = parseFloat(change);
const color = val > 0 ? '#ff4d4d' : (val < 0 ? '#4d4dff' : '#000');
const prefix = val > 0 ? '▲' : (val < 0 ? '' : '');
const span = document.createElement('span');
span.style.color = color;
span.style.fontSize = '0.6em';
span.style.marginLeft = '10px';
span.innerText = `${prefix} ${Math.abs(val)}%`;
el.appendChild(span);
}
// common.js가 로드된 후 실행되도록 보장
window.addEventListener('load', loadMarketData);
</script>
</section>
</html>

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="gameBody(gameTitle, canvasId)" class="game-body-wrapper">
<h1 th:text="${gameTitle}" style="text-align:center; margin-bottom: 15px;">Game Title</h1>
<div class="game-play-box wide" id="game-container"
style="display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 600px; min-width: 320px; margin: 0 auto; background: #fff; box-shadow: 0 4px 10px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden;">
<canvas th:id="${canvasId}" style="display: block; width: 100%; height: auto; flex-shrink: 0;"></canvas>
<div id="ad-slot-container" style="display: block; width: 100%; min-width: 300px; min-height: 280px; background: #fafafa; border-top: 1px solid #eee; text-align: center; overflow: hidden; flex-shrink: 0;">
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const adBox = document.getElementById('ad-slot-container');
function injectAdSense() {
// 중복 주입 방지
if (adBox.querySelector('.adsbygoogle')) return;
// 너비가 너무 작으면(숨겨진 상태 등) 주입하지 않음
if (adBox.clientWidth < 200) return;
console.log("🚀 광고 주입 (너비 확보됨: " + adBox.clientWidth + "px)");
const ins = document.createElement('ins');
ins.className = 'adsbygoogle';
ins.style.display = 'block';
ins.setAttribute('data-ad-client', 'ca-pub-9504446465764716');
ins.setAttribute('data-ad-slot', '5334609005');
ins.setAttribute('data-ad-format', 'auto');
ins.setAttribute('data-full-width-responsive', 'true');
adBox.appendChild(ins);
try {
(adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.error("AdSense Error:", e);
}
}
// 너비 감지 로직 (ResizeObserver)
if ('ResizeObserver' in window) {
const observer = new ResizeObserver(entries => {
for (let entry of entries) {
// 테두리 제외한 내부 너비가 200px 이상일 때만 실행
if (entry.contentRect.width > 200) {
injectAdSense();
observer.disconnect(); // 한 번 넣으면 감시 종료
}
}
});
observer.observe(adBox);
} else {
// 구형 브라우저 폴백
const interval = setInterval(() => {
if (adBox.clientWidth > 200) {
injectAdSense();
clearInterval(interval);
}
}, 500);
}
});
</script>
</div>
</div>
</body>
</html>

View File

@ -15,6 +15,14 @@
<ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li>
<li id="menu_stock">
<a href="#">Stock</a>
<ul>
<li sec:authorize="isAuthenticated()"><a href="/stock/config">API 설정</a></li>
<li sec:authorize="isAuthenticated()"><a href="/stock/dashboard">내 잔고/거래</a></li>
<li><a href="/stock/market">시장 지표</a></li>
</ul>
</li>
<li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>
<li id="menu_drop">
<a href="#">Game</a>

View File

@ -13,6 +13,9 @@
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
<!-- <th:block th:with="userTheme=${user != null ? user.theme : 'default'}">-->
<!-- <link rel="stylesheet" th:href="@{/css/themes/} + ${userTheme} + '.css'" />-->
<!-- </th:block>-->
<script th:inline="javascript">
/*
* [Modified] This object must include all fields from the Post.kt model.
@ -55,8 +58,8 @@
keyword: /*[[${keyword ?: ''}]]*/,
// --- [핵심 추가] ---
token: /*[[${jwtToken}]]*/,
apiBaseUrl : /*[[${apiBaseUrl}]]*/,
userTheme: [[${#authentication.principal != 'anonymousUser' ? user?.theme : null}]],
apiBaseUrl : /*[[${apiBaseUrl}]]*/,
userTheme: [[${#authentication.principal != 'anonymousUser' ? user?.theme : null}]],
};
</script>
</th:block>