...
This commit is contained in:
parent
1ab12cb6d9
commit
39c9624774
@ -11,6 +11,7 @@ ENV MRA_PW=default
|
|||||||
ENV RESOURCE_HANDLER=default
|
ENV RESOURCE_HANDLER=default
|
||||||
ENV RESOURCE_LOCATION=default
|
ENV RESOURCE_LOCATION=default
|
||||||
ENV IMAGE_UPLOAD_PATH=default
|
ENV IMAGE_UPLOAD_PATH=default
|
||||||
|
ENV PUZZLE_IMAGE_UPLOAD_PATH=default
|
||||||
ENV GAPI_KEY=default
|
ENV GAPI_KEY=default
|
||||||
WORKDIR /imgUpload
|
WORKDIR /imgUpload
|
||||||
LABEL maintainer="lunaticbum <lunaticbum@gmail.com>"
|
LABEL maintainer="lunaticbum <lunaticbum@gmail.com>"
|
||||||
@ -23,5 +24,5 @@ EXPOSE 443
|
|||||||
#EXPOSE 27012
|
#EXPOSE 27012
|
||||||
#EXPOSE 3307
|
#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","-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
|
#-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
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package kr.lunaticbum.back.lun.controllers
|
|||||||
|
|
||||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
|
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@ -17,7 +18,8 @@ import org.springframework.web.multipart.MultipartFile
|
|||||||
@RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치
|
@RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치
|
||||||
class PuzzleController(
|
class PuzzleController(
|
||||||
// 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음
|
// 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음
|
||||||
private val puzzleService: PuzzleService
|
private val puzzleService: PuzzleService,
|
||||||
|
@Value("\${puzzle.image.path}") private val puzzleImagePath: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// ======================================================
|
// ======================================================
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
import kr.lunaticbum.back.lun.utils.LogService
|
import kr.lunaticbum.back.lun.utils.LogService
|
||||||
import org.bson.BsonType
|
import org.bson.BsonType
|
||||||
import org.bson.codecs.pojo.annotations.BsonId
|
import org.bson.codecs.pojo.annotations.BsonId
|
||||||
@ -44,14 +45,13 @@ data class ImageMeta(
|
|||||||
*/
|
*/
|
||||||
interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
|
interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
|
||||||
|
|
||||||
/**
|
|
||||||
* MongoDB Aggregation의 $sample 파이프라인을 사용해 랜덤으로 1개의 문서를 가져옵니다.
|
|
||||||
*/
|
|
||||||
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
|
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
|
||||||
fun findRandomImage(): Mono<ImageMeta>
|
fun findRandomImage(): Mono<ImageMeta>
|
||||||
|
|
||||||
// [신규 추가] 파일 이름으로 문서를 찾는 기능
|
|
||||||
fun findByFileName(fileName: String): Mono<ImageMeta>
|
fun findByFileName(fileName: String): Mono<ImageMeta>
|
||||||
|
|
||||||
|
// [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능
|
||||||
|
fun deleteAllByFileNameIn(fileNames: List<String>): Mono<Void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -122,59 +122,60 @@ class ImageMetaService(
|
|||||||
/**
|
/**
|
||||||
* [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직
|
* [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직
|
||||||
*/
|
*/
|
||||||
|
// ImageMetaService.kt의 runFileSystemSync 함수를 아래 내용으로 교체하세요.
|
||||||
private suspend fun runFileSystemSync() {
|
private suspend fun runFileSystemSync() {
|
||||||
// 1. 실제 업로드 폴더에서 원본 파일 목록(썸네일 제외)을 가져옵니다.
|
// 1. [변경] 실제 업로드 폴더에서 파일 '이름' 목록을 Set으로 가져옵니다.
|
||||||
val physicalFiles = File(uploadPath).listFiles { _, name ->
|
val physicalFilenames = File(uploadPath).listFiles { _, name ->
|
||||||
!name.contains("_thumbnail.") && (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".gif"))
|
!name.contains("_thumbnail.") && (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".gif"))
|
||||||
} ?: emptyArray()
|
}?.map { it.name }?.toSet() ?: emptySet()
|
||||||
|
|
||||||
// 2. DB에서 모든 이미지 메타데이터 개수를 가져옵니다.
|
// 2. [변경] DB에서 모든 이미지 '파일 이름'을 Set으로 가져옵니다.
|
||||||
val dbCount = repository.count().awaitSingle()
|
val dbFilenames = repository.findAll().map { it.fileName }.collectList().awaitSingle().toSet()
|
||||||
|
|
||||||
// 3. (요청사항) 개수가 동일하면 작업을 수행할 필요가 없습니다.
|
// 3. 파일 시스템과 DB의 파일 목록이 완전히 일치하면 동기화가 필요 없습니다.
|
||||||
if (physicalFiles.size.toLong() == dbCount) {
|
if (physicalFilenames == dbFilenames) {
|
||||||
logService.log("Image sync check: Counts match (${dbCount} files). No sync needed.")
|
logService.log("Image sync check: File lists are identical. No sync needed.")
|
||||||
return
|
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 {
|
try {
|
||||||
// 6. 실제 파일 목록을 순회합니다.
|
// 4. [추가] DB에 추가해야 할 파일 목록을 계산합니다. (실제 파일 O, DB X)
|
||||||
for (file in physicalFiles) {
|
val filesToAdd = physicalFilenames - dbFilenames
|
||||||
// 7. 파일 이름이 DB 파일 이름 Set에 없는 경우(누락된 경우)에만 처리합니다.
|
if (filesToAdd.isNotEmpty()) {
|
||||||
if (file.name !in dbFilenames) {
|
logService.log("Sync: Found ${filesToAdd.size} files to add to DB...")
|
||||||
logService.log("Sync: Found missing file: ${file.name}. Adding to DB...")
|
for (fileName in filesToAdd) {
|
||||||
|
val file = File(uploadPath, fileName)
|
||||||
// 8. 메타데이터 추출 (이 과정에서 파일이 손상되었으면 ImageIO.read가 Exception 발생)
|
if (file.exists()) {
|
||||||
val bufferedImage = ImageIO.read(file)
|
val bufferedImage = ImageIO.read(file)
|
||||||
val width = bufferedImage.width
|
|
||||||
val height = bufferedImage.height
|
|
||||||
|
|
||||||
val metadata = ImageMeta(
|
val metadata = ImageMeta(
|
||||||
fileName = file.name,
|
fileName = file.name,
|
||||||
originalFileName = "Scanned from disk", // 원본 파일명은 알 수 없으므로 대체 텍스트 사용
|
originalFileName = "Scanned from disk",
|
||||||
fileType = Files.probeContentType(file.toPath()), // 파일 시스템 기반 MIME 타입 추측
|
fileType = Files.probeContentType(file.toPath()),
|
||||||
fileSize = file.length(),
|
fileSize = file.length(),
|
||||||
width = width,
|
width = bufferedImage.width,
|
||||||
height = height,
|
height = bufferedImage.height,
|
||||||
uploadTime = file.lastModified(), // 등록일시 대신 파일 최종 수정일시 사용
|
uploadTime = file.lastModified(),
|
||||||
path = "/blog/post/images/${file.name}" // 저장된 경로
|
path = "/blog/post/images/${file.name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 9. DB에 저장 (DB 연결 오류 시 Exception 발생)
|
|
||||||
repository.save(metadata).awaitSingle()
|
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) {
|
} catch (e: Exception) {
|
||||||
// [요청사항] IO 오류(손상된 파일) 또는 DB 오류 발생 시, 즉시 작업을 중단하고 로그를 남깁니다.
|
logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task.")
|
||||||
logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task immediately. Remaining files will not be processed.")
|
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
// 함수가 여기서 종료되며, launchSyncTask의 finally 블록이 실행되어 잠금이 해제됩니다.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,6 +7,7 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
||||||
import kr.lunaticbum.back.lun.utils.SudokuGenerator
|
import kr.lunaticbum.back.lun.utils.SudokuGenerator
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.data.annotation.Id
|
import org.springframework.data.annotation.Id
|
||||||
import org.springframework.data.mongodb.core.mapping.Document
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
import org.springframework.data.mongodb.repository.Aggregation
|
import org.springframework.data.mongodb.repository.Aggregation
|
||||||
@ -25,6 +26,8 @@ import javax.imageio.ImageIO
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||||
import org.springframework.data.mongodb.core.index.Indexed
|
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 solutionGrid: List<List<Int>>,
|
||||||
val rowClues: List<List<Int>>,
|
val rowClues: List<List<Int>>,
|
||||||
val colClues: List<List<Int>>,
|
val colClues: List<List<Int>>,
|
||||||
val grayscaleImage: String,
|
val grayscaleImageFile: String, // 파일명 (예: "uuid-gray.png")
|
||||||
val originalImage: String,
|
val originalImageFile: String, // 파일명 (예: "uuid-original.png")
|
||||||
val createdAt: LocalDateTime = LocalDateTime.now()
|
val createdAt: LocalDateTime = LocalDateTime.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -69,7 +72,8 @@ class PuzzleService(
|
|||||||
private val sudokuPuzzleRepository: SudokuPuzzleRepository,
|
private val sudokuPuzzleRepository: SudokuPuzzleRepository,
|
||||||
|
|
||||||
// 3. Spider 의존성
|
// 3. Spider 의존성
|
||||||
private val spiderGameRepository: SpiderGameRepository
|
private val spiderGameRepository: SpiderGameRepository,
|
||||||
|
@Value("\${puzzle.image.path}") private val puzzleImagePath: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -87,7 +91,20 @@ class PuzzleService(
|
|||||||
|
|
||||||
fun findById(id: String): Mono<NonogramPuzzle> = puzzleRepository.findById(id) // Nonogram 용
|
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 {
|
suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle {
|
||||||
val puzzleData = withContext(Dispatchers.IO) {
|
val puzzleData = withContext(Dispatchers.IO) {
|
||||||
@ -110,15 +127,33 @@ class PuzzleService(
|
|||||||
}
|
}
|
||||||
val rowClues = solutionGrid.map { getCluesForLine(it) }
|
val rowClues = solutionGrid.map { getCluesForLine(it) }
|
||||||
val colClues = transpose(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(
|
NonogramPuzzle(
|
||||||
solutionGrid = solutionGrid,
|
solutionGrid = solutionGrid,
|
||||||
rowClues = rowClues,
|
rowClues = rowClues,
|
||||||
colClues = colClues,
|
colClues = colClues,
|
||||||
grayscaleImage = grayscaleBase64,
|
grayscaleImageFile = grayFilename,
|
||||||
originalImage = originalBase64
|
originalImageFile = originalFilename
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return puzzleRepository.save(puzzleData).awaitSingle()
|
return puzzleRepository.save(puzzleData).awaitSingle()
|
||||||
|
|||||||
@ -114,9 +114,17 @@
|
|||||||
puzzleContainer.innerHTML = '';
|
puzzleContainer.innerHTML = '';
|
||||||
|
|
||||||
try {
|
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 호출
|
// (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/upload.bjx 호출
|
||||||
const response = await fetch('/puzzle/upload.bjx', {
|
const response = await fetch('/puzzle/upload.bjx', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
[csrfHeader]: csrfToken
|
||||||
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user