....
This commit is contained in:
parent
7af46ac655
commit
74e88d7d89
@ -55,63 +55,51 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// implementation ("jakarta.servlet:jakarta.servlet-api") //스프링부트 3.0 이상
|
// [추가] Kotlin BOM(Bill of Materials)을 사용하여 모든 코틀린 라이브러리 버전을 정렬합니다.
|
||||||
// implementation ("jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api") //스프링부트 3.0 이상
|
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.25"))
|
||||||
// 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")
|
|
||||||
|
|
||||||
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.apache.tomcat.embed:tomcat-embed-jasper")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
|
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-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||||
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
|
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
|
||||||
|
|
||||||
implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect")
|
implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect")
|
||||||
implementation ("org.jsoup:jsoup:1.18.1")
|
implementation ("org.jsoup:jsoup:1.18.1")
|
||||||
|
|
||||||
implementation ("org.seleniumhq.selenium:selenium-java:4.10.0")
|
implementation ("org.seleniumhq.selenium:selenium-java:4.10.0")
|
||||||
|
|
||||||
implementation ("org.commonmark:commonmark:0.18.0")
|
implementation ("org.commonmark:commonmark:0.18.0")
|
||||||
implementation ("net.coobird:thumbnailator:0.4.14")
|
implementation ("net.coobird:thumbnailator:0.4.14")
|
||||||
|
|
||||||
implementation("org.sejda.imageio:webp-imageio:0.1.6")
|
implementation("org.sejda.imageio:webp-imageio:0.1.6")
|
||||||
implementation ("com.drewnoakes:metadata-extractor:2.19.0")
|
implementation ("com.drewnoakes:metadata-extractor:2.19.0")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||||
compileOnly("org.projectlombok:lombok")
|
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 ("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(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-ollama-spring-boot-starter:1.0.0-M6")
|
||||||
implementation ("org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter")
|
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 ("org.slf4j:slf4j-simple:1.7.25")
|
||||||
|
|
||||||
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
|
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
|
||||||
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
|
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
|
||||||
implementation("io.jsonwebtoken:jjwt-jackson: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")
|
annotationProcessor("org.projectlombok:lombok")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("io.projectreactor:reactor-test")
|
testImplementation("io.projectreactor:reactor-test")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
|
||||||
// JSON 처리를 위한 Gson 라이브러리
|
|
||||||
implementation("com.google.code.gson:gson:2.10.1")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
@ -71,6 +72,7 @@ data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", va
|
|||||||
class BlogController(
|
class BlogController(
|
||||||
// 생성자를 통해 모든 서비스 의존성을 주입받습니다 (Spring 권장 방식).
|
// 생성자를 통해 모든 서비스 의존성을 주입받습니다 (Spring 권장 방식).
|
||||||
private val postManager: PostManager,
|
private val postManager: PostManager,
|
||||||
|
private val postHistoryManager: PostHistoryManager,
|
||||||
private val imageMetaService: ImageMetaService,
|
private val imageMetaService: ImageMetaService,
|
||||||
private val logService: LogService,
|
private val logService: LogService,
|
||||||
private val commentService: CommentService,
|
private val commentService: CommentService,
|
||||||
@ -89,6 +91,79 @@ class BlogController(
|
|||||||
private data class Delta(val ops: List<DeltaOp>)
|
private data class Delta(val ops: List<DeltaOp>)
|
||||||
|
|
||||||
|
|
||||||
|
data class GibberishRequest(val content: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규 추가] 게시물을 영구적으로 삭제하는 API입니다.
|
||||||
|
* 작성자 또는 관리자만 이 작업을 수행할 수 있습니다.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/blog/post/{postId}")
|
||||||
|
@ResponseBody
|
||||||
|
suspend fun deletePost(
|
||||||
|
@PathVariable postId: String,
|
||||||
|
@AuthenticationPrincipal user: UserDetails?
|
||||||
|
): ResponseEntity<Map<String, String>> {
|
||||||
|
// 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<ResponseEntity<Any>> {
|
||||||
|
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 ---
|
// --- Private Helper Methods ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -323,7 +398,13 @@ class BlogController(
|
|||||||
// 2. 최종 경로가 유효하면 모델에 추가하고, 아니면 기본 이미지를 사용합니다.
|
// 2. 최종 경로가 유효하면 모델에 추가하고, 아니면 기본 이미지를 사용합니다.
|
||||||
vm.modelMap["randomBannerImage"] = if (!bannerImagePath.isNullOrBlank()) bannerImagePath else defaultBannerImage
|
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<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
|
val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
|
||||||
vm.modelMap["Posts"] = postsList.map { processPostForView(it) }
|
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}")
|
logService.log("Access denied for user ${userDetails?.username} to post ${post.id}")
|
||||||
return ResultMV("redirect:/blog/posts")
|
return ResultMV("redirect:/blog/posts")
|
||||||
}
|
}
|
||||||
|
val processedPost = processPostForView(post)
|
||||||
vm.modelMap["srcPost"] = post
|
vm.modelMap["srcPost"] = processedPost
|
||||||
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(post)
|
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return ResultMV("redirect:/")
|
return ResultMV("redirect:/")
|
||||||
}
|
}
|
||||||
@ -578,61 +659,81 @@ class BlogController(
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/blog/post.bjx")
|
@PostMapping("/blog/post.bjx")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
|
@Transactional // ★ 데이터 일관성을 위해 트랜잭션 어노테이션 추가
|
||||||
suspend fun savePost(
|
suspend fun savePost(
|
||||||
@RequestBody rawPayload: String,
|
@RequestBody rawPayload: String,
|
||||||
@AuthenticationPrincipal user: UserDetails?
|
@AuthenticationPrincipal user: UserDetails?
|
||||||
): Mono<PostSaveResponse> {
|
): PostSaveResponse {
|
||||||
if (user == null) {
|
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)
|
val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper)
|
||||||
|
|
||||||
|
return if (incomingPost.id.isNullOrBlank()) {
|
||||||
// 새 글 작성
|
// 새 글 작성
|
||||||
if (incomingPost.id.isNullOrBlank()) {
|
|
||||||
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
|
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
|
||||||
val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" }
|
val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" }
|
||||||
if (!isAdmin && !canWrite) {
|
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.writer = user.username
|
||||||
incomingPost.writeTime = System.currentTimeMillis()
|
incomingPost.writeTime = System.currentTimeMillis()
|
||||||
incomingPost.modifyTime = incomingPost.writeTime
|
incomingPost.modifyTime = incomingPost.writeTime
|
||||||
return postManager.save(incomingPost).flatMap { savedPost ->
|
|
||||||
savedPost.originId = savedPost.id
|
val savedPost = postManager.save(incomingPost).awaitSingle()
|
||||||
postManager.save(savedPost)
|
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
|
||||||
}.map { finalPost -> PostSaveResponse(0, "Success", PostIdData(finalPost.id!!)) }
|
|
||||||
}
|
} else {
|
||||||
// 기존 글 수정 (새 버전 생성)
|
// --- [완전히 새로 작성된] 기존 글 수정 로직 ---
|
||||||
else {
|
|
||||||
return postManager.findById(incomingPost.id!!).flatMap { originalPost ->
|
// 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 isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
|
||||||
val isWriter = user.username == originalPost.writer
|
val isWriter = user.username == originalPost.writer
|
||||||
if (!isAdmin && !isWriter) {
|
if (!isAdmin && !isWriter) {
|
||||||
return@flatMap Mono.just(PostSaveResponse(403, "Permission denied to update post", null))
|
return PostSaveResponse(403, "Permission denied to update post", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val newVersion = incomingPost.copy(
|
// 2. 원본 데이터를 기반으로 PostHistory 객체를 생성합니다.
|
||||||
id = null,
|
val history = PostHistory(
|
||||||
originId = originalPost.originId ?: originalPost.id,
|
postId = originalPost.id!!,
|
||||||
|
title = originalPost.title,
|
||||||
|
content = originalPost.content,
|
||||||
|
category = originalPost.category,
|
||||||
|
tags = originalPost.tags,
|
||||||
writer = originalPost.writer,
|
writer = originalPost.writer,
|
||||||
writeTime = originalPost.writeTime,
|
writeTime = originalPost.writeTime,
|
||||||
readCount = originalPost.readCount,
|
posting = originalPost.posting,
|
||||||
voteCount = originalPost.voteCount,
|
// ... originalPost의 모든 필드를 복사 ...
|
||||||
unlikeCount = originalPost.unlikeCount,
|
modifyTime = originalPost.modifyTime
|
||||||
modifyTime = System.currentTimeMillis(),
|
|
||||||
postType = originalPost.postType // [추가] 기존 postType을 새 버전에 복사
|
|
||||||
)
|
)
|
||||||
postManager.save(newVersion).map { savedPost ->
|
|
||||||
|
// 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!!))
|
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
|
||||||
}
|
}
|
||||||
}.switchIfEmpty(
|
|
||||||
Mono.just(PostSaveResponse(404, "Original post to edit not found", null))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// [신규] 게시물 차단 API (관리자 전용)
|
// [신규] 게시물 차단 API (관리자 전용)
|
||||||
@PostMapping("/blog/post/{postId}/block")
|
@PostMapping("/blog/post/{postId}/block")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import com.google.gson.Gson
|
|||||||
import com.google.protobuf.LazyStringArrayList.emptyList
|
import com.google.protobuf.LazyStringArrayList.emptyList
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
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
|
||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
|
||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
||||||
@ -37,9 +39,10 @@ import java.util.*
|
|||||||
import javax.naming.AuthenticationException
|
import javax.naming.AuthenticationException
|
||||||
import kotlin.collections.emptyList
|
import kotlin.collections.emptyList
|
||||||
import kr.lunaticbum.back.lun.model.Message
|
import kr.lunaticbum.back.lun.model.Message
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
|
|
||||||
@RestController
|
@Controller
|
||||||
@RequestMapping("/user")
|
@RequestMapping("/user")
|
||||||
class UserController(
|
class UserController(
|
||||||
private val rememberMeServices: RememberMeServices,
|
private val rememberMeServices: RememberMeServices,
|
||||||
@ -50,7 +53,9 @@ class UserController(
|
|||||||
private val messageService: MessageService,
|
private val messageService: MessageService,
|
||||||
private val webBookmarkService: WebBookmarkService,
|
private val webBookmarkService: WebBookmarkService,
|
||||||
private val imageMetaService: ImageMetaService,
|
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
|
@Autowired
|
||||||
lateinit var logService: LogService
|
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")
|
@GetMapping("join.bs")
|
||||||
fun hello(httpServletRequest: HttpServletRequest): ResultMV {
|
fun hello(httpServletRequest: HttpServletRequest): ResultMV {
|
||||||
logService.log("onJoin")
|
logService.log("onJoin")
|
||||||
@ -243,6 +268,17 @@ class UserController(
|
|||||||
.bodyToMono(String::class.java).block() ?: "FAIL"
|
.bodyToMono(String::class.java).block() ?: "FAIL"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/migrate-posts")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
fun runPostMigration(): Mono<ResponseEntity<MigrationReport>> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [수정] '내 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가)
|
* [수정] '내 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가)
|
||||||
*/
|
*/
|
||||||
|
|||||||
110
src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt
Normal file
110
src/main/kotlin/kr/lunaticbum/back/lun/model/MigrationReport.kt
Normal file
@ -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<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class MigrationService(
|
||||||
|
private val mongoTemplate: ReactiveMongoTemplate,
|
||||||
|
private val postRepository: PostRepository, // 새 Post Repository
|
||||||
|
private val postHistoryRepository: PostHistoryRepository // 새 PostHistory Repository
|
||||||
|
) {
|
||||||
|
// Mono<MigrationReport>를 반환하여 비동기 작업의 결과를 컨트롤러에 전달
|
||||||
|
fun migratePosts(): Mono<MigrationReport> {
|
||||||
|
// 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<Map<String, Any>> ?: return@flatMap Mono.empty<Void>()
|
||||||
|
|
||||||
|
if (versions.isEmpty()) {
|
||||||
|
return@flatMap Mono.empty<Pair<Int, Int>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Int,Int>).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<String, Any>): 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<String, Any>, 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의 모든 필드를 안전하게 변환 ...
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -48,9 +48,61 @@ import java.util.ArrayList
|
|||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Date
|
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<PostHistory, String> {
|
||||||
|
// [추가] postId로 모든 히스토리를 최신순으로 조회
|
||||||
|
fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux<PostHistory>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PostHistory를 위한 Service 클래스
|
||||||
|
@Service
|
||||||
|
class PostHistoryManager(private val repository: PostHistoryRepository) {
|
||||||
|
fun save(postHistory: PostHistory): Mono<PostHistory> {
|
||||||
|
return repository.save(postHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [추가] postId로 모든 히스토리를 조회하는 함수
|
||||||
|
fun findByPostId(postId: String): Flux<PostHistory> {
|
||||||
|
return repository.findByPostIdOrderByArchivedAtDesc(postId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class PostType {
|
enum class PostType {
|
||||||
STANDARD, // 일반 블로그 글
|
STANDARD, // 일반 블로그 글
|
||||||
ABOUT_SITE // 사이트 소개 글
|
ABOUT_SITE, // 사이트 소개 글
|
||||||
|
GIBBERISH
|
||||||
}
|
}
|
||||||
|
|
||||||
@Document(collection = "Post")
|
@Document(collection = "Post")
|
||||||
@ -174,24 +226,54 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
|||||||
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
|
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
|
||||||
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
|
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
|
||||||
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
|
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
|
||||||
|
|
||||||
|
// [단순화] 공개된 글 목록 조회 (페이지네이션)
|
||||||
|
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
|
||||||
|
|
||||||
|
// [단순화] 공개된 글 개수 카운트
|
||||||
|
fun countByPostingIsTrue(): Mono<Long>
|
||||||
|
|
||||||
|
// [단순화] 인기글 5개 조회 (공개된 글 대상)
|
||||||
|
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
|
||||||
|
|
||||||
|
// [단순화] 최신글 5개 조회 (공개된 글 대상)
|
||||||
|
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
|
||||||
|
|
||||||
|
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
|
||||||
|
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
|
||||||
|
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
|
||||||
|
|
||||||
|
|
||||||
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
|
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
|
||||||
@Aggregation(pipeline = [
|
@Aggregation(pipeline = [
|
||||||
"{ \$match: { posting: true, isBlocked: false } }", // [수정됨]
|
// 1. 모든 글을 최신순으로 정렬
|
||||||
"{ \$sort: { modifyTime: -1 } }", // 2. 최신 버전이 먼저 오도록 정렬
|
"{ \$sort: { modifyTime: -1 } }",
|
||||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", // 3. 고유 포스트 그룹화
|
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
|
||||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }", // 4. 그룹화된 문서를 원래 형태로 복원
|
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||||
"{ \$sort: { readCount: -1 } }", // 5. 조회수 순으로 정렬
|
// 3. 원래 Post 형태로 복원
|
||||||
"{ \$limit: 5 }" // 6. 상위 5개만 선택
|
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||||
|
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
|
||||||
|
"{ \$match: { posting: true, isBlocked: false } }",
|
||||||
|
// 5. 최종 목록을 조회수(readCount) 순으로 정렬
|
||||||
|
"{ \$sort: { readCount: -1 } }",
|
||||||
|
// 6. 상위 5개만 선택
|
||||||
|
"{ \$limit: 5 }"
|
||||||
])
|
])
|
||||||
fun findTop5UniquePublishedByReadCountDesc(): Flux<Post>
|
fun findTop5UniquePublishedByReadCountDesc(): Flux<Post>
|
||||||
|
|
||||||
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
|
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
|
||||||
@Aggregation(pipeline = [
|
@Aggregation(pipeline = [
|
||||||
"{ \$match: { posting: true, isBlocked: false } }", // [수정됨]
|
// 1. 모든 글을 최신순으로 정렬
|
||||||
"{ \$sort: { modifyTime: -1 } }",
|
"{ \$sort: { modifyTime: -1 } }",
|
||||||
|
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
|
||||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||||
|
// 3. 원래 Post 형태로 복원
|
||||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||||
"{ \$sort: { modifyTime: -1 } }", // 최신순으로 다시 정렬
|
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
|
||||||
|
"{ \$match: { posting: true, isBlocked: false } }",
|
||||||
|
// 5. 최종 목록을 다시 최신순으로 정렬
|
||||||
|
"{ \$sort: { modifyTime: -1 } }",
|
||||||
|
// 6. 상위 5개만 선택
|
||||||
"{ \$limit: 5 }"
|
"{ \$limit: 5 }"
|
||||||
])
|
])
|
||||||
fun findTop5UniquePublishedByModifyTimeDesc(): Flux<Post>
|
fun findTop5UniquePublishedByModifyTimeDesc(): Flux<Post>
|
||||||
@ -237,15 +319,19 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다.
|
|
||||||
* [수정] posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
* [수정] posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
||||||
|
* [[[[[ FIXED LOGIC]]]]]
|
||||||
*/
|
*/
|
||||||
@Aggregation(pipeline = [
|
@Aggregation(pipeline = [
|
||||||
|
// 1. Sort ALL posts first to find the absolute most recent version of each.
|
||||||
"{ \$sort: { modifyTime: -1 } }",
|
"{ \$sort: { modifyTime: -1 } }",
|
||||||
|
// 2. Group by the original ID to get only the latest version.
|
||||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||||
|
// 3. Restore the post document structure.
|
||||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||||
// [[[[[ 신규 추가된 라인 ]]]]]
|
// 4. NOW, filter this list of latest posts to show only the public ones.
|
||||||
"{ \$match: { posting: true } }",
|
"{ \$match: { posting: true } }",
|
||||||
|
// 5. Finally, sort the remaining public posts for display.
|
||||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||||
])
|
])
|
||||||
fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux<Post> // 메서드 이름 변경
|
fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux<Post> // 메서드 이름 변경
|
||||||
@ -253,19 +339,31 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
|||||||
/**
|
/**
|
||||||
* '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다.
|
* '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다.
|
||||||
* [수정] posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
* [수정] posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
||||||
|
* [[[[[FIXED LOGIC]]]]]
|
||||||
*/
|
*/
|
||||||
@Aggregation(pipeline = [
|
@Aggregation(pipeline = [
|
||||||
|
// 1. Sort ALL posts.
|
||||||
"{ \$sort: { modifyTime: -1 } }",
|
"{ \$sort: { modifyTime: -1 } }",
|
||||||
|
// 2. Group to get the latest version of each.
|
||||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||||
|
// 3. Restore the document.
|
||||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||||
// [[[[[ 신규 추가된 라인 ]]]]]
|
// 4. Filter for public posts.
|
||||||
"{ \$match: { posting: true } }",
|
"{ \$match: { posting: true } }",
|
||||||
|
// 5. Count the final result.
|
||||||
"{ \$count: \"totalCount\" }"
|
"{ \$count: \"totalCount\" }"
|
||||||
])
|
])
|
||||||
fun countLatestUniquePublished(): Mono<AggregationCount> // 메서드 이름 변경
|
fun countLatestUniquePublished(): Mono<AggregationCount> // 메서드 이름 변경
|
||||||
|
|
||||||
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
|
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
|
||||||
|
|
||||||
|
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
|
||||||
|
@Aggregation(pipeline = [
|
||||||
|
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
|
||||||
|
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
|
||||||
|
])
|
||||||
|
fun findRandomPublishedPostByType(postType: String): Mono<Post>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -280,6 +378,60 @@ class PostManager(
|
|||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var bCryptPasswordEncoder: PasswordEncoder
|
private lateinit var bCryptPasswordEncoder: PasswordEncoder
|
||||||
|
|
||||||
|
fun deletePost(postId: String): Mono<Void> {
|
||||||
|
return postRepository.deleteById(postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] 익명 사용자용 목록 조회
|
||||||
|
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> {
|
||||||
|
return postRepository.findByPostingIsTrueOrderByModifyTimeDesc(pageable)
|
||||||
|
.collectList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] 익명 사용자용 글 개수
|
||||||
|
fun countLatestUnique(): Mono<Long> {
|
||||||
|
return postRepository.countByPostingIsTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] '글쓰기' 권한 사용자용 목록 조회
|
||||||
|
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
||||||
|
return postRepository.findByPostingIsTrueOrWriterOrderByModifyTimeDesc(username, pageable)
|
||||||
|
.collectList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] '글쓰기' 권한 사용자용 글 개수
|
||||||
|
fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
||||||
|
return postRepository.countByPostingIsTrueOrWriter(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] 익명 사용자용 인기글
|
||||||
|
fun getTop5UniquePublishedByViews(): Flux<Post> {
|
||||||
|
return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p ->
|
||||||
|
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||||
|
if (p.title?.isEmpty() == true) {
|
||||||
|
p.title = "무제(無題)"
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] 익명 사용자용 최신글
|
||||||
|
fun getRecent5UniquePublished(): Flux<Post> {
|
||||||
|
return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map {
|
||||||
|
p ->
|
||||||
|
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||||
|
if (p.title?.isEmpty() == true) {
|
||||||
|
p.title = "무제(無題)"
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
|
||||||
|
fun findRandomGibberish(): Mono<Post> {
|
||||||
|
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
|
||||||
|
}
|
||||||
|
|
||||||
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
|
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
|
||||||
fun findLatestAboutPost(): Mono<Post> {
|
fun findLatestAboutPost(): Mono<Post> {
|
||||||
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
|
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
|
||||||
@ -312,13 +464,13 @@ class PostManager(
|
|||||||
return postRepository.findById(id)
|
return postRepository.findById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회)
|
// * [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회)
|
||||||
*/
|
// */
|
||||||
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
// fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
||||||
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
|
// return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
|
||||||
.collectList()
|
// .collectList()
|
||||||
}
|
// }
|
||||||
|
|
||||||
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
|
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
|
||||||
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
|
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
|
||||||
@ -332,14 +484,14 @@ class PostManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수
|
// * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수
|
||||||
*/
|
// */
|
||||||
fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
// fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
||||||
return postRepository.countLatestUniqueForWriter(username)
|
// return postRepository.countLatestUniqueForWriter(username)
|
||||||
.map { it.totalCount }
|
// .map { it.totalCount }
|
||||||
.switchIfEmpty(Mono.just(0L))
|
// .switchIfEmpty(Mono.just(0L))
|
||||||
}
|
// }
|
||||||
|
|
||||||
fun getPost(id: String): Mono<Post> {
|
fun getPost(id: String): Mono<Post> {
|
||||||
val query = Query.query(Criteria.where("id").`is`(id))
|
val query = Query.query(Criteria.where("id").`is`(id))
|
||||||
@ -373,14 +525,14 @@ class PostManager(
|
|||||||
.collectList()
|
.collectList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회)
|
// * 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회)
|
||||||
* [FIX]: This function should already be correct from the previous step.
|
// * [FIX]: This function should already be correct from the previous step.
|
||||||
*/
|
// */
|
||||||
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> { // <-- Should already return Mono
|
// fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> { // <-- Should already return Mono
|
||||||
return postRepository.findLatestUniquePublishedPaginated(pageable)
|
// return postRepository.findLatestUniquePublishedPaginated(pageable)
|
||||||
.collectList()
|
// .collectList()
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증된 사용자가 보는 글의 총 개수
|
* 인증된 사용자가 보는 글의 총 개수
|
||||||
@ -389,15 +541,15 @@ class PostManager(
|
|||||||
return postRepository.countByOrderByModifyTimeDesc()
|
return postRepository.countByOrderByModifyTimeDesc()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* 익명 사용자가 보는 글의 총 개수
|
// * 익명 사용자가 보는 글의 총 개수
|
||||||
*/
|
// */
|
||||||
fun countLatestUnique(): Mono<Long> {
|
// fun countLatestUnique(): Mono<Long> {
|
||||||
// AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다.
|
// // AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다.
|
||||||
return postRepository.countLatestUniquePublished()
|
// return postRepository.countLatestUniquePublished()
|
||||||
.map { it.totalCount }
|
// .map { it.totalCount }
|
||||||
.switchIfEmpty(Mono.just(0L))
|
// .switchIfEmpty(Mono.just(0L))
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [신규 추가]
|
* [신규 추가]
|
||||||
@ -495,27 +647,28 @@ class PostManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [신규 추가] 익명 사용자용 인기글
|
// // [신규 추가] 익명 사용자용 인기글
|
||||||
fun getTop5UniquePublishedByViews(): Flux<Post> {
|
// fun getTop5UniquePublishedByViews(): Flux<Post> {
|
||||||
return postRepository.findTop5UniquePublishedByReadCountDesc().map { p ->
|
// return postRepository.findTop5UniquePublishedByReadCountDesc().map { p ->
|
||||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
// p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||||
if (p.title?.isEmpty() == true) {
|
// if (p.title?.isEmpty() == true) {
|
||||||
p.title = "무제(無題)"
|
// p.title = "무제(無題)"
|
||||||
}
|
// }
|
||||||
p
|
// println("${p.id} p.posting >> ${p.posting}")
|
||||||
}
|
// p
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// [신규 추가] 익명 사용자용 최신글
|
// // [신규 추가] 익명 사용자용 최신글
|
||||||
fun getRecent5UniquePublished(): Flux<Post> {
|
// fun getRecent5UniquePublished(): Flux<Post> {
|
||||||
return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p ->
|
// return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p ->
|
||||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
// p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||||
if (p.title?.isEmpty() == true) {
|
// if (p.title?.isEmpty() == true) {
|
||||||
p.title = "무제(無題)"
|
// p.title = "무제(無題)"
|
||||||
}
|
// }
|
||||||
p
|
// p
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -585,6 +738,11 @@ object PayloadDecoder {
|
|||||||
odd = odd.reversed()
|
odd = odd.reversed()
|
||||||
even = even.reversed()
|
even = even.reversed()
|
||||||
}
|
}
|
||||||
|
// [추가] 예외적인 type에 대한 기본 처리(안전장치)
|
||||||
|
else -> {
|
||||||
|
odd = odd.reversed()
|
||||||
|
even = even.reversed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// odd와 even 문자열을 다시 조합하여 원본 데이터 생성
|
// odd와 even 문자열을 다시 조합하여 원본 데이터 생성
|
||||||
|
|||||||
@ -64,7 +64,6 @@ data class PersistentLogin(
|
|||||||
interface PersistentLoginRepository : ReactiveMongoRepository<PersistentLogin, String> {
|
interface PersistentLoginRepository : ReactiveMongoRepository<PersistentLogin, String> {
|
||||||
fun findByUsername(username: String): Flux<PersistentLogin>
|
fun findByUsername(username: String): Flux<PersistentLogin>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class MongoPersistentTokenRepository (
|
class MongoPersistentTokenRepository (
|
||||||
private val repository: PersistentLoginRepository
|
private val repository: PersistentLoginRepository
|
||||||
@ -77,33 +76,43 @@ class MongoPersistentTokenRepository (
|
|||||||
tokenValue = token.tokenValue,
|
tokenValue = token.tokenValue,
|
||||||
lastUsed = token.date
|
lastUsed = token.date
|
||||||
)
|
)
|
||||||
repository.save(persistentLogin).block() // 블로킹 여부는 환경에 따라 조절
|
// [수정] .block() 대신 .subscribe()를 사용하여 비동기 실행
|
||||||
println("CALLED rememberMeServices")
|
repository.save(persistentLogin).subscribe()
|
||||||
|
println("CALLED rememberMeServices: createNewToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateToken(series: String, tokenValue: String, lastUsed: Date) {
|
override fun updateToken(series: String, tokenValue: String, lastUsed: Date) {
|
||||||
val login = repository.findById(series).block()
|
// [수정] .block() 대신 .flatMap과 .subscribe()를 사용
|
||||||
if (login != null) {
|
repository.findById(series).flatMap { login ->
|
||||||
val updated = login.copy(tokenValue = tokenValue, lastUsed = lastUsed)
|
val updated = login.copy(tokenValue = tokenValue, lastUsed = lastUsed)
|
||||||
repository.save(updated).block()
|
repository.save(updated)
|
||||||
println("CALLED rememberMeServices")
|
}.subscribe()
|
||||||
}
|
println("CALLED rememberMeServices: updateToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTokenForSeries(seriesId: String): PersistentRememberMeToken? {
|
override fun getTokenForSeries(seriesId: String): PersistentRememberMeToken? {
|
||||||
|
// [주의] 이 인터페이스 메소드는 동기(blocking) 반환을 요구하므로,
|
||||||
|
// 어쩔 수 없이 .block()을 사용해야 합니다. 하지만,
|
||||||
|
// 자동 로그인은 메인 요청 흐름에 덜 치명적이므로 이 부분은 유지합니다.
|
||||||
|
// 근본적인 해결을 위해서는 Spring Security의 ReactivePersistentTokenRepository 사용이 필요합니다.
|
||||||
val login = repository.findById(seriesId).block()
|
val login = repository.findById(seriesId).block()
|
||||||
return login?.let {
|
return login?.let {
|
||||||
println("CALLED rememberMeServices")
|
println("CALLED rememberMeServices: getTokenForSeries")
|
||||||
PersistentRememberMeToken(it.username, it.series, it.tokenValue, it.lastUsed)
|
PersistentRememberMeToken(it.username, it.series, it.tokenValue, it.lastUsed)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeUserTokens(username: String) {
|
override fun removeUserTokens(username: String) {
|
||||||
val tokens = repository.findByUsername(username).collectList().block()
|
// [수정] .block() 대신 .flatMap과 .subscribe()를 사용
|
||||||
tokens?.let {
|
repository.findByUsername(username)
|
||||||
println("CALLED rememberMeServices")
|
.collectList()
|
||||||
repository.deleteAll(it).block()
|
.flatMap { tokens ->
|
||||||
}
|
if (tokens.isNotEmpty()) {
|
||||||
|
repository.deleteAll(tokens)
|
||||||
|
} else {
|
||||||
|
Mono.empty()
|
||||||
|
}
|
||||||
|
}.subscribe()
|
||||||
|
println("CALLED rememberMeServices: removeUserTokens")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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-idle-time=0
|
||||||
spring.data.mongodb.option.max-connection-life-time=0
|
spring.data.mongodb.option.max-connection-life-time=0
|
||||||
spring.data.mongodb.option.connect-timeout=10000
|
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.socket-keep-alive=false
|
||||||
spring.data.mongodb.option.ssl-enabled=false
|
spring.data.mongodb.option.ssl-enabled=false
|
||||||
|
|||||||
@ -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-idle-time=0
|
||||||
spring.data.mongodb.option.max-connection-life-time=0
|
spring.data.mongodb.option.max-connection-life-time=0
|
||||||
spring.data.mongodb.option.connect-timeout=10000
|
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.socket-keep-alive=false
|
||||||
spring.data.mongodb.option.ssl-enabled=false
|
spring.data.mongodb.option.ssl-enabled=false
|
||||||
|
|||||||
@ -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-idle-time=0
|
||||||
spring.data.mongodb.option.max-connection-life-time=0
|
spring.data.mongodb.option.max-connection-life-time=0
|
||||||
spring.data.mongodb.option.connect-timeout=10000
|
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.socket-keep-alive=false
|
||||||
spring.data.mongodb.option.ssl-enabled=false
|
spring.data.mongodb.option.ssl-enabled=false
|
||||||
@ -95,6 +95,7 @@ spring.jpa.show-sql=true
|
|||||||
spring.jpa.properties.hibernate.format_sql=true
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
logging.level.org.hibernate.SQL=DEBUG
|
logging.level.org.hibernate.SQL=DEBUG
|
||||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
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)
|
# Increase server connection timeout to 60 seconds (default is often 20 or 30s)
|
||||||
server.tomcat.connection-timeout=60s
|
server.tomcat.connection-timeout=60s
|
||||||
@ -105,4 +106,3 @@ api.base-url=ss
|
|||||||
build.config.run=local
|
build.config.run=local
|
||||||
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
||||||
jwt.expiration=86400000
|
jwt.expiration=86400000
|
||||||
logging.level.org.springframework.security=DEBUG
|
|
||||||
@ -3524,7 +3524,7 @@ a.btn_layerClose:hover {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
}
|
}
|
||||||
|
#post-published-switch,
|
||||||
#rememberMe {
|
#rememberMe {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@ -3538,12 +3538,12 @@ a.btn_layerClose:hover {
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: -2px;
|
top: -2px;
|
||||||
}
|
}
|
||||||
|
#post-published-switch:checked,
|
||||||
#rememberMe:checked {
|
#rememberMe:checked {
|
||||||
background-color: var(--point-color, #FFA500);
|
background-color: var(--point-color, #FFA500);
|
||||||
border-color: var(--point-color, #FFA500);
|
border-color: var(--point-color, #FFA500);
|
||||||
}
|
}
|
||||||
|
#post-published-switch:checked::after,
|
||||||
#rememberMe:checked::after {
|
#rememberMe:checked::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -589,7 +589,7 @@ function loadEditor() {
|
|||||||
* 작성된 게시물을 서버에 저장합니다. (바닐라 JS)
|
* 작성된 게시물을 서버에 저장합니다. (바닐라 JS)
|
||||||
* (이 함수는 이미 바닐라 XHR을 사용하는 post() 헬퍼 함수를 쓰므로 수정 불필요)
|
* (이 함수는 이미 바닐라 XHR을 사용하는 post() 헬퍼 함수를 쓰므로 수정 불필요)
|
||||||
*/
|
*/
|
||||||
function save() {
|
async function save() {
|
||||||
const titleField = document.getElementById('title_field');
|
const titleField = document.getElementById('title_field');
|
||||||
|
|
||||||
// 1. 원본 baseData를 복사하여 전송용 객체(dataToSend) 생성
|
// 1. 원본 baseData를 복사하여 전송용 객체(dataToSend) 생성
|
||||||
@ -619,17 +619,18 @@ function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadUrl = `${getMainPath()}/blog/post.bjx`;
|
const uploadUrl = `${getMainPath()}/blog/post.bjx`;
|
||||||
if (showConfirm("확인","해당 내용으로 저장하시겠습니까?")) {
|
if (await showConfirm("확인","해당 내용으로 저장하시겠습니까?")) {
|
||||||
console.log("Data being sent to server:", dataToSend);
|
console.log("Data being sent to server:", dataToSend);
|
||||||
|
|
||||||
// 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용)
|
// 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 {
|
try {
|
||||||
const response = JSON.parse(resultData);
|
const response = JSON.parse(resultData);
|
||||||
if (response.resultCode === 0 && response.data && response.data.postId) {
|
if (response.resultCode === 0 && response.data && response.data.postId) {
|
||||||
// 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동
|
// 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동
|
||||||
showAlert("알림","저장되었습니다. 게시물 보기 페이지로 이동합니다.");
|
if (await showConfirm("알림", "저장되었습니다. 게시물 보기 페이지로 이동합니다.")) {
|
||||||
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
|
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showAlert("알림", "저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
|
showAlert("알림", "저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
|
||||||
}
|
}
|
||||||
@ -823,7 +824,7 @@ function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`)
|
|||||||
function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); }
|
function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); }
|
||||||
function gotoSudoKuGen() { document.location.replace(`${getMainPath()}/puzzle/sudoku_gen.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_id = document.getElementById('user_id')
|
||||||
let user_pw = document.getElementById('user_pw')
|
let user_pw = document.getElementById('user_pw')
|
||||||
let user_pw_check = document.getElementById('user_pw_check')
|
let user_pw_check = document.getElementById('user_pw_check')
|
||||||
@ -885,7 +886,7 @@ function onclickJoin(type, keyword) {
|
|||||||
'user_name': user_name.value
|
'user_name': user_name.value
|
||||||
}
|
}
|
||||||
if (user_pw.value === user_pw_check.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) {
|
post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) {
|
||||||
showAlert("알림",resultData)
|
showAlert("알림",resultData)
|
||||||
})
|
})
|
||||||
@ -2017,6 +2018,44 @@ async function openBookmarkEditPopup(buttonElement) {
|
|||||||
showAlert('오류', error.message, 'error');
|
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를 즉시 변경합니다.
|
* [수정된 함수] 이미지의 '보임/숨김' 상태를 서버에 업데이트하고 UI를 즉시 변경합니다.
|
||||||
* @param {HTMLElement} button - 클릭된 버튼 요소 (this)
|
* @param {HTMLElement} button - 클릭된 버튼 요소 (this)
|
||||||
@ -2165,3 +2204,62 @@ async function deleteBookmarkImage(bookmarkId, imageUrl, buttonElement) {
|
|||||||
showAlert('오류', error.message, 'error');
|
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('삭제 중 오류가 발생했습니다.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,11 +29,10 @@
|
|||||||
<p th:if="${srcPost.writeTime != null and srcPost.writeTime > 0}"
|
<p th:if="${srcPost.writeTime != null and srcPost.writeTime > 0}"
|
||||||
th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')}"></p>
|
th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')}"></p>
|
||||||
</header>
|
</header>
|
||||||
<div style="text-align: right; margin-bottom: 2em;">
|
<div style="text-align: right; margin-bottom: 2em; display: flex; align-items: center; justify-content: flex-end;">
|
||||||
<label for="post-published-switch" style="font-weight: bold; color: #555; vertical-align: middle; margin-right: 10px;">
|
<span style="font-weight: bold; color: #555; margin-right: 10px;">게시물 공개</span>
|
||||||
게시물 공개
|
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" class="custom-checkbox" />
|
||||||
</label>
|
<label for="post-published-switch" class="custom-label"></label>
|
||||||
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
|
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
|
||||||
<div class="write_option btn-example controlbox-category" to="#popLayer1">
|
<div class="write_option btn-example controlbox-category" to="#popLayer1">
|
||||||
@ -54,8 +53,13 @@
|
|||||||
<th:block sec:authorize="isAuthenticated()">
|
<th:block sec:authorize="isAuthenticated()">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
|
<div style="display: flex; gap: 1em; margin-top: 1em;">
|
||||||
<button id="save" class="button fit" style="margin-top: 1em;" onclick="save()">저장하기</button>
|
<button id="save" class="button fit primary" onclick="save()">저장하기</button>
|
||||||
|
<button id="delete" class="button fit alt"
|
||||||
|
th:attr="data-post-id=${srcPost.id}"
|
||||||
|
onclick="deleteCurrentPost(this)"
|
||||||
|
th:if="${srcPost.id != null}">삭제하기</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -7,7 +7,11 @@
|
|||||||
<th:block layout:fragment="content" id="content">
|
<th:block layout:fragment="content" id="content">
|
||||||
<section id="banner"
|
<section id="banner"
|
||||||
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''">
|
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''">
|
||||||
<header>
|
<header th:if="${gibberish != null}">
|
||||||
|
<h2>Bum's Gibberish: <em>[[${gibberish}]]</em></h2>
|
||||||
|
<a th:href="@{/blog/viewer/{id}(id=${gibberishId})}" class="button">코멘트 남기기<br>[Leave a Comment]</a>
|
||||||
|
</header>
|
||||||
|
<header th:if="${gibberish == null}">
|
||||||
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
|
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
|
||||||
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
||||||
</header>
|
</header>
|
||||||
@ -102,10 +106,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="wrapper style1" sec:authorize="isAuthenticated()">
|
||||||
|
<div class="container">
|
||||||
|
<div id="gibberish-form" class="box post">
|
||||||
|
<h4>오늘의 Gibberish 남기기 (100자 이내)</h4>
|
||||||
|
<textarea id="gibberish-content" rows="3" maxlength="100" placeholder="문득 떠오른 생각을 적어보세요..."></textarea>
|
||||||
|
<button class="button" style="margin-top: 1em;" onclick="submitGibberish()">등록</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section id="cta2" class="wrapper style3">
|
<section id="cta2" class="wrapper style3">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h2>Are you ready to continue your quest?</h2>
|
<!-- <h2>Are you ready to continue your quest?</h2>-->
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}">
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}">
|
||||||
<head>
|
<head>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@ -11,7 +11,7 @@
|
|||||||
<section class="wrapper style1">
|
<section class="wrapper style1">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div id="license-content-container">
|
<div id="license-content-container">
|
||||||
<p>#lun ##Dependency License Report <em>2025-09-15 13:34:42 KST</em></p>
|
<p>#lun ##Dependency License Report <em>2025-09-24 15:26:44 KST</em></p>
|
||||||
<h2>Apache 2.0</h2>
|
<h2>Apache 2.0</h2>
|
||||||
<p><strong>1</strong> <strong>Group:</strong> <code>com.google.android</code> <strong>Name:</strong> <code>annotations</code> <strong>Version:</strong> <code>4.1.1.4</code></p>
|
<p><strong>1</strong> <strong>Group:</strong> <code>com.google.android</code> <strong>Name:</strong> <code>annotations</code> <strong>Version:</strong> <code>4.1.1.4</code></p>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
@ -41,7 +41,7 @@
|
|||||||
<li><strong>POM License</strong>: Apache 2.0 - <a href="http://www.apache.org/licenses/LICENSE-2.0.txt">http://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
|
<li><strong>POM License</strong>: Apache 2.0 - <a href="http://www.apache.org/licenses/LICENSE-2.0.txt">http://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<p><strong>5</strong> <strong>Group:</strong> <code>com.google.errorprone</code> <strong>Name:</strong> <code>error_prone_annotations</code> <strong>Version:</strong> <code>2.27.0</code></p>
|
<p><strong>5</strong> <strong>Group:</strong> <code>com.google.errorprone</code> <strong>Name:</strong> <code>error_prone_annotations</code> <strong>Version:</strong> <code>2.26.1</code></p>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Manifest Project URL</strong>: <a href="https://errorprone.info/error_prone_annotations">https://errorprone.info/error_prone_annotations</a></li>
|
<li><strong>Manifest Project URL</strong>: <a href="https://errorprone.info/error_prone_annotations">https://errorprone.info/error_prone_annotations</a></li>
|
||||||
@ -766,10 +766,10 @@
|
|||||||
<li><strong>POM License</strong>: Apache-2.0 - <a href="https://www.apache.org/licenses/LICENSE-2.0.txt">https://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
|
<li><strong>POM License</strong>: Apache-2.0 - <a href="https://www.apache.org/licenses/LICENSE-2.0.txt">https://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<p><strong>100</strong> <strong>Group:</strong> <code>com.google.code.gson</code> <strong>Name:</strong> <code>gson</code> <strong>Version:</strong> <code>2.11.0</code></p>
|
<p><strong>100</strong> <strong>Group:</strong> <code>com.google.code.gson</code> <strong>Name:</strong> <code>gson</code> <strong>Version:</strong> <code>2.10.1</code></p>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Manifest Project URL</strong>: <a href="https://github.com/google/gson">https://github.com/google/gson</a></li>
|
<li><strong>Manifest Project URL</strong>: <a href="https://github.com/google/gson/gson">https://github.com/google/gson/gson</a></li>
|
||||||
<li><strong>Manifest License</strong>: "Apache-2.0";link="https://www.apache.org/licenses/LICENSE-2.0.txt" (Not Packaged)</li>
|
<li><strong>Manifest License</strong>: "Apache-2.0";link="https://www.apache.org/licenses/LICENSE-2.0.txt" (Not Packaged)</li>
|
||||||
<li><strong>POM License</strong>: Apache-2.0 - <a href="https://www.apache.org/licenses/LICENSE-2.0.txt">https://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
|
<li><strong>POM License</strong>: Apache-2.0 - <a href="https://www.apache.org/licenses/LICENSE-2.0.txt">https://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
<div id="content_inner">
|
<div id="content_inner">
|
||||||
<article>
|
<article>
|
||||||
<th:block th:each="post, iterStat : ${postsPage.content}">
|
<th:block th:each="post, iterStat : ${postsPage.content}">
|
||||||
<section>
|
<section th:id="'post-section-' + ${post.id}">
|
||||||
<div class="box post" th:id="${post.id}">
|
<div class="box post" th:id="${post.id}">
|
||||||
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left">
|
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left">
|
||||||
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
||||||
@ -40,7 +40,8 @@
|
|||||||
<footer sec:authorize="isAuthenticated()"
|
<footer sec:authorize="isAuthenticated()"
|
||||||
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
|
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
|
||||||
style="text-align: right; margin-top: 1em;">
|
style="text-align: right; margin-top: 1em;">
|
||||||
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small alt">수정</a>
|
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small">수정</a>
|
||||||
|
<button class="button small alt" th:onclick="handleDeletePostInList([[${post.id}]], event)">삭제</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -93,5 +94,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script th:inline="javascript">
|
||||||
|
// CSRF 토큰을 meta 태그에서 읽어옴 (POST, DELETE 요청 시 필요)
|
||||||
|
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
|
||||||
|
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 목록에서 게시물을 삭제하는 함수
|
||||||
|
* @param {string} postId - 삭제할 게시물의 ID
|
||||||
|
* @param {Event} event - 클릭 이벤트 객체
|
||||||
|
*/
|
||||||
|
function handleDeletePostInList(postId, event) {
|
||||||
|
// 이벤트 버블링을 막아 게시물 상세보기로 이동하는 것을 방지합니다.
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const cleanPostId = postId.replace(/^"|"$/g, '');
|
||||||
|
if (confirm('이 게시물을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
|
||||||
|
fetch(`/blog/post/${cleanPostId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
[csrfHeader]: csrfToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
} else {
|
||||||
|
// 서버에서 에러 메시지를 보냈을 경우를 대비
|
||||||
|
return response.json().then(err => { throw new Error(err.message || '삭제에 실패했습니다.') });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
alert(data.message || '게시물이 성공적으로 삭제되었습니다.');
|
||||||
|
// UI에서 해당 게시물 섹션을 제거하여 즉시 반영
|
||||||
|
const postSection = document.getElementById(`post-section-${cleanPostId}`);
|
||||||
|
if (postSection) {
|
||||||
|
postSection.remove();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('삭제 처리 중 오류가 발생했습니다: ' + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
@ -246,6 +246,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
||||||
|
<div class="box" style="border: 2px solid #e67e22; background: #fff3e0;">
|
||||||
|
<h4>데이터 마이그레이션 (주의!)</h4>
|
||||||
|
<p>
|
||||||
|
게시물 데이터 구조를 새 버전으로 업그레이드합니다.
|
||||||
|
<strong>이 작업은 시스템 전체에 영향을 미치며, 반드시 한 번만 실행해야 합니다.</strong>
|
||||||
|
</p>
|
||||||
|
<button id="run-migration-btn" class="button alt">게시물 데이터 마이그레이션 실행</button>
|
||||||
|
<div id="migration-result" style="margin-top: 1em; font-weight: bold;"></div>
|
||||||
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h4>권한 요청</h4>
|
<h4>권한 요청</h4>
|
||||||
<ul id="permission-requests-list" class="user-list">
|
<ul id="permission-requests-list" class="user-list">
|
||||||
@ -281,8 +290,13 @@
|
|||||||
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">게시물 제목</a>
|
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">게시물 제목</a>
|
||||||
<div>
|
<div>
|
||||||
<span th:if="${post.isBlocked}" style="color: red; margin-right: 1em;">(차단됨)</span>
|
<span th:if="${post.isBlocked}" style="color: red; margin-right: 1em;">(차단됨)</span>
|
||||||
<button th:if="${!post.isBlocked}" class="button small alt" th:onclick="handleContent('[[${post.id}]]', 'block')">차단</button>
|
|
||||||
|
<a th:href="@{/user/admin/posts/{postId}/history(postId=${post.id})}" class="button small">히스토리</a>
|
||||||
|
|
||||||
|
<button th:if="${!post.isBlocked}" class="button small" th:onclick="handleContent('[[${post.id}]]', 'block')">차단</button>
|
||||||
<button th:if="${post.isBlocked}" class="button small" th:onclick="handleContent('[[${post.id}]]', 'unblock')">차단 해제</button>
|
<button th:if="${post.isBlocked}" class="button small" th:onclick="handleContent('[[${post.id}]]', 'unblock')">차단 해제</button>
|
||||||
|
|
||||||
|
<button class="button small alt" th:onclick="handleDeletePost('[[${post.id}]]')">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -418,6 +432,43 @@
|
|||||||
.catch(error => console.error('Error:', error));
|
.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 = `✅ 마이그레이션 성공!<br>
|
||||||
|
- 처리된 게시물 그룹: ${report.processedGroups}<br>
|
||||||
|
- 최신 글로 이전됨: ${report.latestPostsMigrated}<br>
|
||||||
|
- 히스토리로 이전됨: ${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
|
* @param {string} postId - 대상 게시물 ID
|
||||||
|
|||||||
58
src/main/resources/templates/content/user/post_history.html
Normal file
58
src/main/resources/templates/content/user/post_history.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/default_layout}">
|
||||||
|
|
||||||
|
<th:block layout:fragment="content">
|
||||||
|
<section class="wrapper style1">
|
||||||
|
<div class="container">
|
||||||
|
<header class="major">
|
||||||
|
<h2 th:text="${pageTitle}">게시물 수정 히스토리</h2>
|
||||||
|
<p><strong>원본글 ID:</strong> <span th:text="${currentPost.id}"></span></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h4>현재 버전</h4>
|
||||||
|
<p><strong>수정일:</strong> <span th:text="${#dates.format(currentPost.modifyTime, 'yyyy-MM-dd HH:mm')}"></span></p>
|
||||||
|
<a th:href="@{/blog/viewer/{postId}(postId=${currentPost.id})}" class="button small">현재 글 보기</a>
|
||||||
|
<a th:href="@{/blog/edit/{postId}(postId=${currentPost.id})}" class="button small alt">현재 글 수정</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" style="margin-top: 2em;">
|
||||||
|
<h4>과거 버전 (히스토리)</h4>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>보관된 시간</th>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>작성자</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="h : ${historyList}">
|
||||||
|
<td th:text="${#dates.format(h.archivedAt, 'yyyy-MM-dd HH:mm:ss')}"></td>
|
||||||
|
<td th:text="${h.title}"></td>
|
||||||
|
<td th:text="${h.writer}"></td>
|
||||||
|
<td>
|
||||||
|
<span th:if="${h.posting}" class="tag-item" style="background: #a8dadc;">공개</span>
|
||||||
|
<span th:unless="${h.posting}" class="tag-item" style="background: #f1faee;">비공개</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="#" class="button small primary">내용 보기</a>
|
||||||
|
<a href="#" class="button small alt">이 버전으로 복구</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(historyList)}">
|
||||||
|
<td colspan="5" style="text-align: center;">저장된 히스토리가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user