This commit is contained in:
lunaticbum 2025-09-24 17:29:33 +09:00
parent 7af46ac655
commit 74e88d7d89
17 changed files with 851 additions and 178 deletions

View File

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

View File

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

View File

@ -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))
}
}
/**
* [수정] ' 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가)
*/

View 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의 모든 필드를 안전하게 변환 ...
)
}

View File

@ -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 문자열을 다시 조합하여 원본 데이터 생성

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('삭제 중 오류가 발생했습니다.');
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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