This commit is contained in:
lunaticbum 2025-09-15 18:21:57 +09:00
parent 1ab12cb6d9
commit 39c9624774
5 changed files with 101 additions and 54 deletions

View File

@ -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 <lunaticbum@gmail.com>"
@ -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

View File

@ -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
) {
// ======================================================

View File

@ -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<ImageMeta, String> {
/**
* MongoDB Aggregation의 $sample 파이프라인을 사용해 랜덤으로 1개의 문서를 가져옵니다.
*/
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
fun findRandomImage(): Mono<ImageMeta>
// [신규 추가] 파일 이름으로 문서를 찾는 기능
fun findByFileName(fileName: String): Mono<ImageMeta>
// [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능
fun deleteAllByFileNameIn(fileNames: List<String>): Mono<Void>
}
/**
@ -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 블록이 실행되어 잠금이 해제됩니다.
}
}
}

View File

@ -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<List<Int>>,
val rowClues: List<List<Int>>,
val colClues: List<List<Int>>,
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<NonogramPuzzle> = puzzleRepository.findById(id) // Nonogram 용
fun deletePuzzle(id : String): Mono<Void> = puzzleRepository.deleteById(id) // Nonogram 용
suspend fun deletePuzzle(id : String): Mono<Void> {
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()

View File

@ -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,
});