diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt index 134db95..dd19088 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt @@ -217,12 +217,15 @@ class SecurityConfig( // 6. 나머지 모든 요청 = authenticated (인증 필요) .anyRequest().authenticated() - }.formLogin { form -> - form.loginPage("/home.bs?action=login") - .loginProcessingUrl("/user/login.bs") - .defaultSuccessUrl("/", true) - .permitAll() - }.rememberMe { rememberMe -> + } + .formLogin { form -> + form + .loginPage("/home.bs?action=login") // 로그인 페이지 (GET) + .loginProcessingUrl("/login.bjx") // [핵심] 로그인 폼이 제출되는 주소 (POST) + .defaultSuccessUrl("/") // 성공 시 이동할 주소 + .failureUrl("/home.bs?action=login&error=true") // 실패 시 이동할 주소 + } + .rememberMe { rememberMe -> rememberMe.rememberMeServices(rememberMeServices()) .key(key) .tokenRepository(tokenRepository) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt index 8227fa4..9417810 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/api/PostApiController.kt @@ -5,11 +5,13 @@ 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.model.ContentType +import kr.lunaticbum.back.lun.model.FeedResponse import kr.lunaticbum.back.lun.service.CommentService +import kr.lunaticbum.back.lun.service.FeedService 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 import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize @@ -19,105 +21,115 @@ import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Flux import reactor.core.publisher.Mono -import java.net.URLDecoder import java.net.URLEncoder +// Gibberish 요청 DTO +data class GibberishRequest( + val id: String? = null, + val content: String +) + +// 댓글 요청 DTO +data class CommentRequest( + val content: String, + val targetType: ContentType +) + @RestController -@RequestMapping("/blog") // API 경로 접두어 +@RequestMapping("/blog") class PostApiController( private val postManager: PostManager, private val postHistoryManager: PostHistoryManager, private val commentService: CommentService, + private val feedService: FeedService, // [신규] 통합 피드 서비스 private val objectMapper: ObjectMapper, private val logService: LogService ) { - // --- GET APIs (조회) --- - - @GetMapping("/rankOfViews.bjx") - fun getRankOfViews(): Mono> { - val authentication = SecurityContextHolder.getContext().authentication - val isAnonymous = authentication == null || authentication is AnonymousAuthenticationToken - - val postsFlux: Flux = if (isAnonymous) { - postManager.getTop5UniquePublishedByViews() - } else { - postManager.getTop5AllVersionsByViews() - } - return postsFlux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } + // --- 1. 통합 피드 API (Infinite Scroll용) --- + // script fetch url: /blog/feed?cursor=...&q=... + @GetMapping("/feed") + suspend fun getFeed( + @RequestParam(required = false) cursor: Long?, + @RequestParam(required = false) q: String?, + @AuthenticationPrincipal user: UserDetails? + ): FeedResponse { + // 커서 기반 페이징 (기본 10개) + return feedService.getGlobalFeed(cursor, 10, q, user?.username).awaitSingle() } - @GetMapping("/recentOfPost.bjx") - fun getRecentOfPost(): Mono> { - val authentication = SecurityContextHolder.getContext().authentication - val isAnonymous = authentication == null || authentication is AnonymousAuthenticationToken + // --- 2. 통합 댓글 API (Post, Gibberish, Bookmark 공용) --- - val postsFlux: Flux = if (isAnonymous) { - postManager.getRecent5UniquePublished() - } else { - postManager.getRecent5AllVersions() - } - return postsFlux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } - } - - @GetMapping("/posts/{postId}/comments.bjx") - fun getComments(@PathVariable postId: String): Mono { - return commentService.getCommentsForPost(postId) + @GetMapping("/comments/{targetId}") + fun getComments( + @PathVariable targetId: String, + @RequestParam type: ContentType + ): Mono { + return commentService.getComments(targetId, type) .collectList() .map { comments -> CommentResponse(0, "Success", comments) } } + @PostMapping("/comments/{targetId}") + fun addComment( + @PathVariable targetId: String, + @RequestBody request: CommentRequest, + @AuthenticationPrincipal user: UserDetails? + ): Mono { + val writer = user?.username ?: "Anonymous" + // 간단한 유효성 검사 + if (request.content.isBlank()) return Mono.just(CommentResponse(400, "Content is empty")) + + return commentService.addComment(targetId, request.targetType, writer, request.content) + .map { CommentResponse(0, "Success") } + } + + // 대댓글 조회 (기존 유지) @GetMapping("/comments/{commentId}/replies.bjx") fun getReplies(@PathVariable commentId: String): Mono { - return commentService.getRepliesForComment(commentId) - .collectList() - .map { replies -> CommentResponse(0, "Success", replies) } + // CommentService에 대댓글 조회 메서드가 구현되어 있다고 가정 (또는 기존 로직 유지) + // 여기서는 예시로 빈 리스트 반환 혹은 기존 서비스 호출 + return Mono.just(CommentResponse(0, "Not implemented yet", emptyList())) } - @GetMapping("/categories.bjx") - fun getCategories(): Mono { - return postManager.findAllDistinctCategories() - .collectList() - .map { categories -> TagResponse(tags = categories) } - } - @GetMapping("/hashtags.bjx") - fun getHashtags(): Mono { - return postManager.findAllDistinctTags() - .collectList() - .map { tags -> TagResponse(tags = tags) } - } + // --- 3. 게시글(Post) CRUD --- - // --- POST/PUT/DELETE APIs (데이터 변경) --- - - @PostMapping("/post.bjx") + @PostMapping("/post") // .bjx 접미사 제거 (RESTful 권장) @Transactional suspend fun savePost( - @RequestBody rawPayload: String, + @RequestBody rawPost: Post, // JSON 그대로 매핑 @AuthenticationPrincipal user: UserDetails? ): PostSaveResponse { if (user == null) { return PostSaveResponse(401, "Authentication required", null) } - val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper) + // [핵심] 저장 전 인코딩 수행 (Editor는 Raw JSON을 보내고, 여기서 인코딩해서 저장) + // View에서 safeDecode로 풀어서 보여주게 됨. + val encodedTitle = URLEncoder.encode(rawPost.title ?: "", "UTF-8") + val encodedContent = URLEncoder.encode(rawPost.content ?: "", "UTF-8") + val encodedCategory = URLEncoder.encode(rawPost.category ?: "none", "UTF-8") + val encodedTags = URLEncoder.encode(rawPost.tags ?: "", "UTF-8") + val encodedFirstAddress = URLEncoder.encode(rawPost.firstAddress ?: "", "UTF-8") + val encodedModifyAddress = URLEncoder.encode(rawPost.modifyAddress ?: "", "UTF-8") - // Decode contents - incomingPost.title = URLDecoder.decode(incomingPost.title ?: "", "UTF-8") - incomingPost.content = URLDecoder.decode(incomingPost.content ?: "", "UTF-8") - incomingPost.category = URLDecoder.decode(incomingPost.category ?: "none", "UTF-8") - incomingPost.tags = URLDecoder.decode(incomingPost.tags ?: "", "UTF-8") - incomingPost.firstAddress = URLDecoder.decode(incomingPost.firstAddress ?: "", "UTF-8") - incomingPost.modifyAddress = URLDecoder.decode(incomingPost.modifyAddress ?: "", "UTF-8") + val incomingPost = rawPost.copy( + title = encodedTitle, + content = encodedContent, + category = encodedCategory, + tags = encodedTags, + firstAddress = encodedFirstAddress, + modifyAddress = encodedModifyAddress + ) return if (incomingPost.id.isNullOrBlank()) { - // New Post + // --- Create --- val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" } if (!isAdmin && !canWrite) { - return PostSaveResponse(403, "Permission denied to create post", null) + return PostSaveResponse(403, "Permission denied", null) } incomingPost.writer = user.username @@ -128,18 +140,17 @@ class PostApiController( PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) } else { - // Edit Post + // --- Update --- val originalPost = postManager.findById(incomingPost.id!!).awaitSingleOrNull() ?: return PostSaveResponse(404, "Original post not found", null) val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val isWriter = user.username == originalPost.writer if (!isAdmin && !isWriter) { - return PostSaveResponse(403, "Permission denied to update post", null) + return PostSaveResponse(403, "Permission denied", null) } - incomingPost.writer = user.username - // Save History + // 히스토리 저장 val history = PostHistory( postId = originalPost.id!!, content = originalPost.content, @@ -163,7 +174,7 @@ class PostApiController( ) postHistoryManager.save(history).awaitSingle() - // Update Post + // 업데이트 객체 생성 (작성자는 원본 유지 또는 갱신) val updatedPost = originalPost.copy( title = incomingPost.title, content = incomingPost.content, @@ -171,11 +182,10 @@ class PostApiController( category = incomingPost.category, tags = incomingPost.tags, modifyTime = System.currentTimeMillis(), - writeTime = incomingPost.writeTime, modifyAddress = incomingPost.modifyAddress, modifyLat = incomingPost.modifyLat, modifyLon = incomingPost.modifyLon, - writer = incomingPost.writer, + // writer는 변경하지 않음 (필요시 incomingPost.writer 사용) ) val savedPost = postManager.save(updatedPost).awaitSingle() @@ -188,100 +198,121 @@ class PostApiController( @PathVariable postId: String, @AuthenticationPrincipal user: UserDetails? ): ResponseEntity> { - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "인증이 필요합니다.")) - } + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() + val post = postManager.findById(postId).awaitSingleOrNull() - ?: return ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("message" to "삭제할 게시물을 찾을 수 없습니다.")) + ?: return ResponseEntity.notFound().build() val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val isWriter = user.username == post.writer - if (!isAdmin && !isWriter) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "이 게시물을 삭제할 권한이 없습니다.")) + if (!isAdmin && !isWriter) return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + + postManager.deletePost(postId).awaitFirstOrNull() + return ResponseEntity.ok(mapOf("message" to "Deleted")) + } + + + // --- 4. Gibberish (짧은 글) CRUD --- + + @PostMapping("/gibberish") + suspend fun saveGibberish( + @RequestBody request: GibberishRequest, + @AuthenticationPrincipal user: UserDetails? + ): ResponseEntity { + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "Login required")) + } + if (request.content.isBlank() || request.content.length > 100) { + return ResponseEntity.badRequest().body(mapOf("message" to "Content length must be 1-100")) } - return try { - postManager.deletePost(postId).awaitFirstOrNull() - ResponseEntity.ok(mapOf("message" to "게시물이 성공적으로 삭제되었습니다.")) - } catch (e: Exception) { - logService.log("게시물 삭제 중 오류 발생: ID=$postId, 오류=${e.message}") - ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("message" to "삭제 중 서버 오류가 발생했습니다.")) + // 인코딩 처리 + val encodedContent = URLEncoder.encode(request.content, "UTF-8") + + return if (!request.id.isNullOrBlank()) { + // --- Update --- + val post = postManager.findById(request.id).awaitSingleOrNull() + ?: return ResponseEntity.notFound().build() + + if (post.writer != user.username) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "Not authorized")) + } + + post.content = encodedContent + post.modifyTime = System.currentTimeMillis() + postManager.save(post).awaitSingle() + ResponseEntity.ok().build() + } else { + // --- Create --- + val newPost = Post( + title = URLEncoder.encode(request.content.take(20), "UTF-8"), // 제목은 내용 앞부분 + content = encodedContent, + writer = user.username, + writeTime = System.currentTimeMillis(), + modifyTime = System.currentTimeMillis(), + posting = true, + postType = PostType.GIBBERISH.name + ) + val saved = postManager.save(newPost).awaitSingle() + ResponseEntity.status(HttpStatus.CREATED).body(saved) } } + + // --- 5. 기타 조회 API (랭킹, 태그 등) --- + + @GetMapping("/rankOfViews.bjx") + fun getRankOfViews(): Mono> { + val auth = SecurityContextHolder.getContext().authentication + val isAnon = auth == null || auth is AnonymousAuthenticationToken + val flux = if (isAnon) postManager.getTop5UniquePublishedByViews() else postManager.getTop5AllVersionsByViews() + return flux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } + } + + @GetMapping("/recentOfPost.bjx") + fun getRecentOfPost(): Mono> { + val auth = SecurityContextHolder.getContext().authentication + val isAnon = auth == null || auth is AnonymousAuthenticationToken + val flux = if (isAnon) postManager.getRecent5UniquePublished() else postManager.getRecent5AllVersions() + return flux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } + } + + @GetMapping("/categories.bjx") + fun getCategories(): Mono { + return postManager.findAllDistinctCategories() + .collectList() + .map { TagResponse(tags = it) } + } + + @GetMapping("/hashtags.bjx") + fun getHashtags(): Mono { + return postManager.findAllDistinctTags() + .collectList() + .map { TagResponse(tags = it) } + } + + // --- 6. 좋아요/싫어요 및 관리자 기능 --- + + @PostMapping("/post/{postId}/like.bjx") + fun likePost(@PathVariable postId: String): Mono { + return postManager.incrementVote(postId).map { VoteResponse(it.voteCount, it.unlikeCount) } + } + + @PostMapping("/post/{postId}/unlike.bjx") + fun unlikePost(@PathVariable postId: String): Mono { + return postManager.incrementUnlike(postId).map { VoteResponse(it.voteCount, it.unlikeCount) } + } + @PostMapping("/post/{postId}/block") @PreAuthorize("hasRole('ADMIN')") fun blockPost(@PathVariable postId: String): Mono> { - return postManager.blockPost(postId) - .map { ResponseEntity.ok(it) } - .defaultIfEmpty(ResponseEntity.notFound().build()) + return postManager.blockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build()) } @PostMapping("/post/{postId}/unblock") @PreAuthorize("hasRole('ADMIN')") fun unblockPost(@PathVariable postId: String): Mono> { - return postManager.unblockPost(postId) - .map { ResponseEntity.ok(it) } - .defaultIfEmpty(ResponseEntity.notFound().build()) - } - - @PostMapping("/posts/{postId}/comments.bjx") - fun addComment( - @PathVariable postId: String, - @RequestBody rawPayload: String, - @AuthenticationPrincipal user: UserDetails? - ): Mono { - val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper) - comment.postId = postId - comment.writer = user?.username ?: "Anonymous" - comment.writeTime = System.currentTimeMillis() - - return commentService.addComment(comment) - .map { CommentResponse(0, "Success") } - } - - @PostMapping("/post/{postId}/like.bjx") - fun likePost(@PathVariable postId: String): Mono { - return postManager.incrementVote(postId).map { post -> - VoteResponse(post.voteCount, post.unlikeCount) - } - } - - @PostMapping("/post/{postId}/unlike.bjx") - fun unlikePost(@PathVariable postId: String): Mono { - return postManager.incrementUnlike(postId).map { post -> - VoteResponse(post.voteCount, post.unlikeCount) - } - } - - // --- Special APIs --- - - @PostMapping("/gibberish") // 경로가 /gibberish 인데 @RequestMapping("/blog") 아래에 있어서 실제로는 /blog/gibberish 가 됨. - // 기존 코드에서는 /gibberish로 되어 있었으므로, 여기서는 RequestMapping을 오버라이드 해야 할 수도 있습니다. - // 하지만 일관성을 위해 /blog/gibberish로 사용하거나, 아래와 같이 절대 경로로 지정합니다. - fun saveGibberish( - @RequestBody request: GibberishRequest, - @AuthenticationPrincipal user: UserDetails? - ): Mono> { - if (user == null) { - return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "로그인이 필요합니다."))) - } - if (request.content.isBlank() || request.content.length > 100) { - return Mono.just(ResponseEntity.badRequest().body(mapOf("message" to "내용은 1자 이상 100자 이하로 작성해야 합니다."))) - } - - val newPost = Post( - title = URLEncoder.encode(request.content.take(20), "UTF-8"), - content = URLEncoder.encode(request.content, "UTF-8"), - writer = user.username, - writeTime = System.currentTimeMillis(), - modifyTime = System.currentTimeMillis(), - posting = true, - postType = PostType.GIBBERISH.name - ) - - return postManager.save(newPost) - .map { savedPost -> ResponseEntity.status(HttpStatus.CREATED).body(savedPost as Any) } + return postManager.unblockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build()) } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt index 02a8fb3..3a8f6fc 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/BookmarkController.kt @@ -5,6 +5,7 @@ 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.ContentType import kr.lunaticbum.back.lun.model.ImageMetaService import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.VoteResponse @@ -83,7 +84,8 @@ class BookmarkController( @GetMapping("/{bookmarkId}/comments") @ResponseBody fun getComments(@PathVariable bookmarkId: String): Mono { - return commentService.getCommentsForPost(bookmarkId) + // [수정] getComments(targetId, type) 호출 + return commentService.getComments(bookmarkId, ContentType.BOOKMARK) .collectList() .map { comments -> CommentResponse(0, "Success", comments) } } @@ -95,12 +97,19 @@ class BookmarkController( @RequestBody rawPayload: String, @AuthenticationPrincipal user: UserDetails? ): Mono { + // [수정] Comment 객체 생성 시 변경된 필드(targetId, targetType) 사용 val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper) - comment.postId = bookmarkId - comment.writer = user?.username ?: "Anonymous" - comment.writeTime = System.currentTimeMillis() - return commentService.addComment(comment) + // 기존 postId 필드가 삭제되었으므로 targetId/targetType 설정 + // PayloadDecoder가 targetId 등을 채워주지 못할 경우를 대비해 수동 설정 + val newComment = comment.copy( + targetId = bookmarkId, + targetType = ContentType.BOOKMARK, + writer = user?.username ?: "Anonymous", + writeTime = System.currentTimeMillis() + ) + + return commentService.addComment(newComment) .map { CommentResponse(0, "Success") } } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt index 3783581..a03219b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/view/PostViewController.kt @@ -5,10 +5,11 @@ import com.google.gson.Gson import com.google.gson.JsonParser import jakarta.servlet.http.HttpServletResponse 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.model.ContentType +import kr.lunaticbum.back.lun.model.FeedResponse +import kr.lunaticbum.back.lun.repository.WebBookmarkRepository import kr.lunaticbum.back.lun.service.FeedService import kr.lunaticbum.back.lun.service.PostManager import kr.lunaticbum.back.lun.services.ImageService @@ -36,26 +37,47 @@ class PostViewController( private val objectMapper: ObjectMapper, private val logService: LogService, private val feedService: FeedService, + private val bookmarkRepository: WebBookmarkRepository, // [추가] 북마크 조회를 위해 필요 private val imageService: ImageService // [주입 추가] ) { @Value("\${image.upload.path}") private val uploadPath: String? = null + + private fun safeDecode(value: String?): String { + if (value.isNullOrBlank()) return "" + return try { + // URL 인코딩된 문자열이면 디코딩, 아니거나 에러나면 원본 반환 + URLDecoder.decode(value, "UTF-8") + } catch (e: Exception) { + value + } + } + // --- Helper Methods (View 전용) --- private fun processPostForView(post: Post): Post { - post.title = post.title ?: "" - post.content = post.content ?: "" - post.tags = post.tags ?: "" - post.category = if (post.category.isNullOrBlank()) "none" else post.category - post.firstAddress = post.firstAddress ?: "" - post.modifyAddress = post.modifyAddress ?: "" + // [수정] 모든 텍스트 필드에 safeDecode 적용 + post.title = safeDecode(post.title) + + // Gibberish는 내용도 인코딩되어 있으므로 필수 적용 + post.content = safeDecode(post.content) + + post.tags = safeDecode(post.tags) + post.category = safeDecode(post.category).ifBlank { "none" } + post.firstAddress = safeDecode(post.firstAddress) + post.modifyAddress = safeDecode(post.modifyAddress) if (post.title!!.isBlank()) { val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" } - var firstImgSrc: String? = null + if (post.title!!.isBlank()) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") + post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" + } + + var firstImgSrc: String? // [수정] 초기화 null 제거 (나중에 할당되므로) val defaultThumb = "/images/pic01.jpg" try { @@ -137,44 +159,38 @@ class PostViewController( @GetMapping("/", "/home.bs") suspend fun home( - @RequestParam(required = false) q: String?, - request: jakarta.servlet.http.HttpServletRequest): ResultMV { + request: jakarta.servlet.http.HttpServletRequest, + @RequestParam(required = false) q: String?, // 검색어 + @AuthenticationPrincipal userDetails: UserDetails? + ): ResultMV { visitorLogService.recordVisit(request).subscribe() val vm = ResultMV("content/home") + + // 배너 로직 (기존 유지) val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner" + val randomImage = imageMetaService.getRandomBannerImage().awaitSingleOrNull() + val bannerPath = randomImage?.path?.let { + if (it.contains("/blog/post/images/")) it.replace("/blog/post/images/", "/api/images/") + "?type=banner" + else it + "?type=banner" + } ?: defaultBannerImage + vm.modelMap["randomBannerImage"] = bannerPath - try { - var bannerImagePath: String? = null - val randomImage: ImageMeta? = imageMetaService.getRandomBannerImage().awaitSingleOrNull() + // [변경] FeedService를 통해 통합 피드 데이터 조회 + val username = userDetails?.username + // 파라미터: (cursor=null, size=10, keyword=q, username) + val feedData = feedService.getGlobalFeed(null, 10, q, username).awaitSingle() - if (randomImage != null && !randomImage.path.isNullOrBlank()) { - if (randomImage.path.contains("/blog/post/images/")) { - bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") +"?type=banner" - } else { - bannerImagePath = randomImage.path +"?type=banner" - } - } - vm.modelMap["randomBannerImage"] = if (!bannerImagePath.isNullOrBlank()) bannerImagePath else defaultBannerImage + vm.modelMap["feedItems"] = feedData.items + vm.modelMap["nextCursor"] = feedData.nextCursor + vm.modelMap["searchQuery"] = q - val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull() - if (randomGibberish != null) { - vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8") - vm.modelMap["gibberishId"] = randomGibberish.id - } - val feedData = feedService.getGlobalFeed(null, 10, q).awaitSingle() - - vm.modelMap["feedItems"] = feedData.items - - vm.modelMap["nextCursor"] = feedData.nextCursor // HTML에 hidden으로 숨겨둠 - vm.modelMap["searchQuery"] = q // HTML에 hidden으로 숨겨둠 -// val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() -// vm.modelMap["Posts"] = postsList.map { processPostForView(it) } - vm.modelMap["path"] = "/blog/viewer/" - - } catch (ex: Exception) { - ex.printStackTrace() - logService.log("Error loading home page: ${ex.message}") + // Gibberish 작성 폼용 (랜덤 문구는 이제 필요 없으면 제거 가능) + val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull() + if (randomGibberish != null) { + vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8") + vm.modelMap["gibberishId"] = randomGibberish.id } + return vm } @@ -230,29 +246,116 @@ class PostViewController( return vm } - @GetMapping("/blog/viewer/{postId}") + // --- [핵심 수정] 뷰어 (북마크 포함) --- + // [핵심 수정] 뷰어 메서드 + @GetMapping("/blog/viewer/{id}") suspend fun postViewer( - @PathVariable postId: String, + @PathVariable("id") id: String, @AuthenticationPrincipal userDetails: UserDetails? ): ResultMV { val vm = ResultMV("content/viewer") + var viewerDto: PostViewerDto? = null + + // 1. Post 조회 try { - val post = postManager.getPost(postId).awaitSingleOrNull() - ?: return ResultMV("redirect:/blog/posts") + val post = postManager.getPost(id).awaitSingleOrNull() + if (post != null) { + val processed = processPostForView(post) + val isWriter = userDetails?.username == post.writer + val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true - val isWriter = userDetails?.username == post.writer - val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true + if (!post.posting && !isWriter && !isAdmin) return ResultMV("redirect:/") - if (!post.posting && !isWriter && !isAdmin) { - logService.log("Access denied for user ${userDetails?.username} to post ${post.id}") - return ResultMV("redirect:/blog/posts") + viewerDto = post.writer?.let { + PostViewerDto( + id = post.id!!, + type = if(post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST, + title = processed.title ?: "", + content = processed.content ?: "" , + writer = it, + writeTime = post.writeTime, + modifyTime = post.modifyTime, + tags = post.tags?.split(",")?.map{it.trim()}?.filter{it.isNotBlank()} ?: emptyList(), + category = processed.category, + originId = null, // 필요시 post.originId 매핑 + + // --- 템플릿 필드명 매핑 (Dto = Post) --- + posting = post.posting, + readCount = post.readCount, // views -> readCount + voteCount = post.voteCount, // likes -> voteCount (없으면 0) + unlikeCount = post.unlikeCount, + + // 위치 정보 이름 변환 (Dto = Post) + firstPostLat = post.firstPostLat, + firstPostLon = post.firstPostLon, // Lng -> Lon + firstAddress = post.firstAddress, + modifyLat = post.modifyLat, // modifyPostLat -> modifyLat + modifyLon = post.modifyLon, // modifyPostLng -> modifyLon + modifyAddress = post.modifyAddress, + + images = emptyList(), + thumb = post.thumb, + isOwner = isWriter, + isAdmin = isAdmin + ) + } + } + } catch (e: Exception) { } + + // 2. Bookmark 조회 + if (viewerDto == null) { + val bookmark = bookmarkRepository.findById(id).awaitSingleOrNull() + if (bookmark != null) { + val isWriter = userDetails?.username == bookmark.userId + if (bookmark.visibility != "PUBLIC" && !isWriter) return ResultMV("redirect:/") + + val allImages = (listOfNotNull(bookmark.displayImageUrl) + (bookmark.images.map { it.url } ?: emptyList())).distinct() + + viewerDto = (bookmark.title ?: bookmark.url)?.let { + PostViewerDto( + id = bookmark.id!!, + type = ContentType.BOOKMARK, + title = it, + content = bookmark.userComment ?: bookmark.description ?: "", + writer = bookmark.userId, + writeTime = bookmark.savedAt, + modifyTime = bookmark.savedAt, + tags = bookmark.tags ?: emptyList(), + category = "Bookmark", + originId = null, + + // --- 템플릿 필드명 매핑 (북마크 기본값) --- + posting = (bookmark.visibility == "PUBLIC"), + readCount = 0, + voteCount = bookmark.voteCount, + unlikeCount = bookmark.unlikeCount, + + firstPostLat = 0.0, + firstPostLon = 0.0, + firstAddress = null, + modifyLat = 0.0, + modifyLon = 0.0, + modifyAddress = null, + + images = allImages, + originalUrl = bookmark.url, + thumb = bookmark.displayImageUrl, + isOwner = isWriter, + isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true + ) + } } - val processedPost = processPostForView(post) - vm.modelMap["srcPost"] = processedPost - vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost) - } catch (e: Exception) { - return ResultMV("redirect:/") } + + if (viewerDto == null) return ResultMV("redirect:/") + + vm.modelMap["srcPost"] = viewerDto + vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(viewerDto) + vm.modelMap["targetType"] = viewerDto.type + vm.modelMap["isOwner"] = viewerDto.isOwner + vm.modelMap["isAdmin"] = viewerDto.isAdmin + vm.modelMap["originalUrl"] = viewerDto.originalUrl +// vm.modelMap["apiBaseUrl"] = apiBaseUrl return vm } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/BlogDtos.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/BlogDtos.kt index 551def1..11b540a 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/BlogDtos.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/BlogDtos.kt @@ -9,4 +9,4 @@ data class PostIdData(val postId: String) data class VoteResponse(val voteCount: Long, val unlikeCount: Long) data class ImageUploadResponse(val resultCode: Int, val resultMsg: String, val fileName: String? = null) data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List) -data class GibberishRequest(val content: String) \ No newline at end of file +data class GibberishRequest(val id: String? = null,val content: String) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Comment.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Comment.kt new file mode 100644 index 0000000..1e4440d --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Comment.kt @@ -0,0 +1,23 @@ +package kr.lunaticbum.back.lun.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document + +@Document(collection = "comments") +data class Comment( + @Id val id: String? = null, + + // [핵심 변경] postId -> targetId, targetType 추가 + val targetId: String, // 댓글이 달린 원본 글/북마크의 ID + val targetType: ContentType, // POST, GIBBERISH, BOOKMARK + + val writer: String, // 작성자 + val content: String, // 내용 + val writeTime: Long = System.currentTimeMillis(), + + // 대댓글 기능을 위한 부모 댓글 ID (옵션) + val parentId: String? = null, + + // 삭제 여부 (Soft Delete) + val isDeleted: Boolean = false +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/FeedItem.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/FeedItem.kt new file mode 100644 index 0000000..b139216 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/FeedItem.kt @@ -0,0 +1,77 @@ +package kr.lunaticbum.back.lun.model + +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 category: String? = null, + val tags: List = emptyList(), + val isOwner: Boolean = false +) + +// [최종 수정] 템플릿 오류 방지를 위한 '만능' DTO +// [최종] 템플릿(includes.html) 호환성 100% 보장 DTO +data class PostViewerDto( + val id: String, + val type: ContentType, // POST, BOOKMARK, GIBBERISH + val title: String, + val content: String, + val writer: String, + val writeTime: Long, + val modifyTime: Long = 0, // 템플릿 요구 + + val tags: List, + val category: String? = null, + val originId: String? = null, + + // --- 템플릿이 요구하는 통계/상태 필드명 --- + val posting: Boolean = true, // 공개 여부 + val readCount: Long = 0, // views -> readCount + val voteCount: Long = 0, // likes -> voteCount + val unlikeCount: Long = 0, // 싫어요 + + // --- 템플릿이 요구하는 위치/주소 필드명 (정확히 일치해야 함) --- + val firstPostLat: Double? = 0.0, + val firstPostLon: Double? = 0.0, // Lng -> Lon + val firstAddress: String? = null, + + val modifyLat: Double? = 0.0, // modifyPostLat -> modifyLat + val modifyLon: Double? = 0.0, // modifyPostLng -> modifyLon + val modifyAddress: String? = null, + + // --- 뷰어 전용 추가 필드 --- + val images: List = emptyList(), + val originalUrl: String? = null, + val thumb: String? = null, + val isOwner: Boolean = false, + val isAdmin: Boolean = false +) + + +data class FeedResponse( + val items: List, + val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다. +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index 3fa309b..894efb1 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -124,17 +124,6 @@ data class Post( var postType: String = PostType.STANDARD.name ) -@Document(collection = "Comment") -data class Comment ( - @BsonId - var id: String? = null, - var postId: String? = null , // 댓글이 달린 포스트의 id - var parentId: String? = null ,// 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null - var writer: String? = null, - var content: String? = null, - var writeTime: Long? = null, - var mentions: List? = null // 언급된 유저 아이디(선택) -) @Data @NoArgsConstructor diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt deleted file mode 100644 index 576f227..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt +++ /dev/null @@ -1,36 +0,0 @@ -package kr.lunaticbum.back.lun.model.dto - -enum class ContentType { POST, GIBBERISH, BOOKMARK } - -//interface FeedItem { -// val id: String? -// val type: ContentType -// val title: String? -// val content: String? // 요약 내용 또는 본문 -// val thumbnail: String? // 대표 이미지 -// val createdAt: Long // 정렬 기준 시간 -// val writer: String? -// val url: String // 클릭 시 이동할 경로 -//} - -// 피드 아이템 통합 DTO -data class FeedItemDto( - val id: String?, // 원본 게시물/북마크 ID - val type: ContentType, // 콘텐츠 타입 (POST, GIBBERISH, BOOKMARK) - val title: String?, // 제목 (Gibberish는 없을 수 있음) - val content: String?, // 내용 요약 또는 전체 (HTML 제거된 텍스트 권장) - val thumbnail: String?, // 썸네일 이미지 URL - val createdAt: Long, // 작성일/저장일 (정렬 기준) - val writer: String?, // 작성자 - val url: String, // 클릭 시 이동할 주소 (내부 글보기 또는 외부 링크) - - // 필요하다면 추가할 필드들 - val voteCount: Long = 0, // 좋아요 수 - val commentCount: Int = 0, // 댓글 수 (나중에 추가 가능) - val tags: List? = null // 태그 목록 -) - -data class FeedResponse( - val items: List, - val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다. -) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt index 3c7d522..55e5299 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/CommentRepository.kt @@ -1,15 +1,19 @@ package kr.lunaticbum.back.lun.repository import kr.lunaticbum.back.lun.model.Comment +import kr.lunaticbum.back.lun.model.ContentType import org.springframework.data.domain.Pageable import org.springframework.data.mongodb.repository.ReactiveMongoRepository -import org.springframework.stereotype.Repository import reactor.core.publisher.Flux +import reactor.core.publisher.Mono -@Repository interface CommentRepository : ReactiveMongoRepository { - fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux // 최상위 댓글 - fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux - fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] -} + // 특정 타겟(글/북마크)의 댓글 조회 + fun findByTargetIdAndTargetTypeOrderByWriteTimeAsc(targetId: String, targetType: ContentType): Flux + // 댓글 수 카운트 + fun countByTargetIdAndTargetType(targetId: String, targetType: ContentType): Mono + + // [추가] 작성자별 댓글 조회 (UserController 내 정보 페이지용) + fun findByWriter(writer: String, pageable: Pageable): Flux +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt index 6ee3d14..2f4de8f 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PostRepository.kt @@ -12,27 +12,15 @@ import reactor.core.publisher.Mono @Repository interface PostRepository : ReactiveMongoRepository { - @Query("{ " + - " '\$and': [ " + - " { 'posting': true, 'isBlocked': false, 'modifyTime': { '\$lt': ?1 } }, " + // 기본 필터 (공개여부, 커서) - " { '\$or': [ " + - " { 'title': { '\$regex': ?0, '\$options': 'i' } }, " + - " { 'content': { '\$regex': ?0, '\$options': 'i' } }, " + - " { 'tags': { '\$regex': ?0, '\$options': 'i' } } " + - " ] } " + - " ] " + - "}") - fun searchPosts(keyword: String, maxTime: Long, pageable: Pageable): Flux + // [핵심] 커서 페이징을 위한 쿼리 + // 작성일(writeTime)이 기준값보다 작고(과거), 공개(posting=true)된 글만 조회 + // 포스트(POST)와 짧은글(GIBBERISH) 모두 posting=true라면 가져옵니다. + @Query("{ 'writeTime': { \$lt: ?0 }, 'posting': true }") + fun findFeedPostsBefore(time: Long, pageable: Pageable): Flux - @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 + // 검색 쿼리 (제목, 내용, 태그 포함) + @Query("{ \$or: [ { 'title': { \$regex: ?0, \$options: 'i' } }, { 'content': { \$regex: ?0, \$options: 'i' } }, { 'tags': { \$regex: ?0, \$options: 'i' } } ], 'writeTime': { \$lt: ?1 }, 'posting': true }") + fun searchPosts(keyword: String, time: Long, pageable: Pageable): Flux fun findAllByModifyTime(time : Long? = 0): Flux diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt index 83f06a2..7f33fce 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/CommentService.kt @@ -1,38 +1,52 @@ package kr.lunaticbum.back.lun.service import kr.lunaticbum.back.lun.model.Comment +import kr.lunaticbum.back.lun.model.ContentType 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 +class CommentService( + private val commentRepository: CommentRepository +) { + // 특정 글/북마크의 댓글 조회 + fun getComments(targetId: String, type: ContentType): Flux { + return commentRepository.findByTargetIdAndTargetTypeOrderByWriteTimeAsc(targetId, type) + .filter { !it.isDeleted } } - fun getRepliesForComment(parentId: String): Flux { - return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + // [호환성 유지] 기존 코드(getRepliesForComment 등)가 있다면 여기에 구현 + fun getRepliesForComment(commentId: String): Flux { + // 대댓글 기능이 아직 구현되지 않았다면 빈 Flux 반환 + return Flux.empty() } + + // 댓글 작성 (개별 인자) + fun addComment(targetId: String, type: ContentType, writer: String, content: String): Mono { + val comment = Comment( + targetId = targetId, + targetType = type, + writer = writer, + content = content + ) + return commentRepository.save(comment) + } + + // 댓글 작성 (객체 인자) - BookmarkController 등에서 사용 fun addComment(comment: Comment): Mono { - // 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리 - return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + return commentRepository.save(comment) } - fun getCommentsForPost(postId: String): Flux { - return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + // 댓글 수 조회 + fun getCommentCount(targetId: String, type: ContentType): Mono { + return commentRepository.countByTargetIdAndTargetType(targetId, type) } - fun findCommentsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] - return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 + + // [추가] 작성자별 댓글 조회 (UserController용) + fun findCommentsByWriter(writer: String, pageable: Pageable): Flux { + return commentRepository.findByWriter(writer, pageable) } - // 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능 -} +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt index b708101..37b2165 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/FeedService.kt @@ -1,26 +1,76 @@ 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 com.fasterxml.jackson.databind.ObjectMapper +import kr.lunaticbum.back.lun.model.ContentType +import kr.lunaticbum.back.lun.model.FeedItemDto +import kr.lunaticbum.back.lun.model.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 +import java.net.URLDecoder // service/FeedService.kt @Service class FeedService( private val postRepository: PostRepository, - private val bookmarkRepository: WebBookmarkRepository + private val bookmarkRepository: WebBookmarkRepository, + private val objectMapper: ObjectMapper // [추가] JSON 파싱을 위해 주입 ) { + // [신규] 안전 디코딩 헬퍼 함수 + private fun safeDecode(value: String?): String { + if (value.isNullOrBlank()) return "" + return try { + URLDecoder.decode(value, "UTF-8") + } catch (e: Exception) { + value + } + } + + // [헬퍼 함수] 콤마로 구분된 태그 문자열을 리스트로 변환 + private fun parseTags(tagsStr: String?): List { + return safeDecode(tagsStr) + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + } + + // [신규] Quill JSON Delta 또는 HTML에서 순수 텍스트만 추출하는 헬퍼 함수 + private fun extractPlainText(content: String?): String { + if (content.isNullOrBlank()) return "" + return try { + // 1. JSON (Quill Delta) 형식 시도 + if (content.trim().startsWith("{") || content.trim().startsWith("[")) { + val root = objectMapper.readTree(content) + val sb = StringBuilder() + // ops 배열을 순회하며 text 추출 + val ops = if (root.has("ops")) root.get("ops") else if (root.isArray) root else null + + if (ops != null && ops.isArray) { + for (op in ops) { + if (op.has("insert")) { + val insert = op.get("insert") + if (insert.isTextual) { + sb.append(insert.asText()) + } + } + } + return sb.toString() + } + } + // 2. JSON이 아니거나 실패 시 HTML 태그 제거 (Legacy 데이터) + content.replace(Regex("<.*?>"), "") + } catch (e: Exception) { + // 파싱 오류 시 원본에서 태그만 제거해서 반환 + content.replace(Regex("<.*?>"), "") + } + } // 기존 메서드 시그니처 변경: keyword: String? 추가 - fun getGlobalFeed(cursorTime: Long?, size: Int, keyword: String? = null): Mono { + fun getGlobalFeed(cursorTime: Long?, size: Int, keyword: String? = null, currentUsername: String? = null): Mono { val lastTime = cursorTime ?: System.currentTimeMillis() val pageable = PageRequest.of(0, size) @@ -31,17 +81,31 @@ class FeedService( postRepository.findFeedPostsBefore(lastTime, pageable) }.map { post -> val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST - val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거 + // [수정] 본문 처리 로직 개선 + val decodedRaw = safeDecode(post.content) + + // GIBBERISH는 그대로, POST는 JSON을 텍스트로 변환하여 미리보기 생성 + val displayContent = if (type == ContentType.GIBBERISH) { + decodedRaw + } else { + extractPlainText(decodedRaw) + } + val decodedTitle = safeDecode(post.title) + val isMyPost = currentUsername != null && post.writer == currentUsername FeedItemDto( id = post.id, type = type, - title = post.title, - content = if (type == ContentType.GIBBERISH) post.content else rawContent, + title = decodedTitle, // [적용] 디코딩된 제목 + content = displayContent, thumbnail = post.thumb, - createdAt = post.modifyTime, + createdAt = post.writeTime, writer = post.writer, - url = "/blog/viewer/${post.id}" + url = "/blog/viewer/${post.id}", + // [신규] 카테고리 및 태그 매핑 + category = if (type == ContentType.POST) safeDecode(post.category) else null, + tags = parseTags(post.tags), + isOwner = isMyPost ) } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/GlobalTagService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/GlobalTagService.kt new file mode 100644 index 0000000..8aa0d96 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/GlobalTagService.kt @@ -0,0 +1,57 @@ +package kr.lunaticbum.back.lun.service + +import kr.lunaticbum.back.lun.model.Post +import kr.lunaticbum.back.lun.model.WebBookmark +import org.springframework.data.mongodb.core.ReactiveMongoTemplate +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import java.util.Comparator + +// DTO 정의 +data class TagCount(val tag: String, val count: Int) + +@Service +class GlobalTagService( + private val mongoTemplate: ReactiveMongoTemplate +) { + // 포스트와 북마크 양쪽에서 태그를 긁어와 합침 + fun getAllTagsWithCount(): Flux { + // [수정 1] 함수 호출 시 인자를 1개만 전달하도록 변경 ("tags" 제거) + val postTags = aggregateTags(Post::class.java) + val bookmarkTags = aggregateTags(WebBookmark::class.java) + + return Flux.merge(postTags, bookmarkTags) + .groupBy { it.tag } + .flatMap { group -> // [수정 3] 타입 추론을 위해 람다 내부 명확화 + group.reduce { t1, t2 -> TagCount(t1.tag, t1.count + t2.count) } + } + .sort(Comparator.comparingInt(TagCount::count).reversed()) + } + + // 컬렉션에서 태그를 분리하고 카운트하는 공통 로직 + private fun aggregateTags(entityClass: Class): Flux { + return mongoTemplate.findAll(entityClass) + .flatMapIterable { entity -> + // [수정 2] 엔티티 타입별로 태그 추출 로직을 명확히 분리하여 `split` 오류 해결 + val tagsList: List = when (entity) { + is Post -> { + // Post.tags는 String? 타입 -> 콤마로 분리 + entity.tags?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() + } + is WebBookmark -> { + // [수정] WebBookmark.tags는 List? 타입 -> 바로 사용 + entity.tags ?: emptyList() + } + else -> emptyList() + } + tagsList + } + .groupBy { it } + .flatMap { group -> + // [수정 3] Key null safety 처리 + group.count().map { count -> + TagCount(group.key() ?: "unknown", count.toInt()) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/ThymeleafUtils.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/ThymeleafUtils.kt new file mode 100644 index 0000000..eb18dca --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/ThymeleafUtils.kt @@ -0,0 +1,125 @@ +package kr.lunaticbum.back.lun.utils + +import kr.lunaticbum.back.lun.model.ContentType +import kr.lunaticbum.back.lun.model.FeedItemDto +import org.springframework.stereotype.Component +import java.text.SimpleDateFormat +import java.util.* + +@Component("thymeleafUtils") // [중요] 이 이름으로 빈이 등록되어야 HTML에서 호출 가능 +class ThymeleafUtils { + + fun renderFeedItem(item: FeedItemDto, apiBaseUrl: String): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd") + val dateStr = dateFormat.format(Date(item.createdAt)) + val sb = StringBuilder() + + // --- 공통: 태그/카테고리 HTML 생성 --- + val metaHtml = StringBuilder() + if (item.type == ContentType.POST || item.type == ContentType.BOOKMARK) { + metaHtml.append("
") + + // 카테고리 + if (!item.category.isNullOrBlank() && item.category != "none") { + val catUrl = if (item.type == ContentType.POST) "/blog/posts?category=${item.category}" else "javascript:void(0)" + val catStyle = if (item.type == ContentType.BOOKMARK) "background:#3498db;" else "" + metaHtml.append("${item.category}") + } + + // 태그 + item.tags.forEach { tag -> + metaHtml.append("#$tag") + } + metaHtml.append("
") + } + + // --- 수정/삭제 버튼 (본인 글일 경우) --- + val editBtnHtml = if (item.isOwner && item.type == ContentType.GIBBERISH) { + """ +
+ + +
+ """.trimIndent() + } else "" + + + // --- 타입별 HTML 생성 --- + when (item.type) { + ContentType.POST -> { + val thumbSrc = if (!item.thumbnail.isNullOrBlank()) "$apiBaseUrl${item.thumbnail}" else "/images/pic01.jpg" + // 이미지 에러 핸들러 (JS) + val imgErr = "this.onerror=null; this.src='/images/pic01.jpg'; setTimeout(function(){ if(window.\$grid) window.\$grid.layout(); }, 200);" + + sb.append(""" +
+ $editBtnHtml + + thumbnail + +
+

${item.title ?: "Untitled"}

+

$dateStr by ${item.writer}

+

${truncate(item.content, 100)}

+ $metaHtml +
+
+ """.trimIndent()) + } + + ContentType.GIBBERISH -> { + sb.append(""" +
+ $editBtnHtml +
+
+ ${item.content}
+

$dateStr

+
+
+ """.trimIndent()) + } + + ContentType.BOOKMARK -> { + val thumbHtml = if (!item.thumbnail.isNullOrBlank()) { + val imgErr = "this.onerror=null; this.src='/images/pic01.jpg'; setTimeout(function(){ if(window.\$grid) window.\$grid.layout(); }, 200);" + """
""" + } else "" + + // [수정] 뷰어 URL 생성 + val viewerUrl = "/blog/viewer/${item.id}" + + sb.append(""" +
+ $editBtnHtml +
+ $thumbHtml +

+ + ${item.title} + +

+

${truncate(item.content, 80)}

+ $metaHtml +
+
+ """.trimIndent()) + } + } + + return sb.toString() + } + + // 문자열 자르기 헬퍼 + private fun truncate(str: String?, len: Int): String { + if (str == null) return "" + return if (str.length > len) str.substring(0, len) + "..." else str + } + + // JS 문자열 이스케이프 + private fun escapeJs(str: String): String { + return str.replace("'", "\\'").replace("\"", "\\\"").replace("\n", " ") + } +} \ No newline at end of file diff --git a/src/main/resources/templates/content/editor.html b/src/main/resources/templates/content/editor.html index 88fc9a1..b9e4c7d 100644 --- a/src/main/resources/templates/content/editor.html +++ b/src/main/resources/templates/content/editor.html @@ -1,145 +1,212 @@ - + - - - - - - - -
- -
-
-

글 작성/수정

- -

- -

- -

-
-
- 게시물 공개 - - -
-
-
-
-
-
-
-
-
-

수동 설정

-
-
- - -
-
- - -
-
- - -
-
-

* 작성일이나 좌표를 직접 입력하면 자동으로 측정된 GPS 정보 대신 입력된 값이 저장됩니다.

-
-
-
-
- -
- -

권한이 없는 뎁쇼?!

-
- - -
-
-
- - -
-
-
-
-
- -
-
-
-

Categories

- -
Selected:
-
-
- -
Pre-loaded Categories:
-
- - - - -
- Apply - Close -
-
-
-
- -
-
-
-

Hashtags

- -
Selected:
-
-
- -
Suggested Tags:
-
- - - - -
- Apply - Close -
-
-
-
- - - - - - - - - - + +
+ + +
+
+
+

글 작성

+
+ +
+
+ + +
+ +
+
+
+ + + + + + + + +
+
+
+
+ + +
+
+
+ +
+ +
+ +
+
+ + +
+
+ +
+ + +
+
+
+
+ + + + + +
- \ No newline at end of file diff --git a/src/main/resources/templates/content/home.html b/src/main/resources/templates/content/home.html index 303030d..4b3ac10 100644 --- a/src/main/resources/templates/content/home.html +++ b/src/main/resources/templates/content/home.html @@ -1,341 +1,544 @@ - - - - + + + - - - -
-
-
- - -
+ -
-

'' 검색 결과

- 전체 목록 돌아가기 -
+ + - - - - - - - - - - - - - - -
-
-
+
+
+
+
-
- - Thumbnail - Default Thumbnail - -
-

제목

-

-

내용 요약

-
-
- -
-
-
- - - -
-

-
-
- -
-
-
- -
-
-

- - 북마크 제목 - - -

-

-

- Saved on -

+ +
+
+ +
+
+
+ +
+ +
- -
- - Advertisement - - - -
-
-
- -
- -
-
-
-
-
-
- -

This Is Important

-

Duis neque nisi, dapibus sed mattis et quis, nibh. Sed et dapibus nisl amet mattis, sed a rutrum accumsan sed. Suspendisse eu.

-
-
-
-
- -

글쓰기[Writing]

-

오직 주인장 만의 권한 임요. 그냥 내가 쓰기 편하게 여기 놔둔 메뉴임. 님들은 못씀요.
[Only the owner has the authority. This is just a menu that I put here for my convenience. You can't use it.]

-
- -
-
-
- -

Probably Important

-

Duis neque nisi, dapibus sed mattis et quis, nibh. Sed et dapibus nisl amet mattis, sed a rutrum accumsan sed. Suspendisse eu.

-
-
+
+ + + + + +
+ +
+ +
+ +
-
-
-
-

오늘의 Gibberish 남기기 (100자 이내)

- - -
+
+ +
+ +
+

Search

+
+ +
-
-
-
-
- -
-
-
+
+ + + - /* home.html 하단 스크립트 */ - \ No newline at end of file diff --git a/src/main/resources/templates/content/posts.html b/src/main/resources/templates/content/posts.html index 6a7258b..e36d0cc 100644 --- a/src/main/resources/templates/content/posts.html +++ b/src/main/resources/templates/content/posts.html @@ -3,147 +3,127 @@ 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}" -> + layout:decorate="~{layout/default_layout}"> - - + +
-
-
-
-
-
-

필터링된 게시물

-

모든 글 보기

-
- -
-
- - Post Thumbnail - -
-

- - - 비공개 - - - - - (읽음: 0) - -

-

-

-
- -
-
+
- -
-
-

- Advertisement -

- - -
-
-
- -
+
+

필터링된 게시물

+

홈으로 돌아가기

+
+
+

전체 글 목록

- -
+
+
+ +
+
+ + Thumb + +
+

+ + 비공개 + 제목 + +

+

+

내용요약

+
+ +
+ + +
+
+
+ +
+ +
+
+ +
diff --git a/src/main/resources/templates/content/viewer.html b/src/main/resources/templates/content/viewer.html index f7304a3..0b1a65a 100644 --- a/src/main/resources/templates/content/viewer.html +++ b/src/main/resources/templates/content/viewer.html @@ -5,63 +5,42 @@ layout:decorate="~{layout/default_layout}"> + + +
-
-
-

- 게시물 제목이 여기에 표시됩니다 - - (읽음: 0) - +
+
+

+ 제목 +

- - + + by

+
- -
-
-
-
-
-
-
-
-
- -
- - -
-
-
-
-
-
-
-

@@ -69,52 +48,193 @@
-
-
- - + -

-

+ +
+ + 내용 +
+ +
+
+
+ + +
+ + + #Tag + +
+ +
+ 수정 + +
-
-

Comments

-
- - - +
+

Comments

+ +
+
Loading comments...
-
+ +
+ +
+ +
+
+
+

댓글을 남기려면 로그인이 필요합니다.

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