....
This commit is contained in:
parent
7af46ac655
commit
74e88d7d89
@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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<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 ---
|
||||
|
||||
/**
|
||||
@ -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<Post> = 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> {
|
||||
): 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')")
|
||||
|
||||
@ -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<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.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 {
|
||||
STANDARD, // 일반 블로그 글
|
||||
ABOUT_SITE // 사이트 소개 글
|
||||
ABOUT_SITE, // 사이트 소개 글
|
||||
GIBBERISH
|
||||
}
|
||||
|
||||
@Document(collection = "Post")
|
||||
@ -174,24 +226,54 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
||||
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
|
||||
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
|
||||
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
|
||||
|
||||
// [단순화] 공개된 글 목록 조회 (페이지네이션)
|
||||
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
|
||||
|
||||
// [단순화] 공개된 글 개수 카운트
|
||||
fun countByPostingIsTrue(): Mono<Long>
|
||||
|
||||
// [단순화] 인기글 5개 조회 (공개된 글 대상)
|
||||
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
|
||||
|
||||
// [단순화] 최신글 5개 조회 (공개된 글 대상)
|
||||
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
|
||||
|
||||
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
|
||||
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
|
||||
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
|
||||
|
||||
|
||||
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$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<Post>
|
||||
|
||||
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
|
||||
@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<Post>
|
||||
@ -237,15 +319,19 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
||||
|
||||
|
||||
/**
|
||||
* 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다.
|
||||
* [수정] 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<Post> // 메서드 이름 변경
|
||||
@ -253,19 +339,31 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
||||
/**
|
||||
* '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다.
|
||||
* [수정] 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<AggregationCount> // 메서드 이름 변경
|
||||
|
||||
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
|
||||
|
||||
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
|
||||
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
|
||||
])
|
||||
fun findRandomPublishedPostByType(postType: String): Mono<Post>
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -280,6 +378,60 @@ class PostManager(
|
||||
@Autowired
|
||||
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> {
|
||||
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
|
||||
@ -312,13 +464,13 @@ class PostManager(
|
||||
return postRepository.findById(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회)
|
||||
*/
|
||||
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
|
||||
.collectList()
|
||||
}
|
||||
// /**
|
||||
// * [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회)
|
||||
// */
|
||||
// fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
||||
// return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
|
||||
// .collectList()
|
||||
// }
|
||||
|
||||
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
|
||||
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
|
||||
@ -332,14 +484,14 @@ class PostManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수
|
||||
*/
|
||||
fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
||||
return postRepository.countLatestUniqueForWriter(username)
|
||||
.map { it.totalCount }
|
||||
.switchIfEmpty(Mono.just(0L))
|
||||
}
|
||||
// /**
|
||||
// * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수
|
||||
// */
|
||||
// fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
||||
// return postRepository.countLatestUniqueForWriter(username)
|
||||
// .map { it.totalCount }
|
||||
// .switchIfEmpty(Mono.just(0L))
|
||||
// }
|
||||
|
||||
fun getPost(id: String): Mono<Post> {
|
||||
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<List<Post>> { // <-- 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<List<Post>> { // <-- Should already return Mono
|
||||
// return postRepository.findLatestUniquePublishedPaginated(pageable)
|
||||
// .collectList()
|
||||
// }
|
||||
|
||||
/**
|
||||
* 인증된 사용자가 보는 글의 총 개수
|
||||
@ -389,15 +541,15 @@ class PostManager(
|
||||
return postRepository.countByOrderByModifyTimeDesc()
|
||||
}
|
||||
|
||||
/**
|
||||
* 익명 사용자가 보는 글의 총 개수
|
||||
*/
|
||||
fun countLatestUnique(): Mono<Long> {
|
||||
// AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다.
|
||||
return postRepository.countLatestUniquePublished()
|
||||
.map { it.totalCount }
|
||||
.switchIfEmpty(Mono.just(0L))
|
||||
}
|
||||
// /**
|
||||
// * 익명 사용자가 보는 글의 총 개수
|
||||
// */
|
||||
// fun countLatestUnique(): Mono<Long> {
|
||||
// // AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다.
|
||||
// return postRepository.countLatestUniquePublished()
|
||||
// .map { it.totalCount }
|
||||
// .switchIfEmpty(Mono.just(0L))
|
||||
// }
|
||||
|
||||
/**
|
||||
* [신규 추가]
|
||||
@ -495,27 +647,28 @@ class PostManager(
|
||||
}
|
||||
}
|
||||
|
||||
// [신규 추가] 익명 사용자용 인기글
|
||||
fun getTop5UniquePublishedByViews(): Flux<Post> {
|
||||
return postRepository.findTop5UniquePublishedByReadCountDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
// // [신규 추가] 익명 사용자용 인기글
|
||||
// fun getTop5UniquePublishedByViews(): Flux<Post> {
|
||||
// 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<Post> {
|
||||
return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
// // [신규 추가] 익명 사용자용 최신글
|
||||
// fun getRecent5UniquePublished(): Flux<Post> {
|
||||
// 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 문자열을 다시 조합하여 원본 데이터 생성
|
||||
|
||||
@ -64,7 +64,6 @@ data class PersistentLogin(
|
||||
interface PersistentLoginRepository : ReactiveMongoRepository<PersistentLogin, String> {
|
||||
fun findByUsername(username: String): Flux<PersistentLogin>
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
|
||||
@ -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('삭제 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -29,11 +29,10 @@
|
||||
<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>
|
||||
</header>
|
||||
<div style="text-align: right; margin-bottom: 2em;">
|
||||
<label for="post-published-switch" style="font-weight: bold; color: #555; vertical-align: middle; margin-right: 10px;">
|
||||
게시물 공개
|
||||
</label>
|
||||
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" />
|
||||
<div style="text-align: right; margin-bottom: 2em; display: flex; align-items: center; justify-content: flex-end;">
|
||||
<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 for="post-published-switch" class="custom-label"></label>
|
||||
</div>
|
||||
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
|
||||
<div class="write_option btn-example controlbox-category" to="#popLayer1">
|
||||
@ -54,8 +53,13 @@
|
||||
<th:block sec:authorize="isAuthenticated()">
|
||||
<div class="container">
|
||||
<div id="editor"></div>
|
||||
|
||||
<button id="save" class="button fit" style="margin-top: 1em;" onclick="save()">저장하기</button>
|
||||
<div style="display: flex; gap: 1em; margin-top: 1em;">
|
||||
<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>
|
||||
</th:block>
|
||||
</section>
|
||||
|
||||
@ -7,7 +7,11 @@
|
||||
<th:block layout:fragment="content" id="content">
|
||||
<section id="banner"
|
||||
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>
|
||||
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
||||
</header>
|
||||
@ -102,10 +106,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<div class="container">
|
||||
<header>
|
||||
<h2>Are you ready to continue your quest?</h2>
|
||||
<!-- <h2>Are you ready to continue your quest?</h2>-->
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}">
|
||||
<head>
|
||||
<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@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">
|
||||
<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>
|
||||
</head>
|
||||
@ -11,7 +11,7 @@
|
||||
<section class="wrapper style1">
|
||||
<div class="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>
|
||||
<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>
|
||||
@ -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>
|
||||
</ul>
|
||||
</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>
|
||||
<ul>
|
||||
<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>
|
||||
</ul>
|
||||
</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>
|
||||
<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>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>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<div id="content_inner">
|
||||
<article>
|
||||
<th:block th:each="post, iterStat : ${postsPage.content}">
|
||||
<section>
|
||||
<section th:id="'post-section-' + ${post.id}">
|
||||
<div class="box post" th:id="${post.id}">
|
||||
<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" />
|
||||
@ -40,7 +40,8 @@
|
||||
<footer sec:authorize="isAuthenticated()"
|
||||
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
@ -93,5 +94,51 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
@ -246,6 +246,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<h4>권한 요청</h4>
|
||||
<ul id="permission-requests-list" class="user-list">
|
||||
@ -281,8 +290,13 @@
|
||||
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">게시물 제목</a>
|
||||
<div>
|
||||
<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 class="button small alt" th:onclick="handleDeletePost('[[${post.id}]]')">삭제</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@ -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 = `✅ 마이그레이션 성공!<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
|
||||
|
||||
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