From 17aea8b43baa0009061fc28ea275cc88c97b4f7f Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 16 Sep 2025 18:42:55 +0900 Subject: [PATCH] ... --- Dockerfile | 5 +- build.gradle.kts | 23 + .../lun/configs/GlobalControllerAdvice.kt | 20 + .../back/lun/configs/SecurityConfig.kt | 8 +- .../back/lun/controllers/BlogController.kt | 73 +- .../lun/controllers/GameRankController.kt | 15 +- .../lunaticbum/back/lun/controllers/Home.kt | 262 ------ .../lunaticbum/back/lun/controllers/Owner.kt | 5 - .../back/lun/controllers/PuzzleController.kt | 22 + .../back/lun/controllers/UserController.kt | 18 +- .../kr/lunaticbum/back/lun/model/GameRank.kt | 64 +- .../kr/lunaticbum/back/lun/model/ImageMeta.kt | 43 +- .../kr/lunaticbum/back/lun/model/Post.kt | 51 +- .../lunaticbum/back/lun/model/PuzzleData.kt | 4 +- .../kr/lunaticbum/back/lun/model/User.kt | 2 +- .../resources/application-local.properties | 103 +++ .../resources/application-prod.properties | 103 +++ src/main/resources/application.properties | 3 +- .../static/css/common_game_theme.css | 2 +- src/main/resources/static/js/common.js | 215 ++++- .../resources/templates/content/home.html | 4 +- .../resources/templates/content/posts.html | 2 +- .../templates/content/puzzle/2048.html | 105 +-- .../templates/content/puzzle/nonogram.html | 230 +---- .../templates/content/puzzle/spider.html | 837 +++++++----------- .../templates/content/puzzle/sudoku.html | 230 ++--- .../templates/content/puzzle/upload.html | 10 +- .../templates/content/user/my_info.html | 94 +- .../resources/templates/fragments/footer.html | 64 +- .../templates/fragments/includes.html | 1 + .../templates/layout/default_layout.html | 44 + 31 files changed, 1335 insertions(+), 1327 deletions(-) create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/configs/GlobalControllerAdvice.kt delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt delete mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/controllers/Owner.kt create mode 100644 src/main/resources/application-local.properties create mode 100644 src/main/resources/application-prod.properties diff --git a/Dockerfile b/Dockerfile index 0202bd9..74547d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,16 +13,17 @@ ENV RESOURCE_LOCATION=default ENV IMAGE_UPLOAD_PATH=default ENV PUZZLE_IMAGE_UPLOAD_PATH=default ENV GAPI_KEY=default +ENV API_BASE_URL=default WORKDIR /imgUpload LABEL maintainer="lunaticbum " LABEL version="0.0.7" LABEL description="Spring Boot Jar Test" -ARG JAR_FILE=build/libs/lun-0.0.7-SNAPSHOT.jar +ARG JAR_FILE=build/libs/lun-0.0.7-SNAPSHOT-prod.jar COPY ${JAR_FILE} app.jar 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}","-Dpuzzle.image.path=${PUZZLE_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}","-Dapi.base-url=${API_BASE_URL}","-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/build.gradle.kts b/build.gradle.kts index acafac1..144271d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import com.github.jk1.license.render.InventoryMarkdownReportRenderer import org.jsoup.Jsoup +import org.springframework.boot.gradle.tasks.bundling.BootJar //import org.gradle.internal.impldep.org.jsoup.Jsoup @@ -220,6 +221,28 @@ tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게 dependsOn(tasks.named("updateLicensePage")) } +// 기본 bootJar 태스크의 설정을 가져오기 위한 참조 +val bootJar by tasks.getting(BootJar::class) + +// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의 +tasks.register("bootJarProd") { + group = "build" + description = "Builds a production JAR that defaults to the 'prod' profile." + archiveClassifier.set("prod") + + // --- 필수 설정 복사 --- + // 1. Main 클래스 설정 복사 + mainClass.set(bootJar.mainClass) + // 2. Classpath 설정 복사 + classpath = bootJar.classpath + // 3. Target Java Version 설정 복사 (이번 오류 해결) + targetJavaVersion.set(bootJar.targetJavaVersion) + + manifest { + attributes["Spring-Profiles-Active"] = "prod" + } +} + // //// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결 //tasks.build { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/GlobalControllerAdvice.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/GlobalControllerAdvice.kt new file mode 100644 index 0000000..c06f189 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/GlobalControllerAdvice.kt @@ -0,0 +1,20 @@ +package kr.lunaticbum.back.lun.configs + +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ModelAttribute + +@ControllerAdvice // 이 클래스가 모든 컨트롤러에 적용될 것임을 선언 +class GlobalControllerAdvice { + + // application.properties에서 값을 주입받는 것은 동일 + @Value("\${api.base-url}") + private lateinit var apiBaseUrl: String + + // @ModelAttribute 어노테이션을 사용한 메서드를 정의 + // 이 메서드의 반환값은 자동으로 모든 모델에 "apiBaseUrl"이라는 이름으로 추가됨 + @ModelAttribute("apiBaseUrl") + fun addApiBaseUrlToModel(): String { + return apiBaseUrl + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index a8e94a5..93dc5a8 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -81,7 +81,8 @@ class SecurityConfig( .csrf { csrf -> csrf.ignoringRequestMatchers( "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", - "/api/ranks/submit" // 통합 랭킹 API + "/api/ranks/submit", // 통합 랭킹 API + "/puzzle/**", // <-- 이 줄을 추가하세요. ) }.authorizeHttpRequests { auth -> auth @@ -98,13 +99,16 @@ class SecurityConfig( "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx", "/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx", "/blog/categories.bjx", "/blog/hashtags.bjx", - "/puzzle/**", "/api/ranks/list", "/licenses" + "/puzzle/**", "/api/ranks/list", "/licenses", + "/puzzle/images/**" ).permitAll() // 3. 공개 POST API = permitAll .requestMatchers(HttpMethod.POST, "/user/login.bjx", "/user/joinUser.bjx", "/api/ranks/submit", + "/bums/save/loc.api", + "/puzzle/**", // <-- 이 줄을 추가하세요. // [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용 "/blog/post/*/like.bjx", "/blog/post/*/unlike.bjx" diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 75483e8..03ce4fe 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -38,6 +38,7 @@ import java.nio.file.Paths import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.* +import javax.imageio.ImageIO // --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 --- @@ -69,6 +70,8 @@ class BlogController( @Value("\${image.upload.path}") private val uploadPath: String? = null + @Value("\${api.base-url}") + private lateinit var apiBaseUrl: String private data class DeltaOp(val insert: Any) private data class Delta(val ops: List) @@ -237,12 +240,26 @@ class BlogController( @ResponseBody suspend fun home(): ResultMV { val vm = ResultMV("content/home") + val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg" + try { - val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() - if (randomImage != null) { - vm.modelMap["randomBannerImage"] = randomImage.path + var bannerImagePath: String? = null + val randomImage: ImageMeta? = imageMetaService.getRandomBannerImage().awaitSingleOrNull() + + if (randomImage != null && !randomImage.path.isNullOrBlank()) { + // 1. 이미지 경로가 예전 방식인지 확인하고 수정합니다. + if (randomImage.path.contains("/blog/post/images/")) { + bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") + } else { + bannerImagePath = randomImage.path + } } + // 2. 최종 경로가 유효하면 모델에 추가하고, 아니면 기본 이미지를 사용합니다. + vm.modelMap["randomBannerImage"] = if (!bannerImagePath.isNullOrBlank()) bannerImagePath else defaultBannerImage + + + val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() vm.modelMap["Posts"] = postsList.map { processPostForView(it) } vm.modelMap["path"] = "/blog/viewer/" @@ -254,6 +271,26 @@ class BlogController( return vm } + // [신규 추가] 이미지 배너 승인 API (관리자 전용) + @PostMapping("/api/images/{imageId}/approve-banner") + @PreAuthorize("hasRole('ADMIN')") + @ResponseBody + fun approveBannerImage(@PathVariable imageId: String): Mono> { + return imageMetaService.approveForBanner(imageId) + .map { ResponseEntity.ok(it) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } + + // [신규 추가] 이미지 배너 승인 해제 API (관리자 전용) + @PostMapping("/api/images/{imageId}/revoke-banner") + @PreAuthorize("hasRole('ADMIN')") + @ResponseBody + fun revokeBannerImage(@PathVariable imageId: String): Mono> { + return imageMetaService.revokeBannerApproval(imageId) + .map { ResponseEntity.ok(it) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } + /** * [수정됨] 게시물 목록 페이지를 역할 기반으로 렌더링합니다. */ @@ -553,11 +590,35 @@ class BlogController( } val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}" val targetPath = Paths.get(uploadPath, uniqueFilename) + return try { + // 1. 파일을 디스크에 저장 Files.createDirectories(targetPath.parent) - Files.copy(file.inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING) - Mono.just(ImageUploadResponse(0, "Success", uniqueFilename)) - } catch (e: IOException) { + file.transferTo(targetPath.toFile()) + + // 2. 저장된 파일의 이미지 정보(가로/세로 크기) 읽기 + val bufferedImage = ImageIO.read(targetPath.toFile()) + val width = bufferedImage?.width ?: 0 + val height = bufferedImage?.height ?: 0 + + // 3. DB에 저장할 ImageMeta 객체 생성 + val imageMeta = ImageMeta( + fileName = uniqueFilename, + originalFileName = file.originalFilename, + fileType = file.contentType, + fileSize = file.size, + width = width, + height = height, + uploadTime = System.currentTimeMillis(), + path = "/api/images/$uniqueFilename" // 새로운 API 경로 사용 + ) + + // 4. 메타데이터를 DB에 저장하고, 성공하면 클라이언트에 응답 + imageMetaService.save(imageMeta).map { + ImageUploadResponse(0, "Success", uniqueFilename) + } + } catch (e: Exception) { + logService.log("File upload or metadata save failed: ${e.message}") Mono.just(ImageUploadResponse(2, "File save failed: ${e.message}", null)) } } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt index 0e2d844..c6833bd 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt @@ -4,6 +4,7 @@ import kr.lunaticbum.back.lun.model.GameRank import kr.lunaticbum.back.lun.model.GameRankService import kr.lunaticbum.back.lun.model.GameType import kr.lunaticbum.back.lun.model.UnifiedRankDto +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import reactor.core.publisher.Flux @@ -14,12 +15,20 @@ import reactor.core.publisher.Mono class GameRankController(private val gameRankService: GameRankService) { /** - * 모든 게임을 위한 통합 랭킹 등록 엔드포인트 + * [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가) */ @PostMapping("/submit") - fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono> { + fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono> { return gameRankService.submitRank(rankDto) - .map { savedRank -> ResponseEntity.ok(savedRank) } + .map { savedRank -> ResponseEntity.ok(savedRank) } + .onErrorResume(IllegalArgumentException::class.java) { e -> + // 서비스에서 이름 중복 예외가 발생하면 409 Conflict 상태와 에러 메시지를 반환 + Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).body(e.message)) + } + .onErrorResume { + // 기타 예외는 500 Internal Server Error로 처리 + Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다.")) + } } /** diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt deleted file mode 100644 index 602bec7..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt +++ /dev/null @@ -1,262 +0,0 @@ -//package kr.lunaticbum.back.lun.controllers -// -//import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -//import com.google.gson.Gson -//import com.google.gson.JsonObject -//import com.google.gson.JsonParser -//import jakarta.servlet.http.HttpServletResponse -//import kotlinx.coroutines.reactor.awaitSingle -//import kotlinx.coroutines.reactor.awaitSingleOrNull -//import kr.lunaticbum.back.lun.model.ImageMeta -//import kr.lunaticbum.back.lun.model.ImageMetaService -//import kr.lunaticbum.back.lun.model.Post -//import kr.lunaticbum.back.lun.model.PostManager -//import kr.lunaticbum.back.lun.model.ResultMV -//import kr.lunaticbum.back.lun.utils.LogService -//import net.coobird.thumbnailator.Thumbnails -//import org.commonmark.node.Node -//import org.commonmark.parser.Parser -//import org.commonmark.renderer.html.HtmlRenderer -//import org.jsoup.Jsoup -//import org.jsoup.nodes.Element -//import org.springframework.beans.factory.annotation.Autowired -//import org.springframework.beans.factory.annotation.Value -//import org.springframework.data.domain.Pageable -//import org.springframework.web.bind.annotation.GetMapping -//import org.springframework.web.bind.annotation.RequestMapping -//import org.springframework.web.bind.annotation.RestController -//import java.io.File -//import java.io.IOException -//import java.net.URLDecoder -// -//@RestController -//@RequestMapping() -//class Home { -// -// @Autowired -// private lateinit var imageMetaService: ImageMetaService -// -// @Autowired -// lateinit var logService: LogService -// -// @Autowired -// private lateinit var postManager: PostManager -// -// data class PostView( -// val id: Long, -// val title: String, -// val thumb: String?, -// val writeTime: Long, -// val textOnly: String, -// val firstImage: String? -// ) -// -// data class DeltaOp(val insert: Any) -// data class Delta(val ops: List) -// -// fun extractFromDelta(deltaJson: String): Pair { -// -// val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java) -// -// var textOnly = StringBuilder() -// var firstImage: String? = null -// -// delta.ops.forEach { op -> -// if (op.insert is String) { -// textOnly.append(op.insert) -// } else if (op.insert is Map<*, *>) { -// val obj = op.insert as Map<*, *> -// if (obj["image"] != null && firstImage == null) { -// firstImage = obj["image"].toString() -// } -// } -// } -// return textOnly.toString() to firstImage -// } -// -// @GetMapping("/","/home.bs") -// suspend fun home() : ResultMV { -// val vm = ResultMV("content/home") -// try { -// try { -// // 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다. -// // DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다. -// val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() // -// -// // 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다. -// if (randomImage != null) { -// vm.modelMap.put("randomBannerImage", randomImage.path) -// } -// // 3. else (null인 경우): 아무것도 하지 않습니다. -// // 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다. -// -// } catch (e: Exception) { -// // 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다. -// logService.log("CRITICAL Error during random banner image query: ${e.message}") -// } -// -// // === [FIXED LOGIC] === -// // 1. Asynchronously await the Mono result without blocking the thread. -// // Use awaitSingleOrNull() just like the random image query. -// val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() -// -// // 2. Apply the processing logic to the resulting list. -// vm.modelMap.put("Posts", postsList.apply { -// this.forEach { -// it.title = URLDecoder.decode(it.title) -// it.content = URLDecoder.decode(it.content) -// val parser: Parser = Parser.builder().build() -// val document: Node = parser.parse(it.content) -// val renderer = HtmlRenderer.builder().build() -// println("content >>> ${it.content}") -// try { -// JsonParser.parseString(it.content) -// it.content?.let { content -> -// var delta = extractFromDelta(content) -// val firstImg = delta.second -// it.image = firstImg ?: "images/pic01.jpg" -// it.thumb = firstImg ?: "images/pic01.jpg" -// it.html = delta.first -// } -// } catch (e: Exception) { -// Jsoup.parse(renderer.render(document))?.let { doc -> -// val firstImg: Element? = doc.select("img")?.first() -// val imgSrc: String = firstImg?.attr("src") ?: "" -// it.image = imgSrc -// it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail.")) -// generateThumbnail(imgSrc.split("/").last(), 200) -// it.html = doc.text() -// } -// } -// it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" -// } -// }) -// // === [END FIXED LOGIC] === -// }catch (ex: Exception){ex.printStackTrace()} -// vm.modelMap.put("path","/blog/viewer/") -// return vm -// } -// -// @Value("\${image.upload.path}") -// private val uploadPath: String? = null -// -// @Value("\${resource.handler}") -// private val resourceHandler: String? = null -// -// fun generateThumbnail(originalPath: String, targetWidth: Int) { -// try { -// val originalFile = File("$uploadPath${File.separator}$originalPath") -// println("origin ${originalPath}") -// println("thumb ${originalPath -// .replace(".", "_thumbnail.")}") -// // 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg) -// val thumbnailPath = originalPath -// .replace(".", "_thumbnail.") -// -// -// val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath") -// // 썸네일 이미 존재하면 종료 -// if (thumbnailFile.exists()) { -// println("썸네일 이미 존재: $thumbnailPath") -// return -// } -// -// -// // 원본 파일 존재 확인 -// if (!originalFile.exists()) { -// println("원본 파일 없음: $originalPath") -// return -// } -// -// // 썸네일 생성 (가로 기준 비율 유지) -// Thumbnails.of(originalFile) -// .width(targetWidth) -// .keepAspectRatio(true) -// .toFile(thumbnailFile) -// -// println("썸네일 생성 완료: $thumbnailFile") -// } catch (e: IOException) { -// println("썸네일 생성 실패: ${e.message}") -// } -// } -// -// -// @GetMapping("/h2") -// fun home2() : ResultMV { -// val vm = ResultMV("content/index_ex") -// vm.modelMap.put("Posts", postManager.find20().apply { -// this.forEach { -// it.title = URLDecoder.decode(it.title) -// it.content = URLDecoder.decode(it.content) -// logService.log(Gson().toJson(it)) -// } -// }) -// return vm -// } -// -// @GetMapping("/left-sidebar") -// fun lside() : ResultMV { -// val vm = ResultMV("content/left-sidebar") -// vm.modelMap.put("Posts", postManager.find20().apply { -// this.forEach { -// it.title = URLDecoder.decode(it.title) -// it.content = URLDecoder.decode(it.content) -// logService.log(Gson().toJson(it)) -// } -// }) -// return vm -// } -// @GetMapping("/no-sidebar") -// fun nside() : ResultMV { -// val vm = ResultMV("content/no-sidebar") -// vm.modelMap.put("Posts", postManager.find20().apply { -// this.forEach { -// it.title = URLDecoder.decode(it.title) -// it.content = URLDecoder.decode(it.content) -// logService.log(Gson().toJson(it)) -// } -// }) -// return vm -// } -// -// @GetMapping("/right-sidebar") -// fun rside() : ResultMV { -// val vm = ResultMV("content/right-sidebar") -// vm.modelMap.put("Posts", postManager.find20().apply { -// this.forEach { -// it.title = URLDecoder.decode(it.title) -// it.content = URLDecoder.decode(it.content) -// logService.log(Gson().toJson(it)) -// } -// }) -// return vm -// } -// -// @GetMapping("/two-sidebar") -// fun bside() : ResultMV { -// val vm = ResultMV("content/two-sidebar") -// vm.modelMap.put("Posts", postManager.find20().apply { -// this.forEach { -// it.title = URLDecoder.decode(it.title) -// it.content = URLDecoder.decode(it.content) -// logService.log(Gson().toJson(it)) -// } -// }) -// return vm -// } -// -// -// -// -// @GetMapping("/login") -// fun login(response: HttpServletResponse) { -// response.sendRedirect("/user/login") -// } -// -// @GetMapping("/licenses") -// fun licenses() : ResultMV { -// val vm = ResultMV("content/licenses") -// return vm -// } -// -//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Owner.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Owner.kt deleted file mode 100644 index 84b7edf..0000000 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Owner.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kr.lunaticbum.back.lun.controllers - -class Owner { - -} \ No newline at end of file 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 51b70b8..fddfced 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -3,10 +3,13 @@ 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.core.io.UrlResource +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.ui.Model import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile +import java.nio.file.Paths /** * [통합 게임 API 허브 컨트롤러] @@ -22,6 +25,25 @@ class PuzzleController( @Value("\${puzzle.image.path}") private val puzzleImagePath: String ) { + // [신규 추가] 저장된 퍼즐 이미지를 제공하는 API + @GetMapping("/images/{filename}") + fun getPuzzleImage(@PathVariable filename: String): ResponseEntity { + return try { + val path = Paths.get(puzzleImagePath).resolve(filename) + val resource = UrlResource(path.toUri()) + + if (resource.exists() || resource.isReadable) { + ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) // 이미지는 PNG로 저장했으므로 + .body(resource) + } else { + ResponseEntity.notFound().build() + } + } catch (e: Exception) { + ResponseEntity.internalServerError().build() + } + } + // ====================================================== // 1. NONOGRAM API (기존 엔드포인트 유지) // ====================================================== diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt index 3c53b9a..b42931c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/UserController.kt @@ -43,9 +43,12 @@ import kotlin.collections.emptyList @RequestMapping("/user") class UserController( private val rememberMeServices: RememberMeServices, - private val userManager: UserManager, // 의존성 주입 추가 - private val postManager: PostManager, // 의존성 주입 추가 - private val commentService: CommentService // 의존성 주입 추가 + private val userManager: UserManager, + private val postManager: PostManager, + private val commentService: CommentService, + private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입 + + private val imageMetaService: ImageMetaService ) { @@ -235,7 +238,7 @@ class UserController( } /** - * [신규 추가] '내 정보' 페이지를 위한 핸들러 + * [수정] '내 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가) */ @GetMapping("/info") suspend fun myInfoPage(@AuthenticationPrincipal userDetails: UserDetails?): ResultMV { @@ -263,6 +266,11 @@ class UserController( // 3. 내가 쓴 댓글 목록 조회 (최신 10개) val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block() vm.modelMap["myComments"] = myComments ?: emptyList() + + // 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개) + val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block() + vm.modelMap["myRanks"] = myRanks ?: emptyList() + vm.modelMap["pageTitle"] = "내 정보" // 동적 페이지 제목 설정 val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true @@ -273,6 +281,8 @@ class UserController( vm.modelMap["allUsers"] = userManager.findAllUsers().collectList().block() vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block() vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회 + vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block() + } return vm diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt index aad7814..30933f0 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt @@ -4,6 +4,9 @@ import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document import java.time.Instant import org.springframework.data.repository.reactive.ReactiveSortingRepository +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails import org.springframework.stereotype.Repository import org.springframework.stereotype.Service import reactor.core.publisher.Flux @@ -70,11 +73,16 @@ interface GameRankRepository : ReactiveSortingRepository { gameType: GameType, contextId: String? ): Flux + + // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회 + fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux } @Service -class GameRankService(private val rankRepository: GameRankRepository) { +class GameRankService( + private val rankRepository: GameRankRepository, + private val userManager: UserManager ) { /** * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. */ @@ -91,16 +99,52 @@ class GameRankService(private val rankRepository: GameRankRepository) { } /** - * 공통 DTO를 받아 랭킹을 저장합니다. + * [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가) */ fun submitRank(rankDto: UnifiedRankDto): Mono { - val gameRank = GameRank( - gameType = rankDto.gameType, - contextId = rankDto.contextId, - playerName = rankDto.playerName, - primaryScore = rankDto.primaryScore, - secondaryScore = rankDto.secondaryScore - ) - return rankRepository.save(gameRank) + val auth = SecurityContextHolder.getContext().authentication + + // 로그인 사용자인지, 비로그인(익명) 사용자인지 확인 + val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken + + if (isAuthenticated) { + // 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화) + val principal = auth.principal as UserDetails + val authenticatedUsername = principal.username + + val gameRank = GameRank( + gameType = rankDto.gameType, + contextId = rankDto.contextId, + playerName = authenticatedUsername, // 실제 인증된 이름 사용 + primaryScore = rankDto.primaryScore, + secondaryScore = rankDto.secondaryScore + ) + return rankRepository.save(gameRank) + } else { + // 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인 + return userManager.findById(rankDto.playerName) + .flatMap { existingUser -> + // 사용자가 존재하면 에러 발생 + Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요.")) + } + .switchIfEmpty(Mono.defer { + // 사용자가 존재하지 않으면 랭킹 저장 진행 + val gameRank = GameRank( + gameType = rankDto.gameType, + contextId = rankDto.contextId, + playerName = rankDto.playerName, + primaryScore = rankDto.primaryScore, + secondaryScore = rankDto.secondaryScore + ) + rankRepository.save(gameRank) + }) + } + } + + /** + * [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다. + */ + fun getRanksByPlayer(playerName: String): Flux { + return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) } } \ No newline at end of file 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 ecbf322..a9ed559 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt @@ -12,11 +12,13 @@ import org.bson.codecs.pojo.annotations.BsonId import org.bson.codecs.pojo.annotations.BsonRepresentation import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.annotation.Profile import org.springframework.context.event.EventListener import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.repository.Aggregation import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.stereotype.Service +import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.io.File import java.nio.file.Files @@ -37,7 +39,9 @@ data class ImageMeta( var width: Int, // 이미지 가로 픽셀 var height: Int, // 이미지 세로 픽셀 var uploadTime: Long, // 등록일시 (Timestamp) - var path: String // 이미지 접근 가능 URL 경로 + var path: String, // 이미지 접근 가능 URL 경로 + + var isBannerCandidate: Boolean = false ) /** @@ -48,6 +52,13 @@ interface ImageMetaRepository : ReactiveMongoRepository { @Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ]) fun findRandomImage(): Mono + // [신규 추가] isBannerCandidate가 true인 이미지 중에서만 랜덤으로 1개를 선택 + @Aggregation(pipeline = [ + "{ \$match: { isBannerCandidate: true } }", + "{ \$sample: { size: 1 } }" + ]) + fun findRandomBannerCandidate(): Mono + fun findByFileName(fileName: String): Mono // [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능 @@ -87,6 +98,7 @@ class ImageMetaService( /** * [신규 추가] Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너 */ + @Profile("!local") @EventListener(ApplicationReadyEvent::class) fun onApplicationReady() { logService.log("Application ready. Launching initial image DB sync task...") @@ -178,4 +190,33 @@ class ImageMetaService( e.printStackTrace() } } + + /** + * [이름 변경 및 로직 수정] 기존 getRandomImage -> getRandomBannerImage + * 배너 후보로 지정된 이미지 중에서 랜덤으로 하나를 가져옵니다. + */ + fun getRandomBannerImage(): Mono { + return repository.findRandomBannerCandidate() + } + + // [신규 추가] 관리자 페이지에서 모든 이미지를 조회하기 위한 메서드 + fun getAllImages(): Flux { + return repository.findAll() + } + + // [신규 추가] 특정 이미지를 배너 후보로 승인하는 메서드 + fun approveForBanner(imageId: String): Mono { + return repository.findById(imageId).flatMap { image -> + image.isBannerCandidate = true + repository.save(image) + } + } + + // [신규 추가] 특정 이미지의 배너 후보 자격을 해제하는 메서드 + fun revokeBannerApproval(imageId: String): Mono { + return repository.findById(imageId).flatMap { image -> + image.isBannerCandidate = false + repository.save(image) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index a31b804..105eaa3 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -35,7 +35,9 @@ import java.time.Duration import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가] import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가] import org.springframework.data.mongodb.core.index.Indexed // [신규 추가] +import java.text.SimpleDateFormat import java.util.Base64 +import java.util.Date @Document(collection = "Post") @CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}") @@ -121,19 +123,28 @@ interface CommentRepository : ReactiveMongoRepository { @Service class CommentService(private val commentRepository: CommentRepository) { + + /** + * [수정] 각 댓글의 content를 URL 디코딩하는 로직을 추가합니다. + */ + private fun decodeCommentContent(comment: Comment): Comment { + comment.content = comment.content?.let { URLDecoder.decode(it, "UTF-8") } + return comment + } + fun getRepliesForComment(parentId: String): Flux { - return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId) + return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 } fun addComment(comment: Comment): Mono { // 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리 - return commentRepository.save(comment) + return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 } fun getCommentsForPost(postId: String): Flux { - return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId) + return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 } fun findCommentsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] - return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable) + return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 } // 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능 } @@ -281,8 +292,16 @@ class PostManager( .collectList() } - fun findPostsByWriter(writer: String, pageable: Pageable): Flux { // [신규 추가] + fun findPostsByWriter(writer: String, pageable: Pageable): Flux { return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable) + .map { post -> + post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + if (post.title.isNullOrBlank()) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") + post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" + } + post + } } /** @@ -310,14 +329,20 @@ class PostManager( * 인증된 사용자를 위한 메서드 (모든 버전 조회) * [FIX]: Change return type to Mono> and remove the blocking call. */ - fun findAllVersionsPaginated(pageable :Pageable) : Mono> { // <-- 1. Change return type - println("pageSize >>> ${pageable.pageSize}") - println("pageNumber >>> ${pageable.pageNumber}") - return postRepository.findAllByOrderByModifyTimeDesc(pageable) - .doOnNext { println(it) } // map 대신 doOnNext로 로그 출력 - .collectList() // Flux → Mono> - // .block(Duration.ofSeconds(30)) // <-- 2. REMOVE THIS BLOCK - // ?: listOf() + fun findAllVersionsPaginated(pageable :Pageable) : Mono> { + return postRepository.findAllByOrderByModifyTimeDesc(pageable) + .map { post -> + // 1. 제목을 UTF-8로 디코딩합니다. + post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + + // 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다. + if (post.title.isNullOrBlank()) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") + post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" + } + post // 수정된 post 객체를 반환 + } + .collectList() } /** 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 c993a61..e1ed250 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -51,7 +51,9 @@ data class NonogramPuzzle( @Repository interface NonogramPuzzleRepository : ReactiveMongoRepository { @Aggregation(pipeline = [ - "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }", +// "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }", +// "{ \$sample: { size: 1 } }" + "{ \$match: { originalImageFile: { \$exists: true }, grayscaleImageFile: { \$exists: true } } }", "{ \$sample: { size: 1 } }" ]) fun findRandom(): Flux // (★ Flux를 인식하기 위해 import 필요) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt index 9798adf..9b5cedd 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt @@ -145,7 +145,7 @@ class UserManager( // return userRepository.findByEmail(id) // } - override fun findById(id: String): Mono? { + override fun findById(id: String): Mono { return userRepository.findById(id) } diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 0000000..5f5d41f --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,103 @@ +spring.application.name=lun +server.port=443 +spring.datasource.username=c +spring.datasource.password=c +spring.datasource.driver-class-name=org.mariadb.jdbc.Driver +#<<<<<<< HEAD +#spring.data.mongodb.host=nas.lunaticbum.kr +#spring.data.mongodb.host=localhost +#spring.data.mongodb.port=27017 +#spring.data.mongodb.database=lun_db +#SSL +#server.ssl.key-store=classpath:prv.p12 +#server.ssl.key-store-type=PKCS12 +#server.ssl.key-store-password=VioPup*383 +#server.http2.enabled=true +#spring.main.web-application-type=SERVLET +#logging.level.org.springframework.boot.autoconfigure=ERROR +#spring.mvc.view.prefix=/templates +#spring.mvc.view.suffix=.html +#server.servlet.register-default-servlet=true +#======= +spring.datasource.url=b +spring.data.mongodb.uri=a +spring.data.mongodb.authentication-database=admin +spring.data.mongodb.database=l +spring.thymeleaf.prefix=classpath:/templates/ +spring.thymeleaf.suffix=.html +spring.thymeleaf.enabled=true + +spring.servlet.multipart.max-file-size=1024MB +spring.servlet.multipart.max-request-size=1024MB +spring.servlet.multipart.enabled=true + +# ?? ???? ??? ?? ? ?? ????. +spring.devtools.livereload.enabled=true +# thymeleaf? ?? ??? ??? ???. cache=false ??(???? true) +spring.thymeleaf.cache=false +# templates ????? ??? ??? ??? ??, ??? ??? ?????. +spring.thymeleaf.check-template-location=true +telegram.bot.key=1 +telegram.my.id=2 +telegram.target.id=3 +weather.api.key=3 +api.gg.place=5 +spring.data.mongodb.option.min-connection-per-host=0 +spring.data.mongodb.option.max-connection-per-host=100 +spring.data.mongodb.option.threads-allowed-to-block-for-connection-multiplier=5 +spring.data.mongodb.option.server-selection-timeout=30000 +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-keep-alive=false +spring.data.mongodb.option.ssl-enabled=false +spring.data.mongodb.option.ssl-invalid-host-name-allowed=false +spring.data.mongodb.option.always-use-m-beans=false + +spring.data.mongodb.option.heartbeat-socket-timeout=20000 +spring.data.mongodb.option.heartbeat-connect-timeout=20000 +spring.data.mongodb.option.min-heartbeat-frequency=500 +spring.data.mongodb.option.heartbeat-frequency=10000 +spring.data.mongodb.option.local-threshold=15 + +spring.ai.ollama.base-url=https://lama.lunaticbum.kr +#spring.ai.ollama.chat.options.model=phi4:14b + +##spring.data.redis.url=ollama.lunaticbum.kr +#spring.data.redis.host=lunaticbum.kr +#spring.data.redis.port=6379 +# +##spring.ai.vectorstore.redis.uri="redis://lunaticbum.kr:6379" +# +# +#spring.ai.vectorstore.redis.initialize-schema=true +#spring.ai.vectorstore.redis.index=spring-ai-redis-index +#spring.ai.vectorstore.redis.prefix=spring-ai-redis-embedding +#https://ollama.lunaticbum.kr/collections/blama_vectors +spring.ai.vectorstore.qdrant.host=ollama.lunaticbum.kr +spring.ai.vectorstore.qdrant.port=443 +#spring.ai.vectorstore.qdrant.initialize-schema=true +spring.ai.vectorstore.qdrant.api-key=blama-admin-key-gb +spring.ai.vectorstore.qdrant.collection-name=blama_vectors +#spring.ai.ollama.embedding.model=nomic-embed-text + +spring.ai.ollama.embedding.enabled=true + +resource.handler=. +resource.location=. +server.forward-headers-strategy=framework +#>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59 +logging.level.org.thymeleaf=DEBUG +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 + +# Increase server connection timeout to 60 seconds (default is often 20 or 30s) +server.tomcat.connection-timeout=60s +# For reactive applications (like yours), also set this timeout +spring.webflux.response-timeout=60s +api.base-url=ss \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..5f5d41f --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,103 @@ +spring.application.name=lun +server.port=443 +spring.datasource.username=c +spring.datasource.password=c +spring.datasource.driver-class-name=org.mariadb.jdbc.Driver +#<<<<<<< HEAD +#spring.data.mongodb.host=nas.lunaticbum.kr +#spring.data.mongodb.host=localhost +#spring.data.mongodb.port=27017 +#spring.data.mongodb.database=lun_db +#SSL +#server.ssl.key-store=classpath:prv.p12 +#server.ssl.key-store-type=PKCS12 +#server.ssl.key-store-password=VioPup*383 +#server.http2.enabled=true +#spring.main.web-application-type=SERVLET +#logging.level.org.springframework.boot.autoconfigure=ERROR +#spring.mvc.view.prefix=/templates +#spring.mvc.view.suffix=.html +#server.servlet.register-default-servlet=true +#======= +spring.datasource.url=b +spring.data.mongodb.uri=a +spring.data.mongodb.authentication-database=admin +spring.data.mongodb.database=l +spring.thymeleaf.prefix=classpath:/templates/ +spring.thymeleaf.suffix=.html +spring.thymeleaf.enabled=true + +spring.servlet.multipart.max-file-size=1024MB +spring.servlet.multipart.max-request-size=1024MB +spring.servlet.multipart.enabled=true + +# ?? ???? ??? ?? ? ?? ????. +spring.devtools.livereload.enabled=true +# thymeleaf? ?? ??? ??? ???. cache=false ??(???? true) +spring.thymeleaf.cache=false +# templates ????? ??? ??? ??? ??, ??? ??? ?????. +spring.thymeleaf.check-template-location=true +telegram.bot.key=1 +telegram.my.id=2 +telegram.target.id=3 +weather.api.key=3 +api.gg.place=5 +spring.data.mongodb.option.min-connection-per-host=0 +spring.data.mongodb.option.max-connection-per-host=100 +spring.data.mongodb.option.threads-allowed-to-block-for-connection-multiplier=5 +spring.data.mongodb.option.server-selection-timeout=30000 +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-keep-alive=false +spring.data.mongodb.option.ssl-enabled=false +spring.data.mongodb.option.ssl-invalid-host-name-allowed=false +spring.data.mongodb.option.always-use-m-beans=false + +spring.data.mongodb.option.heartbeat-socket-timeout=20000 +spring.data.mongodb.option.heartbeat-connect-timeout=20000 +spring.data.mongodb.option.min-heartbeat-frequency=500 +spring.data.mongodb.option.heartbeat-frequency=10000 +spring.data.mongodb.option.local-threshold=15 + +spring.ai.ollama.base-url=https://lama.lunaticbum.kr +#spring.ai.ollama.chat.options.model=phi4:14b + +##spring.data.redis.url=ollama.lunaticbum.kr +#spring.data.redis.host=lunaticbum.kr +#spring.data.redis.port=6379 +# +##spring.ai.vectorstore.redis.uri="redis://lunaticbum.kr:6379" +# +# +#spring.ai.vectorstore.redis.initialize-schema=true +#spring.ai.vectorstore.redis.index=spring-ai-redis-index +#spring.ai.vectorstore.redis.prefix=spring-ai-redis-embedding +#https://ollama.lunaticbum.kr/collections/blama_vectors +spring.ai.vectorstore.qdrant.host=ollama.lunaticbum.kr +spring.ai.vectorstore.qdrant.port=443 +#spring.ai.vectorstore.qdrant.initialize-schema=true +spring.ai.vectorstore.qdrant.api-key=blama-admin-key-gb +spring.ai.vectorstore.qdrant.collection-name=blama_vectors +#spring.ai.ollama.embedding.model=nomic-embed-text + +spring.ai.ollama.embedding.enabled=true + +resource.handler=. +resource.location=. +server.forward-headers-strategy=framework +#>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59 +logging.level.org.thymeleaf=DEBUG +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 + +# Increase server connection timeout to 60 seconds (default is often 20 or 30s) +server.tomcat.connection-timeout=60s +# For reactive applications (like yours), also set this timeout +spring.webflux.response-timeout=60s +api.base-url=ss \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6b56a5e..5f5d41f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -99,4 +99,5 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE # Increase server connection timeout to 60 seconds (default is often 20 or 30s) server.tomcat.connection-timeout=60s # For reactive applications (like yours), also set this timeout -spring.webflux.response-timeout=60s \ No newline at end of file +spring.webflux.response-timeout=60s +api.base-url=ss \ No newline at end of file diff --git a/src/main/resources/static/css/common_game_theme.css b/src/main/resources/static/css/common_game_theme.css index 52d60ea..369e3b2 100644 --- a/src/main/resources/static/css/common_game_theme.css +++ b/src/main/resources/static/css/common_game_theme.css @@ -89,7 +89,7 @@ button:disabled { /* (★ 통일) 게임 컨트롤/랭킹 등을 감싸는 공통 '카드' UI (변수 사용) */ #sudoku-game-app .container, -.ranking-container, +.game-body-wrapper .ranking-container, /* <-- 이렇게 수정하세요 */ #setup-container, #game-controls { background: var(--color-bg-card); diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 14b58b0..0c6458f 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -110,9 +110,9 @@ window.addEventListener('DOMContentLoaded', () => { } // --- 1. 사이드바 목록 가져오기 (이 기능은 이미 바닐라 JS였습니다) --- - if (document.querySelector(".rank_of_view")) { - fetchRankOfViews(); - } + // if (document.querySelector(".rank_of_view")) { + // fetchRankOfViews(); + // } if (document.querySelector(".recent_posts")) { fetchRecentPosts(); } @@ -165,7 +165,7 @@ window.addEventListener('DOMContentLoaded', () => { e.preventDefault(); // isLoggedIn 변수를 사용하여 추가적인 클라이언트 측 방어 if (!isLoggedIn) { - alert('로그인이 필요합니다.'); + showAlert("알림",'로그인이 필요합니다.'); return; } submitComment(); @@ -421,7 +421,7 @@ function selectLocalVideo() { input.onchange = () => { const file = input.files[0]; if (!file || !file.type.startsWith('video/')) { - alert('동영상 파일만 업로드할 수 있습니다.'); + showAlert("알림",'동영상 파일만 업로드할 수 있습니다.'); return; } uploadVideo(file); @@ -617,7 +617,7 @@ function save() { } const uploadUrl = `${getMainPath()}/blog/post.bjx`; - if (confirm("해당 내용으로 저장하시겠습니까?")) { + if (showConfirm("확인","해당 내용으로 저장하시겠습니까?")) { console.log("Data being sent to server:", dataToSend); // 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용) @@ -626,14 +626,14 @@ function save() { const response = JSON.parse(resultData); if (response.resultCode === 0 && response.data && response.data.postId) { // 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동 - alert("저장되었습니다. 게시물 보기 페이지로 이동합니다."); + showAlert("알림","저장되었습니다. 게시물 보기 페이지로 이동합니다."); location.href = getMainPath() + "/blog/viewer/" + response.data.postId; } else { - alert("저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error")); + showAlert("알림","저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error")); } } catch (e) { console.error("Failed to parse save response:", e, resultData); - alert("저장에 성공했으나 서버 응답을 처리할 수 없습니다."); + showAlert("알림","저장에 성공했으나 서버 응답을 처리할 수 없습니다."); } }); } @@ -805,7 +805,7 @@ function submitLoginForm() { if (response.isOk) { location.reload(); // 로그인 성공 시 페이지 새로고침 } else { - alert(`로그인 실패: ${response.resultMsg}`); + showAlert(`로그인 실패`, `${response.resultMsg}`); } }); } @@ -843,7 +843,7 @@ function onclickJoin(type, keyword) { case user_id : if (korean.test(text)) { hasValues = false - alert("id를 확인 해보슈."); + showAlert("알림","id를 확인 해보슈."); } break; case user_pw : @@ -854,23 +854,23 @@ function onclickJoin(type, keyword) { false === spPattern.test(text) ) { hasValues = false - alert("pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈."); + showAlert("알림","pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈."); } break case user_email : if(false === email.test(field.value)) { hasValues = false - alert("email를 확인 해보슈."); + showAlert("알림","email를 확인 해보슈."); } break } } else if (hasValues) { hasValues = false switch (field) { - case user_id : alert("id를 확인 해보슈.");break - case user_pw : alert("pw를 확인 해보슈.");break - case user_pw_check : alert("pw를 확인 해보슈.");break - case user_name : alert("name를 확인 해보슈.");break - case user_email : alert("email를 확인 해보슈.");break + case user_id : showAlert("알림","id를 확인 해보슈.");break + case user_pw : showAlert("알림","pw를 확인 해보슈.");break + case user_pw_check : showAlert("알림","pw를 확인 해보슈.");break + case user_name : showAlert("알림","name를 확인 해보슈.");break + case user_email : showAlert("알림","email를 확인 해보슈.");break } } }) @@ -882,15 +882,15 @@ function onclickJoin(type, keyword) { 'user_name': user_name.value } if (user_pw.value === user_pw_check.value) { - if(confirm(JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) { + if(showConfirm("확인",JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) { post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) { - alert(resultData) + showAlert("알림",resultData) }) } else { } } else { - alert("비번이 다름요") + showAlert("알림","비번이 다름요") } } } @@ -936,7 +936,7 @@ function post(target, type, data, key, callBackResult) { httpRequest.onreadystatechange = () => { if (httpRequest.readyState === XMLHttpRequest.DONE) { if (httpRequest.status === 200) callBackResult(httpRequest.response); - else alert('Request Error!'); + else showAlert("알림",'Request Error!'); } }; httpRequest.open('POST', target, true); @@ -969,7 +969,7 @@ function postLogin(target, type, data, key, callBackResult) { try { callBackResult(JSON.parse(httpRequest.response)); } catch (e) { console.error("Login response parse error:", e); } - } else { alert('Request Error!'); } + } else { showAlert("알림",'Request Error!'); } } }; httpRequest.withCredentials = true; // 쿠키(세션) 전송 허용 @@ -1151,7 +1151,7 @@ function handleVote(buttonElement, voteType) { .catch(error => { // 실패 시 console.error('Error handling vote:', error); - alert('투표 중 오류가 발생했습니다.'); + showAlert("알림",'투표 중 오류가 발생했습니다.'); controls.querySelectorAll('button').forEach(btn => btn.disabled = false); // 버튼 다시 활성화 }); } @@ -1166,7 +1166,7 @@ function submitComment() { const content = commentInput.value.trim(); if (content.length === 0) { - alert('댓글 내용을 입력하세요.'); + showAlert("알림",'댓글 내용을 입력하세요.'); commentInput.focus(); return; } @@ -1182,7 +1182,7 @@ function submitComment() { const postId = serverData.id; if (!postId) { - alert("게시물 ID를 찾을 수 없습니다."); + showAlert("알림","게시물 ID를 찾을 수 없습니다."); return; } @@ -1196,12 +1196,12 @@ function submitComment() { try { const response = JSON.parse(resultData); if (response.resultCode === 0) { - alert('댓글이 성공적으로 등록되었습니다.'); + showAlert("알림",'댓글이 성공적으로 등록되었습니다.'); commentInput.value = ''; // 입력창 초기화 cancelReply(); // 답글 상태 초기화 fetchComments(postId); // 목록 새로고침 } else { - alert('댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류')); + showAlert("알림",'댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류')); } } catch (e) { console.error('Failed to parse comment submission response:', e, resultData); @@ -1370,7 +1370,9 @@ async function submitRank(gameType, contextId, playerName, primaryScore, seconda }); if (!response.ok) { - throw new Error('랭킹 등록에 실패했습니다.'); + // [수정] 서버가 에러 메시지를 본문에 보냈을 경우, 해당 메시지를 에러로 throw + const errorMessage = await response.text(); + throw new Error(errorMessage || '랭킹 등록에 실패했습니다.'); } return response.json(); } @@ -1395,3 +1397,158 @@ async function fetchRanks(gameType, contextId = null) { } +/** + * [핵심] 통합 게임 성공 모달을 표시하고 랭킹 관련 로직을 처리하는 함수 + * @param {object} options - 게임 결과 정보 + * @param {string} options.gameType - GameType Enum (예: 'SUDOKU') + * @param {string|null} options.contextId - 게임 세부 ID (예: 퍼즐 ID) + * @param {string} options.successMessage - 모달에 표시할 메시지 (예: "1분 20초만에 클리어!") + * @param {number} options.primaryScore - 랭킹에 등록할 주 점수 + * @param {number|null} options.secondaryScore - 랭킹에 등록할 보조 점수 + */ +async function showGameSuccessModal(options) { + const { gameType, contextId, successMessage, primaryScore, secondaryScore } = options; + + // 1. 모달의 DOM 요소 가져오기 + const modal = document.getElementById('unified-game-success-modal'); + const messageEl = document.getElementById('ugsm-message'); + const rankingListEl = document.getElementById('ugsm-ranking-list'); + // ... (나머지 요소 가져오기는 기존과 동일) + const guestArea = document.getElementById('ugsm-guest-ranking'); + const userArea = document.getElementById('ugsm-user-ranking'); + const playerNameInput = document.getElementById('ugsm-player-name'); + const saveBtn = document.getElementById('ugsm-save-score-btn'); + // 닫기 버튼은 공통 로직으로 처리되므로 여기서 제어할 필요가 없습니다. + + // 2. 성공 메시지 설정 + messageEl.textContent = successMessage; + + // 3. 랭킹 목록 표시 (footer.html의 updateGameRanking과 유사) + rankingListEl.innerHTML = '
  • 로딩 중...
  • '; + try { + const ranks = await fetchRanks(gameType, contextId); + rankingListEl.innerHTML = ''; + if (ranks.length > 0) { + ranks.forEach((rank, index) => { + const li = document.createElement('li'); + // footer.html의 점수 포맷 함수 재사용 + const formattedScore = formatScore(rank.primaryScore, rank.gameType); + li.innerHTML = `${index + 1}. ${rank.playerName} ${formattedScore}`; + rankingListEl.appendChild(li); + }); + } else { + rankingListEl.innerHTML = '
  • 아직 등록된 랭킹이 없습니다.
  • '; + } + } catch (e) { + rankingListEl.innerHTML = '
  • 랭킹을 불러오는데 실패했습니다.
  • '; + } + + if (typeof currentUser !== 'undefined' && currentUser.isLoggedIn) { + // 로그인 상태일 경우 + guestArea.style.display = 'none'; + userArea.style.display = 'block'; + + // 서버에 랭킹 즉시 자동 제출 + try { + await submitRank(gameType, contextId, currentUser.username, primaryScore, secondaryScore); + // 성공 후 랭킹 목록 새로고침 + const updatedRanks = await fetchRanks(gameType, contextId); + // (랭킹 목록 업데이트 로직 추가...) + } catch (error) { + console.error('Auto rank submission failed:', error); + userArea.innerHTML = '

    랭킹 자동 등록에 실패했습니다.

    '; + } + } else { + // 비로그인 상태일 경우 + guestArea.style.display = 'block'; + userArea.style.display = 'none'; + playerNameInput.value = ''; + + // '점수 저장' 버튼에 이벤트 리스너 할당 (중복 할당 방지를 위해 기존 리스너 제거) + const newSaveBtn = saveBtn.cloneNode(true); + saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); + + newSaveBtn.addEventListener('click', async () => { + const playerName = playerNameInput.value.trim(); + if (!playerName) { + showAlert("알림",'이름을 입력해주세요.'); + return; + } + newSaveBtn.disabled = true; + newSaveBtn.textContent = '저장 중...'; + + try { + await submitRank(gameType, contextId, playerName, primaryScore, secondaryScore); + showAlert("알림",'랭킹이 등록되었습니다!'); + // ▼▼▼ [핵심 수정] 이 부분을 바꿔주세요 ▼▼▼ + // 기존 코드: modal.style.display = 'none'; + closePopup(); // 배경(dim)과 팝업을 모두 닫는 공통 함수 호출 + // ▲▲▲ 여기까지 수정 ▲▲▲ + } catch (error) { + showAlert("알림",'랭킹 등록에 실패했습니다: ' + error.message); + newSaveBtn.disabled = false; + newSaveBtn.textContent = '점수 저장'; + } + }); + } + // ▼▼▼ [핵심 수정] 모달을 직접 조작하는 대신, 공통 오버레이와 팝업을 표시합니다. ▼▼▼ + const overlay = document.querySelector('.dim_layer'); + if (modal && overlay) { + overlay.style.display = 'block'; + modal.style.display = 'block'; + } + // ▲▲▲ 여기까지 수정 ▲▲▲ +} + +/** + * 게임 타입에 따라 점수 표시 형식을 변경합니다. + * SUDOKU, NONOGRAM처럼 시간 기반 게임은 mm:ss 형식으로, + * 그 외에는 점수 형식으로 변환합니다. + */ +function formatScore(score, gameType) { + if (['SUDOKU', 'NONOGRAM'].includes(gameType)) { + const minutes = Math.floor(score / 60).toString().padStart(2, '0'); + const seconds = (score % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; + } + if (gameType === 'SPIDER') { + return `${score} moves`; + } + return `${score} 점`; +} + +/** + * 사이트 공통 스타일을 적용한 커스텀 알림(Alert) 함수 + * @param {string} title - 팝업의 제목 + * @param {string} text - 팝업의 내용 + * @param {string} icon - 'success', 'error', 'warning', 'info', 'question' 중 하나 + */ +function showAlert(title, text, icon = 'info') { + Swal.fire({ + title: title, + text: text, + icon: icon, + confirmButtonColor: '#FFA500', // main.css의 --point-color + confirmButtonText: '확인' + }); +} + +/** + * 사이트 공통 스타일을 적용한 커스텀 확인(Confirm) 함수 + * @param {string} title - 팝업의 제목 + * @param {string} text - 팝업의 내용 + * @returns {Promise} 사용자가 '확인'을 누르면 true, '취소'를 누르면 false를 반환 + */ +async function showConfirm(title, text) { + const result = await Swal.fire({ + title: title, + text: text, + icon: 'question', + showCancelButton: true, + confirmButtonColor: '#FFA500', + cancelButtonColor: '#555555', // main.css의 --button-alt-default + confirmButtonText: '확인', + cancelButtonText: '취소' + }); + return result.isConfirmed; +} \ No newline at end of file diff --git a/src/main/resources/templates/content/home.html b/src/main/resources/templates/content/home.html index b477edf..06686dc 100644 --- a/src/main/resources/templates/content/home.html +++ b/src/main/resources/templates/content/home.html @@ -6,7 +6,7 @@ layout:decorate="~{layout/default_layout}">