diff --git a/Dockerfile b/Dockerfile index 442095b..0202bd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ ENV MRA_PW=default ENV RESOURCE_HANDLER=default ENV RESOURCE_LOCATION=default ENV IMAGE_UPLOAD_PATH=default +ENV PUZZLE_IMAGE_UPLOAD_PATH=default ENV GAPI_KEY=default WORKDIR /imgUpload LABEL maintainer="lunaticbum " @@ -23,5 +24,5 @@ EXPOSE 443 #EXPOSE 27012 #EXPOSE 3307 #ENTRYPOINT ["java","-jar","app.jar","-Dspring-boot.run.arguments=--telegram.bot.key=${BOT_KEY}, --telegram.my.id=${TG_MINE}, --telegram.target.id=${TG_TARGET_ID}, --weather.api.key=${WEATHER_KEY}"] -ENTRYPOINT ["java","-Dtelegram.bot.key=${BOT_KEY}","-Dtelegram.my.id=${TG_MINE}","-Dtelegram.target.id=${TG_TARGET_ID}","-Dweather.api.key=${WEATHER_KEY}","-Dspring.datasource.url=${DATASOURCE_URL}" ,"-Dspring.data.mongodb.uri=${MONGODB_HOST}","-Dspring.data.mongodb.database=${MONGODB_NAME}","-Dspring.datasource.username=${MRA_ADMIN}","-Dspring.datasource.password=${MRA_PW}","-Dresource.handler=${RESOURCE_HANDLER}","-Dresource.location=${RESOURCE_LOCATION}","-Dimage.upload.path=${IMAGE_UPLOAD_PATH}","-Dapi.gg.place=${GAPI_KEY}","-jar","app.jar"] +ENTRYPOINT ["java","-Dtelegram.bot.key=${BOT_KEY}","-Dtelegram.my.id=${TG_MINE}","-Dtelegram.target.id=${TG_TARGET_ID}","-Dweather.api.key=${WEATHER_KEY}","-Dspring.datasource.url=${DATASOURCE_URL}" ,"-Dspring.data.mongodb.uri=${MONGODB_HOST}","-Dspring.data.mongodb.database=${MONGODB_NAME}","-Dspring.datasource.username=${MRA_ADMIN}","-Dspring.datasource.password=${MRA_PW}","-Dresource.handler=${RESOURCE_HANDLER}","-Dresource.location=${RESOURCE_LOCATION}","-Dimage.upload.path=${IMAGE_UPLOAD_PATH}","-Dpuzzle.image.path=${PUZZLE_IMAGE_UPLOAD_PATH}","-Dapi.gg.place=${GAPI_KEY}","-jar","app.jar"] #-Dtelegram.bot.key=bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w -Dtelegram.target.id=71476436 -Dtelegram.my.id=71476436 -Dweather.api.key=de574a260b1f474d99955729241909 -Dspring.datasource.url=jdbc:mariadb://mra.sbspace.synology.me -Dspring.data.mongodb.uri=mongodb://lun_admin:VioPup*383@mongo.sbspace.synology.me/?wtimeoutMS=300&connectTimeoutMS=500&socketTimeoutMS=200 -Dspring.data.mongodb.database=lun_db -Dspring.datasource.username=lun_admin -Dspring.datasource.password=VioPup*383 -Dresource.handler=/blog/post/image/** -Dresource.location=file:///imgUpload -Dimage.upload.path=imgUpload diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt index 52fadd4..51b70b8 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -2,6 +2,7 @@ package kr.lunaticbum.back.lun.controllers import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import +import org.springframework.beans.factory.annotation.Value import org.springframework.http.ResponseEntity import org.springframework.ui.Model import org.springframework.web.bind.annotation.* @@ -17,7 +18,8 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치 class PuzzleController( // 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음 - private val puzzleService: PuzzleService + private val puzzleService: PuzzleService, + @Value("\${puzzle.image.path}") private val puzzleImagePath: String ) { // ====================================================== diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt index 381d12d..ecbf322 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.utils.LogService import org.bson.BsonType import org.bson.codecs.pojo.annotations.BsonId @@ -44,14 +45,13 @@ data class ImageMeta( */ interface ImageMetaRepository : ReactiveMongoRepository { - /** - * MongoDB Aggregation의 $sample 파이프라인을 사용해 랜덤으로 1개의 문서를 가져옵니다. - */ @Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ]) fun findRandomImage(): Mono - // [신규 추가] 파일 이름으로 문서를 찾는 기능 fun findByFileName(fileName: String): Mono + + // [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능 + fun deleteAllByFileNameIn(fileNames: List): Mono } /** @@ -122,59 +122,60 @@ class ImageMetaService( /** * [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직 */ + // ImageMetaService.kt의 runFileSystemSync 함수를 아래 내용으로 교체하세요. private suspend fun runFileSystemSync() { - // 1. 실제 업로드 폴더에서 원본 파일 목록(썸네일 제외)을 가져옵니다. - val physicalFiles = File(uploadPath).listFiles { _, name -> + // 1. [변경] 실제 업로드 폴더에서 파일 '이름' 목록을 Set으로 가져옵니다. + val physicalFilenames = File(uploadPath).listFiles { _, name -> !name.contains("_thumbnail.") && (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".gif")) - } ?: emptyArray() + }?.map { it.name }?.toSet() ?: emptySet() - // 2. DB에서 모든 이미지 메타데이터 개수를 가져옵니다. - val dbCount = repository.count().awaitSingle() + // 2. [변경] DB에서 모든 이미지 '파일 이름'을 Set으로 가져옵니다. + val dbFilenames = repository.findAll().map { it.fileName }.collectList().awaitSingle().toSet() - // 3. (요청사항) 개수가 동일하면 작업을 수행할 필요가 없습니다. - if (physicalFiles.size.toLong() == dbCount) { - logService.log("Image sync check: Counts match (${dbCount} files). No sync needed.") + // 3. 파일 시스템과 DB의 파일 목록이 완전히 일치하면 동기화가 필요 없습니다. + if (physicalFilenames == dbFilenames) { + logService.log("Image sync check: File lists are identical. No sync needed.") return } - logService.log("Image sync: File count (${physicalFiles.size}) vs DB count ($dbCount). Syncing...") + logService.log("Image sync: Mismatch detected. Syncing...") - // 4. DB에 존재하는 모든 파일 이름을 Set으로 변환하여 빠른 조회를 준비합니다. - val dbFilenames = repository.findAll().map { it.fileName }.collectList().awaitSingle().toSet() - - // 5. (요청사항) 에러 발생 시 즉시 중단되도록 전체 루프를 try-catch로 감쌉니다. try { - // 6. 실제 파일 목록을 순회합니다. - for (file in physicalFiles) { - // 7. 파일 이름이 DB 파일 이름 Set에 없는 경우(누락된 경우)에만 처리합니다. - if (file.name !in dbFilenames) { - logService.log("Sync: Found missing file: ${file.name}. Adding to DB...") - - // 8. 메타데이터 추출 (이 과정에서 파일이 손상되었으면 ImageIO.read가 Exception 발생) - val bufferedImage = ImageIO.read(file) - val width = bufferedImage.width - val height = bufferedImage.height - - val metadata = ImageMeta( - fileName = file.name, - originalFileName = "Scanned from disk", // 원본 파일명은 알 수 없으므로 대체 텍스트 사용 - fileType = Files.probeContentType(file.toPath()), // 파일 시스템 기반 MIME 타입 추측 - fileSize = file.length(), - width = width, - height = height, - uploadTime = file.lastModified(), // 등록일시 대신 파일 최종 수정일시 사용 - path = "/blog/post/images/${file.name}" // 저장된 경로 - ) - - // 9. DB에 저장 (DB 연결 오류 시 Exception 발생) - repository.save(metadata).awaitSingle() + // 4. [추가] DB에 추가해야 할 파일 목록을 계산합니다. (실제 파일 O, DB X) + val filesToAdd = physicalFilenames - dbFilenames + if (filesToAdd.isNotEmpty()) { + logService.log("Sync: Found ${filesToAdd.size} files to add to DB...") + for (fileName in filesToAdd) { + val file = File(uploadPath, fileName) + if (file.exists()) { + val bufferedImage = ImageIO.read(file) + val metadata = ImageMeta( + fileName = file.name, + originalFileName = "Scanned from disk", + fileType = Files.probeContentType(file.toPath()), + fileSize = file.length(), + width = bufferedImage.width, + height = bufferedImage.height, + uploadTime = file.lastModified(), + path = "/blog/post/images/${file.name}" + ) + repository.save(metadata).awaitSingle() + } } } + + // 5. [추가] DB에서 삭제해야 할 '고아 데이터' 목록을 계산합니다. (실제 파일 X, DB O) + val filesToDelete = dbFilenames - physicalFilenames + if (filesToDelete.isNotEmpty()) { + logService.log("Sync: Found ${filesToDelete.size} orphan DB entries to delete...") + repository.deleteAllByFileNameIn(filesToDelete.toList()).awaitSingleOrNull() + } + + logService.log("Image sync finished successfully.") + } catch (e: Exception) { - // [요청사항] IO 오류(손상된 파일) 또는 DB 오류 발생 시, 즉시 작업을 중단하고 로그를 남깁니다. - logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task immediately. Remaining files will not be processed.") + logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task.") e.printStackTrace() - // 함수가 여기서 종료되며, launchSyncTask의 finally 블록이 실행되어 잠금이 해제됩니다. } } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt index 04b14bf..c993a61 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withContext import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite import kr.lunaticbum.back.lun.utils.SudokuGenerator +import org.springframework.beans.factory.annotation.Value import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.repository.Aggregation @@ -25,6 +26,8 @@ import javax.imageio.ImageIO import kotlin.random.Random import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.mongodb.core.index.Indexed +import java.io.File +import java.util.UUID /** @@ -40,8 +43,8 @@ data class NonogramPuzzle( val solutionGrid: List>, val rowClues: List>, val colClues: List>, - val grayscaleImage: String, - val originalImage: String, + val grayscaleImageFile: String, // 파일명 (예: "uuid-gray.png") + val originalImageFile: String, // 파일명 (예: "uuid-original.png") val createdAt: LocalDateTime = LocalDateTime.now() ) @@ -69,7 +72,8 @@ class PuzzleService( private val sudokuPuzzleRepository: SudokuPuzzleRepository, // 3. Spider 의존성 - private val spiderGameRepository: SpiderGameRepository + private val spiderGameRepository: SpiderGameRepository, + @Value("\${puzzle.image.path}") private val puzzleImagePath: String ) { companion object { @@ -87,7 +91,20 @@ class PuzzleService( fun findById(id: String): Mono = puzzleRepository.findById(id) // Nonogram 용 - fun deletePuzzle(id : String): Mono = puzzleRepository.deleteById(id) // Nonogram 용 + suspend fun deletePuzzle(id : String): Mono { + val puzzle = puzzleRepository.findById(id).awaitSingleOrNull() + if (puzzle != null) { + // 파일 시스템에서 이미지 파일 삭제 + try { + File(puzzleImagePath, puzzle.grayscaleImageFile).delete() + File(puzzleImagePath, puzzle.originalImageFile).delete() + } catch (e: Exception) { + // 파일 삭제 실패 시 로그를 남길 수 있습니다 (선택사항). + e.printStackTrace() + } + } + return puzzleRepository.deleteById(id) // DB에서 퍼즐 정보 삭제 + } suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle { val puzzleData = withContext(Dispatchers.IO) { @@ -110,15 +127,33 @@ class PuzzleService( } val rowClues = solutionGrid.map { getCluesForLine(it) } val colClues = transpose(solutionGrid).map { getCluesForLine(it) } - val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300)) - val originalBase64 = imageToBase64(resizedOriginal) + + // --- Base64 인코딩 대신 파일로 저장 --- + // 1. 고유한 파일명 생성 + val uniqueId = UUID.randomUUID().toString() + val grayFilename = "$uniqueId-gray.png" + val originalFilename = "$uniqueId-original.png" + + // 2. 저장 경로에 파일 객체 생성 + val grayFile = File(puzzleImagePath, grayFilename) + val originalFile = File(puzzleImagePath, originalFilename) + + // 3. 부모 디렉토리가 없으면 생성 + grayFile.parentFile.mkdirs() + + // 4. BufferedImage를 파일로 저장 + ImageIO.write(resizeImage(grayImage, 300), "png", grayFile) + ImageIO.write(resizedOriginal, "png", originalFile) + // --- 파일 저장 로직 끝 --- + + NonogramPuzzle( solutionGrid = solutionGrid, rowClues = rowClues, colClues = colClues, - grayscaleImage = grayscaleBase64, - originalImage = originalBase64 + grayscaleImageFile = grayFilename, + originalImageFile = originalFilename ) } return puzzleRepository.save(puzzleData).awaitSingle() diff --git a/src/main/resources/templates/content/puzzle/upload.html b/src/main/resources/templates/content/puzzle/upload.html index 5450298..5dce44a 100644 --- a/src/main/resources/templates/content/puzzle/upload.html +++ b/src/main/resources/templates/content/puzzle/upload.html @@ -114,9 +114,17 @@ puzzleContainer.innerHTML = ''; try { + // 1. 레이아웃의 meta 태그에서 CSRF 토큰 값을 읽어옵니다. + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content') || ''; + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content') || 'X-CSRF-TOKEN'; + + // (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/upload.bjx 호출 const response = await fetch('/puzzle/upload.bjx', { method: 'POST', + headers: { + [csrfHeader]: csrfToken + }, body: formData, });