diff --git a/build.gradle.kts b/build.gradle.kts index c1d76f8..8fd8fb3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,63 +55,51 @@ repositories { } dependencies { -// implementation ("jakarta.servlet:jakarta.servlet-api") //스프링부트 3.0 이상 -// implementation ("jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api") //스프링부트 3.0 이상 -// implementation ("org.glassfish.web:jakarta.servlet.jsp.jstl") //스프링부트 3.0 이상 - implementation ("org.slf4j:jcl-over-slf4j") -// implementation ("org.springframework.boot:spring-boot-starter-batch") - implementation ("org.springframework.boot:spring-boot-starter-quartz") + // [추가] Kotlin BOM(Bill of Materials)을 사용하여 모든 코틀린 라이브러리 버전을 정렬합니다. + implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.25")) - implementation ("com.google.code.gson:gson:2.11.0") + // --- 기존 의존성 (정리됨) --- + implementation ("org.slf4j:jcl-over-slf4j") + implementation ("org.springframework.boot:spring-boot-starter-quartz") implementation ("org.apache.tomcat.embed:tomcat-embed-jasper") implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") - implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6") - implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect") implementation ("org.jsoup:jsoup:1.18.1") - implementation ("org.seleniumhq.selenium:selenium-java:4.10.0") - implementation ("org.commonmark:commonmark:0.18.0") implementation ("net.coobird:thumbnailator:0.4.14") - implementation("org.sejda.imageio:webp-imageio:0.1.6") implementation ("com.drewnoakes:metadata-extractor:2.19.0") implementation("org.springframework.boot:spring-boot-starter-security") compileOnly("org.projectlombok:lombok") - -// implementation(platform("com.google.cloud:libraries-bom: 26.55.0")) -// implementation("com.google.cloud:google-cloud-apikeys") implementation ("com.google.maps:google-maps-services:2.2.0") -// implementation ("org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-SNAPSHOT") -// implementation ("org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter:1.0.0-SNAPSHOT") -// implementation("org.springframework.ai:spring-ai-ollama-spring-boot-starter:1.0.0-SNAPSHOT") implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0-M6")) implementation("org.springframework.ai:spring-ai-ollama-spring-boot-starter:1.0.0-M6") implementation ("org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter") -// implementation ("io.qdrant:client:1.13.0") - implementation ("org.slf4j:slf4j-simple:1.7.25") - implementation("io.jsonwebtoken:jjwt-api:0.11.5") implementation("io.jsonwebtoken:jjwt-impl:0.11.5") implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") + // [수정] 버전 번호를 제거합니다. (BOM이 버전을 관리해 줍니다) + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // [수정] Gson 라이브러리 중복 제거 (2.11.0 버전만 남김) + implementation ("com.google.code.gson:gson:2.11.0") + annotationProcessor("org.projectlombok:lombok") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - - // JSON 처리를 위한 Gson 라이브러리 - implementation("com.google.code.gson:gson:2.10.1") } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 30c3fd9..30e66c3 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -33,6 +33,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.stereotype.Controller +import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile import reactor.core.publisher.Flux @@ -71,6 +72,7 @@ data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", va class BlogController( // 생성자를 통해 모든 서비스 의존성을 주입받습니다 (Spring 권장 방식). private val postManager: PostManager, + private val postHistoryManager: PostHistoryManager, private val imageMetaService: ImageMetaService, private val logService: LogService, private val commentService: CommentService, @@ -89,6 +91,79 @@ class BlogController( private data class Delta(val ops: List) + data class GibberishRequest(val content: String) + + /** + * [신규 추가] 게시물을 영구적으로 삭제하는 API입니다. + * 작성자 또는 관리자만 이 작업을 수행할 수 있습니다. + */ + @DeleteMapping("/blog/post/{postId}") + @ResponseBody + suspend fun deletePost( + @PathVariable postId: String, + @AuthenticationPrincipal user: UserDetails? + ): ResponseEntity> { + // 1. 사용자 인증 확인 + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(mapOf("message" to "인증이 필요합니다.")) + } + + // 2. 삭제할 게시물 조회 + val post = postManager.findById(postId).awaitSingleOrNull() + ?: return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(mapOf("message" to "삭제할 게시물을 찾을 수 없습니다.")) + + // 3. 권한 확인 (관리자 또는 작성자) + 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 "이 게시물을 삭제할 권한이 없습니다.")) + } + + // 4. 삭제 실행 + 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 "삭제 중 서버 오류가 발생했습니다.")) + } + } + + // BlogController 클래스 내부에 추가 + @PostMapping("/gibberish") + @ResponseBody + 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은 content의 앞부분을 잘라서 사용하거나, 간단한 규칙으로 생성 + 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, // Gibberish는 기본적으로 공개 + postType = PostType.GIBBERISH.name + ) + + return postManager.save(newPost) + .map { savedPost -> ResponseEntity.status(HttpStatus.CREATED).body(savedPost) } + } + + // --- Private Helper Methods --- /** @@ -323,7 +398,13 @@ class BlogController( // 2. 최종 경로가 유효하면 모델에 추가하고, 아니면 기본 이미지를 사용합니다. vm.modelMap["randomBannerImage"] = if (!bannerImagePath.isNullOrBlank()) bannerImagePath else defaultBannerImage - +// [추가] 랜덤 Gibberish 포스트를 조회하여 모델에 추가 + val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull() + if (randomGibberish != null) { + // Post 객체를 바로 전달해도 되지만, 내용만 간단히 쓸 것이므로 content만 전달 + vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8") + vm.modelMap["gibberishId"] = randomGibberish.id // 댓글 페이지로 이동할 ID + } val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() vm.modelMap["Posts"] = postsList.map { processPostForView(it) } @@ -417,9 +498,9 @@ class BlogController( logService.log("Access denied for user ${userDetails?.username} to post ${post.id}") return ResultMV("redirect:/blog/posts") } - - vm.modelMap["srcPost"] = post - vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(post) + val processedPost = processPostForView(post) + vm.modelMap["srcPost"] = processedPost + vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost) } catch (e: Exception) { return ResultMV("redirect:/") } @@ -578,61 +659,81 @@ class BlogController( */ @PostMapping("/blog/post.bjx") @ResponseBody + @Transactional // ★ 데이터 일관성을 위해 트랜잭션 어노테이션 추가 suspend fun savePost( @RequestBody rawPayload: String, @AuthenticationPrincipal user: UserDetails? - ): Mono { + ): PostSaveResponse { if (user == null) { - return Mono.just(PostSaveResponse(401, "Authentication required", null)) + return PostSaveResponse(401, "Authentication required", null) } - println("rawPayload ${rawPayload}") + val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper) - // 새 글 작성 - if (incomingPost.id.isNullOrBlank()) { + return if (incomingPost.id.isNullOrBlank()) { + // 새 글 작성 val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" } if (!isAdmin && !canWrite) { - return Mono.just(PostSaveResponse(403, "Permission denied to create post", null)) + return PostSaveResponse(403, "Permission denied to create post", null) } incomingPost.writer = user.username incomingPost.writeTime = System.currentTimeMillis() incomingPost.modifyTime = incomingPost.writeTime - return postManager.save(incomingPost).flatMap { savedPost -> - savedPost.originId = savedPost.id - postManager.save(savedPost) - }.map { finalPost -> PostSaveResponse(0, "Success", PostIdData(finalPost.id!!)) } - } - // 기존 글 수정 (새 버전 생성) - else { - return postManager.findById(incomingPost.id!!).flatMap { originalPost -> - val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } - val isWriter = user.username == originalPost.writer - if (!isAdmin && !isWriter) { - return@flatMap Mono.just(PostSaveResponse(403, "Permission denied to update post", null)) - } - val newVersion = incomingPost.copy( - id = null, - originId = originalPost.originId ?: originalPost.id, - writer = originalPost.writer, - writeTime = originalPost.writeTime, - readCount = originalPost.readCount, - voteCount = originalPost.voteCount, - unlikeCount = originalPost.unlikeCount, - modifyTime = System.currentTimeMillis(), - postType = originalPost.postType // [추가] 기존 postType을 새 버전에 복사 - ) - postManager.save(newVersion).map { savedPost -> - PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) - } - }.switchIfEmpty( - Mono.just(PostSaveResponse(404, "Original post to edit not found", null)) + val savedPost = postManager.save(incomingPost).awaitSingle() + PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) + + } else { + // --- [완전히 새로 작성된] 기존 글 수정 로직 --- + + // 1. DB에서 수정할 원본 게시물을 조회합니다. + val originalPost = postManager.findById(incomingPost.id!!).awaitSingleOrNull() + ?: return PostSaveResponse(404, "Original post not found", null) + + // 2. 권한을 확인합니다. + 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) + } + + // 2. 원본 데이터를 기반으로 PostHistory 객체를 생성합니다. + val history = PostHistory( + postId = originalPost.id!!, + title = originalPost.title, + content = originalPost.content, + category = originalPost.category, + tags = originalPost.tags, + writer = originalPost.writer, + writeTime = originalPost.writeTime, + posting = originalPost.posting, + // ... originalPost의 모든 필드를 복사 ... + modifyTime = originalPost.modifyTime ) + + // 3. 히스토리를 DB에 저장합니다. + postHistoryManager.save(history).awaitSingle() + + // 4. 원본 객체(originalPost)의 내용을 클라이언트가 보낸 새 내용(incomingPost)으로 업데이트합니다. + val updatedPost = originalPost.copy( + title = incomingPost.title, + content = incomingPost.content, + posting = incomingPost.posting, // ★ 변경된 공개 상태 적용 + category = incomingPost.category, + tags = incomingPost.tags, + modifyTime = System.currentTimeMillis() // 수정 시간 갱신 + // ... 기타 수정 가능한 필드들 ... + ) + + // 5. 업데이트된 원본을 저장합니다. (ID가 있으므로 UPDATE 쿼리가 실행됨) + val savedPost = postManager.save(updatedPost).awaitSingle() + PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) } } + // [신규] 게시물 차단 API (관리자 전용) @PostMapping("/blog/post/{postId}/block") @PreAuthorize("hasRole('ADMIN')") diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt index dcf3090..649b566 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt @@ -4,6 +4,8 @@ import com.google.gson.Gson import com.google.protobuf.LazyStringArrayList.emptyList import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 @@ -37,9 +39,10 @@ import java.util.* import javax.naming.AuthenticationException import kotlin.collections.emptyList import kr.lunaticbum.back.lun.model.Message +import org.springframework.stereotype.Controller import reactor.core.publisher.Flux -@RestController +@Controller @RequestMapping("/user") class UserController( private val rememberMeServices: RememberMeServices, @@ -50,7 +53,9 @@ class UserController( private val messageService: MessageService, private val webBookmarkService: WebBookmarkService, private val imageMetaService: ImageMetaService, - private val jwtUtil: JwtUtil + private val jwtUtil: JwtUtil, + private val migrationService: MigrationService, + private val postHistoryManager: PostHistoryManager ) { @@ -60,7 +65,27 @@ class UserController( @Autowired lateinit var logService: LogService + // [추가] 게시물 히스토리 조회 페이지 (관리자 전용) + @GetMapping("/admin/posts/{postId}/history") + @PreAuthorize("hasRole('ADMIN')") + suspend fun postHistoryPage(@PathVariable postId: String): ResultMV { + val vm = ResultMV("content/user/post_history") // 새 템플릿 파일을 렌더링 + // 1. 현재 버전의 게시물 조회 + val currentPost = postManager.findById(postId).awaitSingleOrNull() + if (currentPost == null) { + // 게시물이 없으면 리디렉션 또는 에러 처리 + return ResultMV("redirect:/user/info") + } + + // 2. PostHistoryManager를 통해 히스토리 목록 조회 + val historyList = postHistoryManager.findByPostId(postId).collectList().awaitSingle() + + vm.modelMap["currentPost"] = currentPost + vm.modelMap["historyList"] = historyList + vm.setTitle("'${currentPost.title}' 수정 히스토리") + return vm + } @GetMapping("join.bs") fun hello(httpServletRequest: HttpServletRequest): ResultMV { logService.log("onJoin") @@ -243,6 +268,17 @@ class UserController( .bodyToMono(String::class.java).block() ?: "FAIL" } + @PostMapping("/admin/migrate-posts") + @PreAuthorize("hasRole('ADMIN')") + fun runPostMigration(): Mono> { + return migrationService.migratePosts() + .map { report -> ResponseEntity.ok(report) } + .onErrorResume { e -> // 에러 발생 시 + val errorReport = MigrationReport(0, 0, 0, listOf(e.message ?: "Unknown error")) + Mono.just(ResponseEntity.status(500).body(errorReport)) + } + } + /** * [수정] '내 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가) */ diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt new file mode 100644 index 0000000..6a63360 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt @@ -0,0 +1,110 @@ +package kr.lunaticbum.back.lun.model + +import org.springframework.data.domain.Sort +import org.springframework.data.mongodb.core.ReactiveMongoTemplate +import org.springframework.data.mongodb.core.aggregation.Aggregation +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.aggregation.Aggregation.* +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +// 마이그레이션 결과 리포트를 위한 데이터 클래스 +data class MigrationReport( + val processedGroups: Int, + val latestPostsMigrated: Int, + val historyPostsMigrated: Int, + val errors: List +) + +@Service +class MigrationService( + private val mongoTemplate: ReactiveMongoTemplate, + private val postRepository: PostRepository, // 새 Post Repository + private val postHistoryRepository: PostHistoryRepository // 새 PostHistory Repository +) { + // Mono를 반환하여 비동기 작업의 결과를 컨트롤러에 전달 + fun migratePosts(): Mono { + // 1. originId가 null이 아닌 문서만 대상으로 그룹화 + val aggregation = newAggregation( + Post::class.java, + sort(Sort.Direction.DESC, "modifyTime"), + group("originId") + .push("$$ROOT").`as`("versions") + ) + + return mongoTemplate.aggregate(aggregation, "Post", Map::class.java) + .flatMap { versionGroup -> + @Suppress("UNCHECKED_CAST") + val versions = versionGroup["versions"] as? List> ?: return@flatMap Mono.empty() + + if (versions.isEmpty()) { + return@flatMap Mono.empty>() + } + + // 3. 첫 번째 항목이 최신 버전, 나머지가 과거 버전 + // FIX 1: Changed .first to .first() + val latestVersionMap = versions.first() + val oldVersionsMaps = versions.drop(1) + + // 4. 최신 버전을 새 Post 객체로 변환하고 저장 + val newPost = mapToPost(latestVersionMap).apply { + originId = null + } + + postRepository.save(newPost) + .flatMap { savedPost -> + // 5. 과거 버전들을 PostHistory 객체로 변환하여 저장 + val historyFlux = Flux.fromIterable(oldVersionsMaps) + .map { oldVersionMap -> + mapToHistory(oldVersionMap, savedPost.id!!) + } + .flatMap { history -> + postHistoryRepository.save(history) + } + historyFlux.then(Mono.just(Pair(1, oldVersionsMaps.size))) + } + } + .reduce(Pair(0, 0)) { acc, pair -> + Pair(acc.first + (pair as Pair).first, acc.second + pair.second) + } + .map { (latestCount, historyCount) -> + MigrationReport( + processedGroups = latestCount, + latestPostsMigrated = latestCount, + historyPostsMigrated = historyCount, + errors = emptyList() + ) + } + .defaultIfEmpty(MigrationReport(0, 0, 0, listOf("No data to migrate."))) + } + + // Map을 Post 객체로 변환하는 헬퍼 함수 + private fun mapToPost(map: Map): Post = Post( + id = map["_id"]?.toString(), + originId = map["originId"] as? String, // 마이그레이션 중에는 originId가 필요 + title = map["title"] as? String, + content = map["content"] as? String, + category = map["category"] as? String, + tags = map["tags"] as? String, + writer = map["writer"] as? String, + writeTime = (map["writeTime"] as? Number)?.toLong() ?: 0L, + posting = map["posting"] as? Boolean ?: false, + modifyTime = (map["modifyTime"] as? Number)?.toLong() ?: 0L + // ... Post의 모든 필드를 안전하게 변환 ... + ) + + // Map을 PostHistory 객체로 변환하는 헬퍼 함수 + private fun mapToHistory(map: Map, newPostId: String): PostHistory = PostHistory( + postId = newPostId, + title = map["title"] as? String, + content = map["content"] as? String, + category = map["category"] as? String, + tags = map["tags"] as? String, + writer = map["writer"] as? String, + writeTime = (map["writeTime"] as? Number)?.toLong() ?: 0L, + posting = map["posting"] as? Boolean ?: false, + modifyTime = (map["modifyTime"] as? Number)?.toLong() ?: 0L + // ... PostHistory의 모든 필드를 안전하게 변환 ... + ) +} \ 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 384146d..7ac1828 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -48,9 +48,61 @@ import java.util.ArrayList import java.util.Base64 import java.util.Date +@Document(collection = "PostHistory") +data class PostHistory( + @Id + var id: String? = null, + var postId: String, // 원본 Post의 ID + + // --- Post의 모든 필드를 그대로 가져옵니다 --- + var title : String? = null, + var content : String? = null, + var category : String? = null, + var tags : String? = null, + var writer : String? = null, + var writeTime : Long = 0, + var posting : Boolean = false, + var firstPostLat : Double = 0.0, + var firstPostLon : Double = 0.0, + var firstAddress : String = "", + var modifyAddress : String = "", + var modifyTime : Long = 0, + var modifyLat : Double = 0.0, + var modifyLon : Double = 0.0, + var readCount : Long = 0, + var voteCount : Long = 0, + var unlikeCount : Long = 0, + var isBlocked: Boolean = false, + var postType: String = PostType.STANDARD.name, + + // --- 히스토리 전용 필드 --- + var archivedAt: Long = System.currentTimeMillis() // 보관된 시간 +) + +// 2. PostHistory를 위한 Repository 인터페이스 +@Repository +interface PostHistoryRepository : ReactiveMongoRepository { + // [추가] postId로 모든 히스토리를 최신순으로 조회 + fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux +} + +// 3. PostHistory를 위한 Service 클래스 +@Service +class PostHistoryManager(private val repository: PostHistoryRepository) { + fun save(postHistory: PostHistory): Mono { + return repository.save(postHistory) + } + + // [추가] postId로 모든 히스토리를 조회하는 함수 + fun findByPostId(postId: String): Flux { + return repository.findByPostIdOrderByArchivedAtDesc(postId) + } +} + enum class PostType { STANDARD, // 일반 블로그 글 - ABOUT_SITE // 사이트 소개 글 + ABOUT_SITE, // 사이트 소개 글 + GIBBERISH } @Document(collection = "Post") @@ -174,24 +226,54 @@ interface PostRepository : ReactiveMongoRepository { fun findTop5ByOrderByReadCountDesc(): Flux fun findTop5ByOrderByModifyTimeDesc(): Flux fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux + + // [단순화] 공개된 글 목록 조회 (페이지네이션) + fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux + + // [단순화] 공개된 글 개수 카운트 + fun countByPostingIsTrue(): Mono + + // [단순화] 인기글 5개 조회 (공개된 글 대상) + fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux + + // [단순화] 최신글 5개 조회 (공개된 글 대상) + fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux + + // [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글) + fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux + fun countByPostingIsTrueOrWriter(writer: String): Mono + + // [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상) @Aggregation(pipeline = [ - "{ \$match: { posting: true, isBlocked: false } }", // [수정됨] - "{ \$sort: { modifyTime: -1 } }", // 2. 최신 버전이 먼저 오도록 정렬 - "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", // 3. 고유 포스트 그룹화 - "{ \$replaceRoot: { newRoot: \"\$post\" } }", // 4. 그룹화된 문서를 원래 형태로 복원 - "{ \$sort: { readCount: -1 } }", // 5. 조회수 순으로 정렬 - "{ \$limit: 5 }" // 6. 상위 5개만 선택 + // 1. 모든 글을 최신순으로 정렬 + "{ \$sort: { modifyTime: -1 } }", + // 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출 + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + // 3. 원래 Post 형태로 복원 + "{ \$replaceRoot: { newRoot: \"\$post\" } }", + // 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링 + "{ \$match: { posting: true, isBlocked: false } }", + // 5. 최종 목록을 조회수(readCount) 순으로 정렬 + "{ \$sort: { readCount: -1 } }", + // 6. 상위 5개만 선택 + "{ \$limit: 5 }" ]) fun findTop5UniquePublishedByReadCountDesc(): Flux // [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상) @Aggregation(pipeline = [ - "{ \$match: { posting: true, isBlocked: false } }", // [수정됨] + // 1. 모든 글을 최신순으로 정렬 "{ \$sort: { modifyTime: -1 } }", + // 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출 "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + // 3. 원래 Post 형태로 복원 "{ \$replaceRoot: { newRoot: \"\$post\" } }", - "{ \$sort: { modifyTime: -1 } }", // 최신순으로 다시 정렬 + // 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링 + "{ \$match: { posting: true, isBlocked: false } }", + // 5. 최종 목록을 다시 최신순으로 정렬 + "{ \$sort: { modifyTime: -1 } }", + // 6. 상위 5개만 선택 "{ \$limit: 5 }" ]) fun findTop5UniquePublishedByModifyTimeDesc(): Flux @@ -237,15 +319,19 @@ interface PostRepository : ReactiveMongoRepository { /** - * 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다. * [수정] posting이 true인 문서만 필터링하는 $match 단계를 추가합니다. + * [[[[[ FIXED LOGIC]]]]] */ @Aggregation(pipeline = [ + // 1. Sort ALL posts first to find the absolute most recent version of each. "{ \$sort: { modifyTime: -1 } }", + // 2. Group by the original ID to get only the latest version. "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + // 3. Restore the post document structure. "{ \$replaceRoot: { newRoot: \"\$post\" } }", - // [[[[[ 신규 추가된 라인 ]]]]] + // 4. NOW, filter this list of latest posts to show only the public ones. "{ \$match: { posting: true } }", + // 5. Finally, sort the remaining public posts for display. "{ \$sort: { \"modifyTime\": -1 } }" ]) fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux // 메서드 이름 변경 @@ -253,19 +339,31 @@ interface PostRepository : ReactiveMongoRepository { /** * '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다. * [수정] posting이 true인 문서만 필터링하는 $match 단계를 추가합니다. + * [[[[[FIXED LOGIC]]]]] */ @Aggregation(pipeline = [ + // 1. Sort ALL posts. "{ \$sort: { modifyTime: -1 } }", + // 2. Group to get the latest version of each. "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", + // 3. Restore the document. "{ \$replaceRoot: { newRoot: \"\$post\" } }", - // [[[[[ 신규 추가된 라인 ]]]]] + // 4. Filter for public posts. "{ \$match: { posting: true } }", + // 5. Count the final result. "{ \$count: \"totalCount\" }" ]) fun countLatestUniquePublished(): Mono // 메서드 이름 변경 fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux // [신규 추가] + // [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회 + @Aggregation(pipeline = [ + "{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링 + "{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링 + ]) + fun findRandomPublishedPostByType(postType: String): Mono + } @@ -280,6 +378,60 @@ class PostManager( @Autowired private lateinit var bCryptPasswordEncoder: PasswordEncoder + fun deletePost(postId: String): Mono { + return postRepository.deleteById(postId) + } + + // [수정] 익명 사용자용 목록 조회 + fun findLatestUniquePaginated(pageable: Pageable) : Mono> { + return postRepository.findByPostingIsTrueOrderByModifyTimeDesc(pageable) + .collectList() + } + + // [수정] 익명 사용자용 글 개수 + fun countLatestUnique(): Mono { + return postRepository.countByPostingIsTrue() + } + + // [수정] '글쓰기' 권한 사용자용 목록 조회 + fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { + return postRepository.findByPostingIsTrueOrWriterOrderByModifyTimeDesc(username, pageable) + .collectList() + } + + // [수정] '글쓰기' 권한 사용자용 글 개수 + fun countLatestUniqueForWriter(username: String): Mono { + return postRepository.countByPostingIsTrueOrWriter(username) + } + + // [수정] 익명 사용자용 인기글 + fun getTop5UniquePublishedByViews(): Flux { + return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // [수정] 익명 사용자용 최신글 + fun getRecent5UniquePublished(): Flux { + return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map { + p -> + p.title = URLDecoder.decode(p.title, "UTF-8") + if (p.title?.isEmpty() == true) { + p.title = "무제(無題)" + } + p + } + } + + // [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드 + fun findRandomGibberish(): Mono { + return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name) + } + // [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드 fun findLatestAboutPost(): Mono { // 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴 @@ -312,13 +464,13 @@ class PostManager( return postRepository.findById(id) } - /** - * [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회) - */ - fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { - return postRepository.findLatestUniqueForWriterPaginated(username, pageable) - .collectList() - } +// /** +// * [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회) +// */ +// fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { +// return postRepository.findLatestUniqueForWriterPaginated(username, pageable) +// .collectList() +// } fun findPostsByWriter(writer: String, pageable: Pageable): Flux { return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable) @@ -332,14 +484,14 @@ class PostManager( } } - /** - * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수 - */ - fun countLatestUniqueForWriter(username: String): Mono { - return postRepository.countLatestUniqueForWriter(username) - .map { it.totalCount } - .switchIfEmpty(Mono.just(0L)) - } +// /** +// * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수 +// */ +// fun countLatestUniqueForWriter(username: String): Mono { +// return postRepository.countLatestUniqueForWriter(username) +// .map { it.totalCount } +// .switchIfEmpty(Mono.just(0L)) +// } fun getPost(id: String): Mono { val query = Query.query(Criteria.where("id").`is`(id)) @@ -373,14 +525,14 @@ class PostManager( .collectList() } - /** - * 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회) - * [FIX]: This function should already be correct from the previous step. - */ - fun findLatestUniquePaginated(pageable: Pageable) : Mono> { // <-- Should already return Mono - return postRepository.findLatestUniquePublishedPaginated(pageable) - .collectList() - } +// /** +// * 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회) +// * [FIX]: This function should already be correct from the previous step. +// */ +// fun findLatestUniquePaginated(pageable: Pageable) : Mono> { // <-- Should already return Mono +// return postRepository.findLatestUniquePublishedPaginated(pageable) +// .collectList() +// } /** * 인증된 사용자가 보는 글의 총 개수 @@ -389,15 +541,15 @@ class PostManager( return postRepository.countByOrderByModifyTimeDesc() } - /** - * 익명 사용자가 보는 글의 총 개수 - */ - fun countLatestUnique(): Mono { - // AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다. - return postRepository.countLatestUniquePublished() - .map { it.totalCount } - .switchIfEmpty(Mono.just(0L)) - } +// /** +// * 익명 사용자가 보는 글의 총 개수 +// */ +// fun countLatestUnique(): Mono { +// // AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다. +// return postRepository.countLatestUniquePublished() +// .map { it.totalCount } +// .switchIfEmpty(Mono.just(0L)) +// } /** * [신규 추가] @@ -495,27 +647,28 @@ class PostManager( } } - // [신규 추가] 익명 사용자용 인기글 - fun getTop5UniquePublishedByViews(): Flux { - return postRepository.findTop5UniquePublishedByReadCountDesc().map { p -> - p.title = URLDecoder.decode(p.title, "UTF-8") - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - p - } - } +// // [신규 추가] 익명 사용자용 인기글 +// fun getTop5UniquePublishedByViews(): Flux { +// return postRepository.findTop5UniquePublishedByReadCountDesc().map { p -> +// p.title = URLDecoder.decode(p.title, "UTF-8") +// if (p.title?.isEmpty() == true) { +// p.title = "무제(無題)" +// } +// println("${p.id} p.posting >> ${p.posting}") +// p +// } +// } - // [신규 추가] 익명 사용자용 최신글 - fun getRecent5UniquePublished(): Flux { - return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p -> - p.title = URLDecoder.decode(p.title, "UTF-8") - if (p.title?.isEmpty() == true) { - p.title = "무제(無題)" - } - p - } - } +// // [신규 추가] 익명 사용자용 최신글 +// fun getRecent5UniquePublished(): Flux { +// return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p -> +// p.title = URLDecoder.decode(p.title, "UTF-8") +// if (p.title?.isEmpty() == true) { +// p.title = "무제(無題)" +// } +// p +// } +// } } @@ -585,6 +738,11 @@ object PayloadDecoder { odd = odd.reversed() even = even.reversed() } + // [추가] 예외적인 type에 대한 기본 처리(안전장치) + else -> { + odd = odd.reversed() + even = even.reversed() + } } // odd와 even 문자열을 다시 조합하여 원본 데이터 생성 diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/TokenData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/TokenData.kt index a7877cd..4350d6c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/TokenData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/TokenData.kt @@ -64,7 +64,6 @@ data class PersistentLogin( interface PersistentLoginRepository : ReactiveMongoRepository { fun findByUsername(username: String): Flux } - @Component class MongoPersistentTokenRepository ( private val repository: PersistentLoginRepository @@ -77,33 +76,43 @@ class MongoPersistentTokenRepository ( tokenValue = token.tokenValue, lastUsed = token.date ) - repository.save(persistentLogin).block() // 블로킹 여부는 환경에 따라 조절 - println("CALLED rememberMeServices") + // [수정] .block() 대신 .subscribe()를 사용하여 비동기 실행 + repository.save(persistentLogin).subscribe() + println("CALLED rememberMeServices: createNewToken") } override fun updateToken(series: String, tokenValue: String, lastUsed: Date) { - val login = repository.findById(series).block() - if (login != null) { + // [수정] .block() 대신 .flatMap과 .subscribe()를 사용 + repository.findById(series).flatMap { login -> val updated = login.copy(tokenValue = tokenValue, lastUsed = lastUsed) - repository.save(updated).block() - println("CALLED rememberMeServices") - } + repository.save(updated) + }.subscribe() + println("CALLED rememberMeServices: updateToken") } override fun getTokenForSeries(seriesId: String): PersistentRememberMeToken? { + // [주의] 이 인터페이스 메소드는 동기(blocking) 반환을 요구하므로, + // 어쩔 수 없이 .block()을 사용해야 합니다. 하지만, + // 자동 로그인은 메인 요청 흐름에 덜 치명적이므로 이 부분은 유지합니다. + // 근본적인 해결을 위해서는 Spring Security의 ReactivePersistentTokenRepository 사용이 필요합니다. val login = repository.findById(seriesId).block() return login?.let { - println("CALLED rememberMeServices") + println("CALLED rememberMeServices: getTokenForSeries") PersistentRememberMeToken(it.username, it.series, it.tokenValue, it.lastUsed) - } } override fun removeUserTokens(username: String) { - val tokens = repository.findByUsername(username).collectList().block() - tokens?.let { - println("CALLED rememberMeServices") - repository.deleteAll(it).block() - } + // [수정] .block() 대신 .flatMap과 .subscribe()를 사용 + repository.findByUsername(username) + .collectList() + .flatMap { tokens -> + if (tokens.isNotEmpty()) { + repository.deleteAll(tokens) + } else { + Mono.empty() + } + }.subscribe() + println("CALLED rememberMeServices: removeUserTokens") } } diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index b93131a..1eca548 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -50,7 +50,7 @@ spring.data.mongodb.option.max-wait-time=120000 spring.data.mongodb.option.max-connection-idle-time=0 spring.data.mongodb.option.max-connection-life-time=0 spring.data.mongodb.option.connect-timeout=10000 -spring.data.mongodb.option.socket-timeout=0 +spring.data.mongodb.option.socket-timeout=60000 spring.data.mongodb.option.socket-keep-alive=false spring.data.mongodb.option.ssl-enabled=false diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index e94640d..459cc4f 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -50,7 +50,7 @@ spring.data.mongodb.option.max-wait-time=120000 spring.data.mongodb.option.max-connection-idle-time=0 spring.data.mongodb.option.max-connection-life-time=0 spring.data.mongodb.option.connect-timeout=10000 -spring.data.mongodb.option.socket-timeout=0 +spring.data.mongodb.option.socket-timeout=60000 spring.data.mongodb.option.socket-keep-alive=false spring.data.mongodb.option.ssl-enabled=false diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ede3c59..72582b1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -50,7 +50,7 @@ spring.data.mongodb.option.max-wait-time=120000 spring.data.mongodb.option.max-connection-idle-time=0 spring.data.mongodb.option.max-connection-life-time=0 spring.data.mongodb.option.connect-timeout=10000 -spring.data.mongodb.option.socket-timeout=0 +spring.data.mongodb.option.socket-timeout=60000 spring.data.mongodb.option.socket-keep-alive=false spring.data.mongodb.option.ssl-enabled=false @@ -95,6 +95,7 @@ spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +logging.level.org.springframework.security=DEBUG # Increase server connection timeout to 60 seconds (default is often 20 or 30s) server.tomcat.connection-timeout=60s @@ -105,4 +106,3 @@ api.base-url=ss build.config.run=local jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long jwt.expiration=86400000 -logging.level.org.springframework.security=DEBUG \ No newline at end of file diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 47701fe..508c883 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -3524,7 +3524,7 @@ a.btn_layerClose:hover { vertical-align: middle; margin-left: 0.5em; } - +#post-published-switch, #rememberMe { vertical-align: middle; width: 22px; @@ -3538,12 +3538,12 @@ a.btn_layerClose:hover { position: relative; top: -2px; } - +#post-published-switch:checked, #rememberMe:checked { background-color: var(--point-color, #FFA500); border-color: var(--point-color, #FFA500); } - +#post-published-switch:checked::after, #rememberMe:checked::after { content: ''; position: absolute; diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index c44c6e9..a609a30 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -589,7 +589,7 @@ function loadEditor() { * 작성된 게시물을 서버에 저장합니다. (바닐라 JS) * (이 함수는 이미 바닐라 XHR을 사용하는 post() 헬퍼 함수를 쓰므로 수정 불필요) */ -function save() { +async function save() { const titleField = document.getElementById('title_field'); // 1. 원본 baseData를 복사하여 전송용 객체(dataToSend) 생성 @@ -619,23 +619,24 @@ function save() { } const uploadUrl = `${getMainPath()}/blog/post.bjx`; - if (showConfirm("확인","해당 내용으로 저장하시겠습니까?")) { + if (await showConfirm("확인","해당 내용으로 저장하시겠습니까?")) { console.log("Data being sent to server:", dataToSend); // 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용) - post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, function(resultData) { + post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, async function (resultData) { try { const response = JSON.parse(resultData); if (response.resultCode === 0 && response.data && response.data.postId) { // 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동 - showAlert("알림","저장되었습니다. 게시물 보기 페이지로 이동합니다."); - location.href = getMainPath() + "/blog/viewer/" + response.data.postId; + if (await showConfirm("알림", "저장되었습니다. 게시물 보기 페이지로 이동합니다.")) { + location.href = getMainPath() + "/blog/viewer/" + response.data.postId; + } } else { - showAlert("알림","저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error")); + showAlert("알림", "저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error")); } } catch (e) { console.error("Failed to parse save response:", e, resultData); - showAlert("알림","저장에 성공했으나 서버 응답을 처리할 수 없습니다."); + showAlert("알림", "저장에 성공했으나 서버 응답을 처리할 수 없습니다."); } }); } @@ -823,7 +824,7 @@ function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`) function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); } function gotoSudoKuGen() { document.location.replace(`${getMainPath()}/puzzle/sudoku_gen.bs`); } -function onclickJoin(type, keyword) { +async function onclickJoin(type, keyword) { let user_id = document.getElementById('user_id') let user_pw = document.getElementById('user_pw') let user_pw_check = document.getElementById('user_pw_check') @@ -885,7 +886,7 @@ function onclickJoin(type, keyword) { 'user_name': user_name.value } if (user_pw.value === user_pw_check.value) { - if(showConfirm("확인",JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) { + if(await showConfirm("확인",JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) { post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) { showAlert("알림",resultData) }) @@ -2017,6 +2018,44 @@ async function openBookmarkEditPopup(buttonElement) { showAlert('오류', error.message, 'error'); } } + +async function submitGibberish() { + const content = document.getElementById('gibberish-content').value; + if (!content || content.trim().length === 0) { + showAlert('알림', '내용을 입력해주세요.'); + return; + } + if (content.length > 100) { + showAlert('알림', '내용은 100자를 넘을 수 없습니다.'); + return; + } + + try { + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content') || ''; + const response = await fetch('/gibberish', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ content: content }) + }); + + if (response.ok) { + showAlert('성공', '성공적으로 등록되었습니다!', 'success'); + document.getElementById('gibberish-content').value = ''; + // 필요하다면 페이지를 새로고침하여 새 Gibberish를 볼 수 있게 함 + // location.reload(); + } else { + const errorData = await response.json(); + showAlert('오류', `등록에 실패했습니다: ${errorData.message}`, 'error'); + } + } catch (error) { + showAlert('오류', '네트워크 오류가 발생했습니다.', 'error'); + } +} + + /** * [수정된 함수] 이미지의 '보임/숨김' 상태를 서버에 업데이트하고 UI를 즉시 변경합니다. * @param {HTMLElement} button - 클릭된 버튼 요소 (this) @@ -2165,3 +2204,62 @@ async function deleteBookmarkImage(bookmarkId, imageUrl, buttonElement) { showAlert('오류', error.message, 'error'); } } + +function handleDeletePost(postId) { + const cleanPostId = postId.replace(/^"|"$/g, ''); + if (confirm(`'${cleanPostId}' 게시물을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) { + fetch(`/blog/post/${cleanPostId}`, { + method: 'DELETE', + headers: { [csrfHeader]: csrfToken } + }) + .then(response => { + if (response.ok) { + alert('게시물이 성공적으로 삭제되었습니다.'); + // UI에서 해당 게시물 행을 즉시 제거 + document.getElementById(`post-row-${cleanPostId}`).remove(); + } else { + return response.json().then(err => { throw new Error(err.message) }); + } + }) + .catch(error => { + console.error('Error:', error); + alert('삭제 처리 중 오류가 발생했습니다: ' + error.message); + }); + } +} + +/** + * [신규 추가] 현재 수정 중인 게시물을 삭제하는 함수 + * @param {string} postId 삭제할 게시물의 ID + */ +function deleteCurrentPost(buttonElement) { + const postId = buttonElement.getAttribute('data-post-id'); // data-post-id 속성에서 ID를 읽어옵니다. + + if (!postId) { + alert('삭제할 수 없는 게시물입니다.'); + return; + } + + if (confirm('정말로 이 게시물을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) { + fetch(`/blog/post/${postId}`, { + method: 'DELETE', + headers: { + [csrfHeader]: csrfToken + } + }) + .then(response => response.json().then(data => ({ok: response.ok, data}))) + .then(({ok, data}) => { + if (ok) { + alert('게시물이 삭제되었습니다.'); + // 삭제 성공 후 게시물 목록 페이지로 이동 + window.location.href = '/blog/posts'; + } else { + alert('삭제에 실패했습니다: ' + data.message); + } + }) + .catch(error => { + console.error('Error:', error); + alert('삭제 중 오류가 발생했습니다.'); + }); + } +} \ 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 b2007af..e68427b 100644 --- a/src/main/resources/templates/content/editor.html +++ b/src/main/resources/templates/content/editor.html @@ -29,11 +29,10 @@

-
- - +
+ 게시물 공개 + +
@@ -54,8 +53,13 @@
- - +
+ + +
diff --git a/src/main/resources/templates/content/home.html b/src/main/resources/templates/content/home.html index 90819bd..a1857f7 100644 --- a/src/main/resources/templates/content/home.html +++ b/src/main/resources/templates/content/home.html @@ -7,7 +7,11 @@
+
+
+
+

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

+ + +
+
+
-

Are you ready to continue your quest?

+
diff --git a/src/main/resources/templates/content/licenses.html b/src/main/resources/templates/content/licenses.html index b506bec..979adde 100644 --- a/src/main/resources/templates/content/licenses.html +++ b/src/main/resources/templates/content/licenses.html @@ -1,8 +1,8 @@ - - + + @@ -11,7 +11,7 @@
-

#lun ##Dependency License Report 2025-09-15 13:34:42 KST

+

#lun ##Dependency License Report 2025-09-24 15:26:44 KST

Apache 2.0

1 Group: com.google.android Name: annotations Version: 4.1.1.4

@@ -41,7 +41,7 @@
  • POM License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt
  • -

    5 Group: com.google.errorprone Name: error_prone_annotations Version: 2.27.0

    +

    5 Group: com.google.errorprone Name: error_prone_annotations Version: 2.26.1

    -

    100 Group: com.google.code.gson Name: gson Version: 2.11.0

    +

    100 Group: com.google.code.gson Name: gson Version: 2.10.1

    diff --git a/src/main/resources/templates/content/posts.html b/src/main/resources/templates/content/posts.html index 3a0001a..39d4a97 100644 --- a/src/main/resources/templates/content/posts.html +++ b/src/main/resources/templates/content/posts.html @@ -17,7 +17,7 @@
    + + \ No newline at end of file diff --git a/src/main/resources/templates/content/user/my_info.html b/src/main/resources/templates/content/user/my_info.html index d3f48be..b97554d 100644 --- a/src/main/resources/templates/content/user/my_info.html +++ b/src/main/resources/templates/content/user/my_info.html @@ -246,6 +246,15 @@
    +
    +

    데이터 마이그레이션 (주의!)

    +

    + 게시물 데이터 구조를 새 버전으로 업그레이드합니다. + 이 작업은 시스템 전체에 영향을 미치며, 반드시 한 번만 실행해야 합니다. +

    + +
    +

    권한 요청

    @@ -418,6 +432,43 @@ .catch(error => console.error('Error:', error)); } + + const migrationBtn = document.getElementById('run-migration-btn'); + if (migrationBtn) { + migrationBtn.addEventListener('click', async function() { + if (!await showConfirm('경고', '정말로 데이터 마이그레이션을 실행하시겠습니까? 이 작업은 되돌릴 수 없으며, 반드시 한 번만 실행해야 합니다.')) { + return; + } + + const resultDiv = document.getElementById('migration-result'); + resultDiv.innerHTML = '마이그레이션 진행 중... 페이지를 닫지 마세요.'; + migrationBtn.disabled = true; + + try { + const response = await fetch('/user/admin/migrate-posts', { + method: 'POST', + headers: { [csrfHeader]: csrfToken } + }); + + const report = await response.json(); + + if (response.ok) { + resultDiv.innerHTML = `✅ 마이그레이션 성공!
    + - 처리된 게시물 그룹: ${report.processedGroups}
    + - 최신 글로 이전됨: ${report.latestPostsMigrated}
    + - 히스토리로 이전됨: ${report.historyPostsMigrated}`; + resultDiv.style.color = 'green'; + } else { + throw new Error(report.errors.join(', ')); + } + } catch (error) { + resultDiv.innerHTML = `❌ 마이그레이션 실패: ${error.message}`; + resultDiv.style.color = 'red'; + migrationBtn.disabled = false; + } + }); + } + /** * (관리자) 콘텐츠(게시물)를 차단하거나 해제하는 함수 * @param {string} postId - 대상 게시물 ID diff --git a/src/main/resources/templates/content/user/post_history.html b/src/main/resources/templates/content/user/post_history.html new file mode 100644 index 0000000..2657070 --- /dev/null +++ b/src/main/resources/templates/content/user/post_history.html @@ -0,0 +1,58 @@ + + + + +
    +
    +
    +

    게시물 수정 히스토리

    +

    원본글 ID:

    +
    + +
    +

    현재 버전

    +

    수정일:

    + 현재 글 보기 + 현재 글 수정 +
    + +
    +

    과거 버전 (히스토리)

    +
    + + + + + + + + + + + + + + + + + + + + + + +
    보관된 시간제목작성자상태관리
    + 공개 + 비공개 + + 내용 보기 + 이 버전으로 복구 +
    저장된 히스토리가 없습니다.
    +
    +
    +
    +
    +
    + \ No newline at end of file