This commit is contained in:
lunaticbum 2025-09-18 17:55:32 +09:00
parent 17aea8b43b
commit 5e0db4ff03
38 changed files with 3011 additions and 1228 deletions

View File

@ -109,6 +109,9 @@ dependencies {
testImplementation("io.projectreactor:reactor-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// JSON 처리를 위한 Gson 라이브러리
implementation("com.google.code.gson:gson:2.10.1")
}
@ -223,26 +226,101 @@ tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게
// 기본 bootJar 태스크의 설정을 가져오기 위한 참조
val bootJar by tasks.getting(BootJar::class)
//
//// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의
//tasks.register<BootJar>("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"
// }
//}
// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의
tasks.register<BootJar>("bootJarProd") {
// "local" 프로파일용 JAR를 빌드하는 작업
tasks.register<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJarLocal") {
group = "build"
description = "Builds a production JAR that defaults to the 'prod' profile."
archiveClassifier.set("prod")
description = "로컬 환경용 JAR 파일을 빌드합니다 ('local' 프로파일 적용)."
archiveClassifier.set("local") // 파일 이름에 local 접미사 추가 (e.g., app-local.jar)
// --- 필수 설정 복사 ---
// 1. Main 클래스 설정 복사
mainClass.set(bootJar.mainClass)
// 2. Classpath 설정 복사
classpath = bootJar.classpath
// 3. Target Java Version 설정 복사 (이번 오류 해결)
// 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다.
mainClass.set(tasks.bootJar.get().mainClass)
classpath = tasks.bootJar.get().classpath
targetJavaVersion.set(bootJar.targetJavaVersion)
manifest {
attributes["Spring-Profiles-Active"] = "prod"
// 'resources' 폴더의 모든 파일을 복사하되...
from("src/main/resources") {
include("**/*")
// prod 설정 파일은 제외합니다.
exclude("application-prod.properties")
// local 설정 파일의 이름을 application.properties로 변경합니다.
rename("application-local.properties", "application.properties")
}
}
// "prod" 프로파일용 JAR를 빌드하는 작업
tasks.register<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJarProd") {
group = "build"
description = "운영 환경용 JAR 파일을 빌드합니다 ('prod' 프로파일 적용)."
archiveClassifier.set("prod") // 파일 이름에 prod 접미사 추가 (e.g., app-prod.jar)
// 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다.
mainClass.set(tasks.bootJar.get().mainClass)
classpath = tasks.bootJar.get().classpath
targetJavaVersion.set(bootJar.targetJavaVersion)
// 'resources' 폴더의 모든 파일을 복사하되...
from("src/main/resources") {
include("**/*")
// local 설정 파일은 제외합니다.
exclude("application-local.properties")
// prod 설정 파일의 이름을 application.properties로 변경합니다.
rename("application-prod.properties", "application.properties")
}
}
// 🚀 1. 명령어를 실행할 새로운 Exec 태스크 정의
tasks.register<Exec>("runCommandAfterProdJar") {
group = "build"
description = "prod JAR 빌드 후 실행할 명령어를 정의합니다."
// 이 태스크는 bootJarProd가 성공해야만 의미가 있으므로, 의존성을 명시해주는 것이 좋습니다.
dependsOn(tasks.named("bootJarProd"))
// 실행할 OS 명령어와 인자를 설정합니다.
// 예시 1: Docker 이미지 빌드
commandLine("docker", "buildx","buildx","--platform","linux/amd64", "-t", "lunaticbum/testjar:0.025", ".")
// 예시 2: 빌드된 JAR 파일을 특정 서버로 복사
// commandLine("scp", "build/libs/your-app-name-prod.jar", "user@server:/path/to/deploy")
// 예시 3: 간단한 셸 스크립트 실행
// commandLine("./deploy.sh")
// 필요하다면 작업 디렉토리를 설정할 수 있습니다.
// workingDir = rootDir
// doLast {
// println("prod JAR 빌드가 완료되었습니다. 추가 명령어를 실행합니다.")
// exec {
// commandLine("docker", "push", "lunaticbum/testjar:0.025")
// // commandLine("echo", "Hello from doLast!")
// }
// }
}
// 🚀 2. bootJarProd 태스크가 끝나면 위에서 정의한 태스크를 실행하도록 연결
//tasks.named("bootJarProd") {
// finalizedBy(tasks.named("runCommandAfterProdJar"))
//}
//
//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
//tasks.build {

View File

@ -1,124 +0,0 @@
//package kr.lunaticbum.back.lun.configs
//
//import io.netty.channel.ChannelOption
//import io.netty.handler.timeout.ReadTimeoutHandler
//import io.netty.handler.timeout.WriteTimeoutHandler
//import jakarta.servlet.ServletContext
//import jakarta.servlet.ServletException
//import kr.lunaticbum.back.lun.utils.LogService
//import lombok.RequiredArgsConstructor
//import org.springframework.beans.factory.annotation.Autowired
//import org.springframework.beans.factory.annotation.Qualifier
//import org.springframework.context.annotation.Bean
//import org.springframework.http.client.reactive.ReactorClientHttpConnector
//import org.springframework.web.WebApplicationInitializer
//import org.springframework.web.context.ContextLoaderListener
//import org.springframework.web.context.support.AnnotationConfigWebApplicationContext
//import org.springframework.web.reactive.function.client.ClientRequest
//import org.springframework.web.reactive.function.client.ClientResponse
//import org.springframework.web.reactive.function.client.ExchangeFilterFunction
//import org.springframework.web.reactive.function.client.WebClient
//import org.springframework.web.servlet.DispatcherServlet
//import org.springframework.web.servlet.HandlerInterceptor
//import org.springframework.web.servlet.config.annotation.InterceptorRegistry
//import org.springframework.web.util.DefaultUriBuilderFactory
//import reactor.core.publisher.Mono
//import reactor.netty.Connection
//import reactor.netty.http.client.HttpClient
//import java.time.Duration
//import java.util.concurrent.TimeUnit
//import java.util.function.Consumer
//
//
//@RequiredArgsConstructor
//class WebConfig : WebApplicationInitializer {
//
// lateinit var logService : LogService
//
// var factory: DefaultUriBuilderFactory = DefaultUriBuilderFactory()
//
// var httpClient: HttpClient = HttpClient.create()
// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) // 10초
//
//
// @Throws(ServletException::class)
// override fun onStartup(servletContext: ServletContext) {
// // Spring MVC 프로젝트 설정을 위해 작성하는 클래스의 객체를 생성한다.
// val servletAppContext = AnnotationConfigWebApplicationContext()
//// servletAppContext.register(ServletAppContext::class.java)
//
// // 요청 발생 시 요청을 처리하는 서블릿을 DispatcherServlet으로 설정해준다.
//// val dispatcherServlet = DispatcherServlet(servletAppContext)
//// val servlet = servletContext.addServlet("dispatcher", dispatcherServlet)
////
//// // 부가 설정
//// servlet.setLoadOnStartup(1)
//// servlet.addMapping("/")
//
// // Bean을 정의하는 클래스를 지정한다.
// val rootAppContext = AnnotationConfigWebApplicationContext()
// rootAppContext.register(RootAppContext::class.java)
//
// val listener = ContextLoaderListener(rootAppContext)
// servletContext.addListener(listener)
// }
//
//
//
//
// @Bean
// fun webClient(): WebClient {
// /**
// * 통신시 timeout 세팅
// * - connect, read, write 를 모두 5000ms
// */
//
// val httpClient = HttpClient.create()
// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
// .responseTimeout(Duration.ofMillis(5000))
// .doOnConnected { conn: Connection ->
// conn.addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
// .addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS))
// }
//
// val webClient = WebClient.builder()
// .baseUrl("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w")
// .clientConnector(ReactorClientHttpConnector(httpClient)) //생성한 HttpClient 연결
// //Request Header 로깅 필터
// .filter(
// ExchangeFilterFunction.ofRequestProcessor { clientRequest: ClientRequest ->
// logService.log(">>>>>>>>> REQUEST <<<<<<<<<<")
// logService.log("Request: ${clientRequest.method()} ${clientRequest.url()}")
// clientRequest.headers()
// .forEach { (name: String?, values: MutableList<String?>?) ->
// values.forEach(
// Consumer<String> { value: String? ->
// logService.log(
// "${name} : ${value}"
// )
// })
// }
// Mono.just<ClientRequest>(clientRequest)
// }
// ) //Response Header 로깅 필터
// .filter(
// ExchangeFilterFunction.ofResponseProcessor { clientResponse: ClientResponse ->
// logService.log(">>>>>>>>>> RESPONSE <<<<<<<<<<")
// clientResponse.headers().asHttpHeaders()
// .forEach { (name: String?, values: MutableList<String?>?) ->
// values.forEach(
// Consumer<String> { value: String? ->
// logService.log(
// "${name} ${value}"
// )
// })
// }
// Mono.just<ClientResponse>(clientResponse)
// }
// )
// .defaultHeader("Content-type", "application/x-www-form-urlencoded;charset=utf-8") //기본 헤더설정
// .build()
//
// return webClient
// }
//}

View File

@ -0,0 +1,117 @@
//import com.google.gson.Gson
//import okhttp3.*
//import okhttp3.MediaType.Companion.toMediaType
//import okhttp3.RequestBody.Companion.asRequestBody
//import okhttp3.RequestBody.Companion.toRequestBody
//import java.io.File
//import java.io.IOException
//
//// Gson 파싱을 위한 데이터 클래스
//data class LoginRequest(val userId: String, val userPw: String)
//data class LoginResponse(val token: String?)
//
///**
// * API 통합 테스트를 실행하는 메인 함수입니다.
// * IDE에서 직접 실행(▶)할 수 있습니다.
// */
//fun main() {
// val tester = ApiIntegrationTest()
// tester.runBookmarkTest()
//}
//
//class ApiIntegrationTest {
//
// private val client = OkHttpClient()
// private val gson = Gson()
// private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
//
// // --- 테스트 환경 설정 ---
// private val baseUrl = "http://localhost:443"
// private val testUserId = "lunaticbum"
// private val testUserPw = "VioPup*383"
// private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일
//
// /**
// * 로그인 API를 호출하여 JWT 토큰을 반환합니다.
// */
// private fun loginAndGetToken(): String? {
// println("1. 로그인을 시도합니다...")
//
// val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw)
// val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType)
//
// val request = Request.Builder()
// .url("$baseUrl/api/auth/login")
// .post(requestBody)
// .build()
//
// try {
// client.newCall(request).execute().use { response ->
// if (!response.isSuccessful) {
// println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}")
// return null
// }
// val responseBody = response.body?.string()
// val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java)
// println("✅ 로그인 성공!")
// return loginResponse.token
// }
// } catch (e: IOException) {
// println("❌ 로그인 중 오류 발생: ${e.message}")
// return null
// }
// }
//
// /**
// * 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다.
// */
// private fun saveBookmarkWithToken(token: String) {
// println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}")
//
// if (!imageToUpload.exists()) {
// println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.")
// return
// }
//
// // Multipart 요청 본문 생성
// val requestBody = MultipartBody.Builder()
// .setType(MultipartBody.FORM)
// .addFormDataPart(
// "bookmarkData",
// """{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}"""
// )
// .addFormDataPart(
// "imageFile",
// imageToUpload.name,
// imageToUpload.asRequestBody("image/jpeg".toMediaType())
// )
// .build()
//
// val request = Request.Builder()
// .url("$baseUrl/api/bookmarks/with-image")
// .header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가
// .post(requestBody)
// .build()
//
// try {
// client.newCall(request).execute().use { response ->
// println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}")
// println("응답 내용: ${response.body?.string()}")
// }
// } catch (e: IOException) {
// println("❌ 북마크 저장 중 오류 발생: ${e.message}")
// }
// }
//
// /**
// * 전체 테스트 시나리오를 실행합니다.
// */
// fun runBookmarkTest() {
// val token = loginAndGetToken()
// if (token != null) {
// saveBookmarkWithToken(token)
// } else {
// println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
// }
// }
//}

View File

@ -1,6 +1,8 @@
package kr.lunaticbum.back.lun.configs
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.MalformedJwtException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.model.MongoPersistentTokenRepository
@ -9,6 +11,7 @@ import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
@ -31,12 +34,21 @@ import org.springframework.web.ErrorResponse
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import jakarta.servlet.FilterChain
import kr.lunaticbum.back.lun.utils.JwtUtil
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter
import java.security.SignatureException
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가
class SecurityConfig(
private val jwtUtil: JwtUtil,
private val userManager: UserManager,
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
private val tokenRepository: MongoPersistentTokenRepository
@ -48,7 +60,7 @@ class SecurityConfig(
fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers("/api/images/**", "/images/**")
web.ignoring().requestMatchers( "/images/**")
}
}
@ -74,15 +86,53 @@ class SecurityConfig(
return source
}
@Bean
@Order(1) // API 보안 설정을 먼저 적용
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨
.csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
.anyRequest().authenticated() // 나머지 API는 인증 필요
}
.exceptionHandling { handling ->
handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
}
// 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가
.addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
fun jwtAuthenticationEntryPoint(): AuthenticationEntryPoint {
return AuthenticationEntryPoint { request, response, authException ->
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_JSON_VALUE
val body = mapOf(
"status" to HttpServletResponse.SC_UNAUTHORIZED,
"error" to "Unauthorized",
"message" to (authException.message ?: "JWT Authentication Failed"),
"path" to request.servletPath
)
ObjectMapper().writeValue(response.outputStream, body)
}
}
@Bean
@Order(2) // 웹 페이지 보안 설정
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
http.cors { }
.csrf { csrf ->
csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
"/api/ranks/submit", // 통합 랭킹 API
"/puzzle/**", // <-- 이 줄을 추가하세요.
"/api/ranks/submit",
"/bums/save/loc.api",
"/puzzle/**",
)
}.authorizeHttpRequests { auth ->
auth
@ -93,6 +143,7 @@ class SecurityConfig(
// 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET,
"/api/images/**",
"/", "/home.bs", "/bums/where.bs",
"/user/login.bs", "/user/join.bs",
"/blog/viewer/**", "/blog/posts",
@ -100,7 +151,9 @@ class SecurityConfig(
"/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx",
"/blog/categories.bjx", "/blog/hashtags.bjx",
"/puzzle/**", "/api/ranks/list", "/licenses",
"/puzzle/images/**"
"/puzzle/images/**",
"/bums/face.bs", // [추가] 사이트 소개 페이지
"/bookmarks/**", // [추가] 북마크 목록 페이지
).permitAll()
// 3. 공개 POST API = permitAll
@ -108,10 +161,12 @@ class SecurityConfig(
"/user/login.bjx", "/user/joinUser.bjx",
"/api/ranks/submit",
"/bums/save/loc.api",
"/puzzle/**", // <-- 이 줄을 추가하세요.
// [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용
"/puzzle/**",
"/tlg/repotToMe.bjx",
"/blog/post/*/like.bjx",
"/blog/post/*/unlike.bjx"
"/blog/post/*/unlike.bjx",
"/bookmarks/*/like", // [추가] 북마크 좋아요
"/bookmarks/*/unlike" // [추가] 북마크 싫어요
).permitAll()
// 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청
@ -124,15 +179,16 @@ class SecurityConfig(
// 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능)
.requestMatchers(
"/user/approve-writer/**", "/user/reject-writer/**",
"/blog/post/*/block", "/blog/post/*/unblock"
"/blog/post/*/block", "/blog/post/*/unblock",
"/api/images/*/approve-banner",
"/api/images/*/revoke-banner"
).hasRole("ADMIN")
// 6. 나머지 모든 요청 = authenticated (인증 필요)
.anyRequest().authenticated()
}.formLogin { form ->
form.loginPage("/home.bs?action=login")
.loginProcessingUrl("/user/login.bs") // 로그인 처리 URL 명시 (선택사항)
.loginProcessingUrl("/user/login.bs")
.defaultSuccessUrl("/", true)
.permitAll()
}.rememberMe { rememberMe ->
@ -143,6 +199,10 @@ class SecurityConfig(
.userDetailsService(userManager)
}.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}.exceptionHandling { handling ->
handling
.authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시
.accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시
}
return http.build()
}
@ -187,4 +247,52 @@ class SecurityConfig(
fun bCryptPasswordEncoder(): BCryptPasswordEncoder {
return BCryptPasswordEncoder()
}
}
class JwtAuthenticationFilter(
private val jwtUtil: JwtUtil,
private val userManager: UserManager
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
try {
val jwt = authHeader.substring(7)
val username = jwtUtil.extractUsername(jwt)
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails = this.userManager.loadUserByUsername(username)
if (jwtUtil.isTokenValid(jwt, userDetails)) {
println("jwtUtil.isTokenValid($jwt, $userDetails)")
val authToken = UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities
)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
println("authToken.details >>> ${authToken.details}")
SecurityContextHolder.getContext().authentication = authToken
}
}
} catch (e: ExpiredJwtException) {
println("JWT Error: Token has expired - ${e.message}")
} catch (e: SignatureException) {
println("JWT Error: Signature validation failed - ${e.message}")
} catch (e: MalformedJwtException) {
println("JWT Error: Malformed token - ${e.message}")
} catch (e: Exception) {
println("JWT Error: Could not set user authentication in security context - ${e.message}")
}
filterChain.doFilter(request, response)
println("JWT Token validated")
}
}

View File

@ -0,0 +1,30 @@
package kr.lunaticbum.back.lun.configs
import io.netty.channel.ChannelOption
import io.netty.handler.timeout.ReadTimeoutHandler
import io.netty.handler.timeout.WriteTimeoutHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.util.concurrent.TimeUnit
@Configuration
class WebClientConfig {
@Bean
fun webClient(): WebClient {
// Netty HttpClient에 타임아웃 설정
val httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃 5초
.doOnConnected { conn ->
conn.addHandlerLast(ReadTimeoutHandler(5, TimeUnit.SECONDS)) // 읽기 타임아웃 5초
.addHandlerLast(WriteTimeoutHandler(5, TimeUnit.SECONDS)) // 쓰기 타임아웃 5초
}
return WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.build()
}
}

View File

@ -1,21 +1,29 @@
package kr.lunaticbum.back.lun.controllers
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.gson.Gson
import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText
import net.coobird.thumbnailator.Thumbnails
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.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
@ -28,6 +36,7 @@ import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.io.File
import java.io.IOException
import java.net.URLDecoder
@ -39,6 +48,7 @@ import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.*
import javax.imageio.ImageIO
import kotlin.io.path.exists
// --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 ---
@ -125,7 +135,7 @@ class BlogController(
// 썸네일 생성 및 경로 설정
generateThumbnail(filename, 200) // 너비 200px 썸네일 생성
val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".")
post.thumb = "/api/images/$thumbFilename"
post.thumb = "/api/images/$thumbFilename?type=thumbnail"
} else {
// 게시물에 이미지가 없는 경우, 기본 썸네일을 지정합니다.
post.image = null
@ -145,42 +155,94 @@ class BlogController(
*/
@GetMapping("/api/images/{filename:.+}")
@ResponseBody
fun getImage(@PathVariable filename: String): ResponseEntity<ByteArray> {
fun getImage(
@PathVariable filename: String,
@RequestParam(required = false) type: String? // "thumbnail" 같은 타입 요청
): ResponseEntity<ByteArray> {
if (uploadPath.isNullOrBlank()) {
return ResponseEntity.notFound().build()
}
try {
val imagePath: Path = Paths.get(uploadPath, filename)
// 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인 (Path Traversal 공격 방지)
if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) {
return ResponseEntity.badRequest().build()
logService.log("req $filename ")
// 1. 요청 타입이 없으면 원본 이미지를 반환
if (type.isNullOrBlank()) {
val originalPath = Paths.get(uploadPath, filename)
return serveImage(originalPath, filename)
}
if (Files.exists(imagePath) && Files.isReadable(imagePath)) {
val imageBytes = Files.readAllBytes(imagePath)
// 파일 확장자를 기반으로 적절한 Content-Type 헤더 설정
val contentType = when (filename.substringAfterLast('.').lowercase()) {
"jpg", "jpeg" -> MediaType.IMAGE_JPEG
"png" -> MediaType.IMAGE_PNG
"gif" -> MediaType.IMAGE_GIF
else -> MediaType.APPLICATION_OCTET_STREAM
// 2. 요청 타입에 따라 캐시 파일 이름과 목표 너비 설정
val (targetWidth, resizedFilename) = when (type) {
"thumbnail" -> {
val baseName = filename.substringBeforeLast(".")
val extension = filename.substringAfterLast(".")
Pair(400, "${baseName}_thumbnail.${extension}") // 썸네일 너비: 400px
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"")
.contentType(contentType)
.body(imageBytes)
"banner" -> {
val baseName = filename.substringBeforeLast(".")
val extension = filename.substringAfterLast(".")
Pair(1200, "${baseName}_banner.${extension}") // 썸네일 너비: 400px
}
// 필요하다면 다른 타입 추가 (예: "medium" -> 800)
else -> Pair(null, null)
}
if (targetWidth == null || resizedFilename == null) {
val originalPath = Paths.get(uploadPath, filename)
return serveImage(originalPath, filename) // 지원하지 않는 타입이면 원본 반환
}
// 3. 캐시 파일 경로 확인
val resizedPath = Paths.get(uploadPath, resizedFilename)
// 4. 캐시 파일이 이미 존재하면 바로 반환
if (Files.exists(resizedPath)) {
return serveImage(resizedPath, resizedFilename)
}
// 5. 캐시 파일이 없으면 원본을 찾아 리사이즈 후 저장 (캐싱)
val originalPath = Paths.get(uploadPath, filename)
if (!Files.exists(originalPath)) {
return ResponseEntity.notFound().build()
}
Thumbnails.of(originalPath.toFile())
.width(targetWidth)
.keepAspectRatio(true)
.outputQuality(0.85)
.toFile(resizedPath.toFile())
// 6. 새로 생성된 캐시 파일을 반환
return serveImage(resizedPath, resizedFilename)
} catch (e: IOException) {
logService.log("Error reading image file: $filename, Error: ${e.message}")
// 파일을 읽는 중 오류가 발생하면 500 서버 에러를 반환할 수 있습니다.
return ResponseEntity.internalServerError().build()
}
}
// 파일이 존재하지 않으면 404 Not Found 응답
return ResponseEntity.notFound().build()
// 이미지 파일을 읽어 ResponseEntity로 만드는 헬퍼 함수
private fun serveImage(imagePath: Path, filename: String): ResponseEntity<ByteArray> {
if (!Files.exists(imagePath) || !Files.isReadable(imagePath)) {
return ResponseEntity.notFound().build()
}
// 보안: 요청된 파일이 실제 업로드 경로 내에 있는지 확인
if (!imagePath.normalize().startsWith(Paths.get(uploadPath).normalize())) {
return ResponseEntity.badRequest().build()
}
val imageBytes = Files.readAllBytes(imagePath)
val contentType = when (filename.substringAfterLast('.').lowercase()) {
"jpg", "jpeg" -> MediaType.IMAGE_JPEG
"png" -> MediaType.IMAGE_PNG
"gif" -> MediaType.IMAGE_GIF
else -> MediaType.APPLICATION_OCTET_STREAM
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"")
.contentType(contentType)
.body(imageBytes)
}
/**
@ -240,7 +302,7 @@ class BlogController(
@ResponseBody
suspend fun home(): ResultMV {
val vm = ResultMV("content/home")
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg"
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner"
try {
var bannerImagePath: String? = null
@ -249,9 +311,9 @@ class BlogController(
if (randomImage != null && !randomImage.path.isNullOrBlank()) {
// 1. 이미지 경로가 예전 방식인지 확인하고 수정합니다.
if (randomImage.path.contains("/blog/post/images/")) {
bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/")
bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") +"?type=banner"
} else {
bannerImagePath = randomImage.path
bannerImagePath = randomImage.path +"?type=banner"
}
}
@ -371,6 +433,7 @@ class BlogController(
@GetMapping(value = ["/blog/edit", "/blog/edit/{postId}"])
suspend fun editPost(
@PathVariable(required = false) postId: String?,
@RequestParam(required = false) type: String?, // [추가] 'type' 파라미터 받기
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
if (userDetails == null) {
@ -394,6 +457,10 @@ class BlogController(
// 사용자의 의도대로 기본 제목을 설정합니다.
title = "무제(無題) (${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(java.util.Date())})"
content = "" // 내용은 비워둡니다.
if (type == PostType.ABOUT_SITE.name) {
this.postType = PostType.ABOUT_SITE.name
vm.modelMap["pageTitle"] = "사이트 소개글 작성"
}
}
vm.modelMap["srcPost"] = newPost
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(newPost)
@ -551,7 +618,8 @@ class BlogController(
readCount = originalPost.readCount,
voteCount = originalPost.voteCount,
unlikeCount = originalPost.unlikeCount,
modifyTime = System.currentTimeMillis()
modifyTime = System.currentTimeMillis(),
postType = originalPost.postType // [추가] 기존 postType을 새 버전에 복사
)
postManager.save(newVersion).map { savedPost ->
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
@ -669,4 +737,381 @@ class BlogController(
@GetMapping("/licenses")
fun licenses() = ResultMV("content/licenses")
}
}
@RestController
@RequestMapping("/bums")
class BumsPrivate {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var postManager : PostManager
@Autowired
lateinit var locationService: LocationLogService
@GetMapping("face.bs")
suspend fun aboutMePage(): ResultMV {
val vm = ResultMV("content/about_view") // 소개글 전용 뷰 템플릿 사용
// 'ABOUT_SITE' 타입의 가장 최신 글을 찾아 모델에 추가
val aboutPost = postManager.findLatestAboutPost().awaitSingleOrNull()
if (aboutPost != null) {
vm.modelMap["srcPost"] = aboutPost
vm.modelMap["srcPostJson"] = com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(aboutPost)
vm.setTitle("BUM'sPace 소개")
} else {
// 글이 없을 경우를 대비한 처리
vm.modelMap["srcPost"] = Post(title = "소개글이 아직 작성되지 않았습니다.", content = "")
vm.modelMap["srcPostJson"] = "{}"
vm.setTitle("소개글 없음")
}
return vm
}
@GetMapping("where.bs")
fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기
val m = ResultMV("content/private/where")
// (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순)
// 예시: 날짜 필드명이 "createdAt"일 경우
val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending())
// (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출)
// 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다.
val locationPage: Page<LocationLog> = locationService.findAll(pageable)
// (4) 모델에 Page 객체 전체를 전달
m.modelMap.put("locationPage", locationPage)
m.setTitle("돼지 여기있다요~!!")
return m
}
@ResponseBody
@PostMapping("save/loc.api")
fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log("${httpServletRequest.requestURI}")
logService.log(jsonString)
var location : LocationLog? = null
jsonString.plainText().let {
Gson().fromJson<LocationLog>(it, LocationLog::class.java)?.let { model ->
location = model
logService.log(model.toString())
locationService.save(model)
}
}
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
})
// CoroutineScope(Dispatchers.IO).launch {
// location?.let {
// val client = WebClient.create()
// client.get()
// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
return responce
}
}
@RestController
@RequestMapping("/api/og")
class OpenGraphController(private val logService: LogService) {
// Jsoup 라이브러리를 사용하기 위해 의존성 추가가 필요할 수 있습니다.
// build.gradle.kts 파일에 implementation("org.jsoup:jsoup:1.15.3") 추가
/**
* 전달받은 URL의 Open Graph 메타 태그 정보를 파싱하여 반환합니다.
*/
@GetMapping("/parse")
fun fetchOpenGraphData(@RequestParam url: String): Mono<ResponseEntity<Map<String, String>>> {
return Mono.fromCallable {
try {
// Jsoup으로 URL에 접속하여 HTML 문서를 가져옴
val doc = Jsoup.connect(url).get()
// "og:title", "og:description", "og:image" 메타 태그를 찾음
val title = doc.select("meta[property=og:title]").attr("content")
val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content")
// 찾은 정보를 Map에 담아 성공 응답(200 OK)으로 반환
val data = mapOf(
"title" to (title.ifEmpty { doc.title() }), // og:title 없으면 그냥 title 태그 사용
"description" to description,
"thumbnailUrl" to imageUrl
)
ResponseEntity.ok(data)
} catch (e: java.net.SocketTimeoutException) {
// 타임아웃 예외를 명시적으로 처리
logService.log("OG data parsing timed out for URL: $url")
ResponseEntity.status(408).body(mapOf("error" to "요청 시간이 초과되었습니다."))
} catch (e: Exception) {
logService.log("OG data parsing failed for URL: $url, Error: ${e.message}")
// 파싱 실패 시 에러 응답
ResponseEntity.badRequest().body(mapOf("error" to "URL 정보를 가져올 수 없습니다."))
}
}.subscribeOn(Schedulers.boundedElastic()) // I/O 작업을 별도 스레드에서 처리
}
}
@Controller
@RequestMapping("/bookmarks")
class BookmarkController(private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val commentService: CommentService, // [신규 추가] CommentService 주입
private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입
) {
@GetMapping
suspend fun bookmarkListPage(
@RequestParam(value = "page", defaultValue = "0") page: Int,
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
val vm = ResultMV("content/bookmarks") // 북마크 전용 뷰 템플릿
val pageable = PageRequest.of(page, 9) // 한 페이지에 9개씩 (3x3 그리드)
// 서비스 레이어를 호출하여 현재 사용자 권한에 맞는 북마크 목록을 가져옴
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
vm.modelMap["bookmarksPage"] = bookmarksPage
vm.setTitle("저장된 페이지 목록")
return vm
}
@PostMapping("/{bookmarkId}/like")
@ResponseBody
fun likeBookmark(@PathVariable bookmarkId: String): Mono<VoteResponse> {
return bookmarkService.incrementVote(bookmarkId).map {
VoteResponse(it.voteCount, it.unlikeCount)
}
}
@PostMapping("/{bookmarkId}/unlike")
@ResponseBody
fun unlikeBookmark(@PathVariable bookmarkId: String): Mono<VoteResponse> {
return bookmarkService.incrementUnlike(bookmarkId).map {
VoteResponse(it.voteCount, it.unlikeCount)
}
}
@GetMapping("/{bookmarkId}/comments")
@ResponseBody
fun getComments(@PathVariable bookmarkId: String): Mono<CommentResponse> {
// 기존 CommentService의 메소드를 그대로 호출.
// 서비스는 ID가 post의 것인지 bookmark의 것인지 구분할 필요 없음.
return commentService.getCommentsForPost(bookmarkId)
.collectList()
.map { comments -> CommentResponse(0, "Success", comments) }
}
@PostMapping("/{bookmarkId}/comments")
@ResponseBody
fun addComment(
@PathVariable bookmarkId: String,
@RequestBody rawPayload: String,
@AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> {
val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper)
// **핵심**: Comment 객체의 postId 필드에 bookmarkId를 설정
comment.postId = bookmarkId
comment.writer = user?.username ?: "Anonymous"
comment.writeTime = System.currentTimeMillis()
// 기존 CommentService를 사용하여 댓글 저장
return commentService.addComment(comment)
.map { CommentResponse(0, "Success") }
}
@Value("\${image.upload.path}")
private val uploadPath: String? = null
/**
* 북마크 목록을 페이지네이션으로 조회하는 API
* (: GET /api/bookmarks?page=0&size=10)
*/
@GetMapping("/list")
suspend fun getBookmarkList(
@AuthenticationPrincipal userDetails: UserDetails?,
pageable: Pageable
): ResponseEntity<Page<WebBookmark>> {
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
return ResponseEntity.ok(bookmarksPage)
}
/**
* 북마크를 저장하는 API
*/
@PostMapping("/save")
fun saveBookmark(
@RequestBody request: Map<String, String>,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null) {
// 이 요청은 인증이 필요하므로 user가 null일 수 없음 (SecurityConfig에서 보장)
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build())
val newBookmark = WebBookmark(
userId = user.username,
url = url,
userComment = request["userComment"],
visibility = request["visibility"] ?: "PRIVATE",
metadataStatus = "PENDING"
)
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
}
data class BookmarkDataDto(
val url: String,
val userComment: String?,
val visibility: String?
)
@PostMapping("api/with-image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun saveBookmarkWithImage(
@RequestPart("imageFile") imageFile: MultipartFile,
@RequestPart("bookmarkData") bookmarkDataJson: String, // 북마크 데이터는 JSON 문자열로 받음
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null || uploadPath.isNullOrBlank()) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
// 1. 이미지 파일 저장
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
Files.createDirectories(targetPath.parent)
imageFile.transferTo(targetPath.toFile())
} catch (e: Exception) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
}
// 2. 북마크 데이터 (JSON 문자열)를 DTO 객체로 변환
val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson)
// 3. WebBookmark 객체 생성
val newBookmark = WebBookmark(
userId = user.username,
url = bookmarkData.url,
userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "PENDING",
// 저장된 이미지의 서버 URL을 저장
userSelectedImageUrl = "/api/images/$uniqueFilename"
)
// 4. 북마크 정보 DB에 저장
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
}
}
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkApiController(
private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val commentService: CommentService, // [신규 추가] CommentService 주입
private val objectMapper: ObjectMapper, // [신규 추가] JSON 처리를 위해 주입
private val logService: LogService,
) {
@Value("\${image.upload.path}")
private val uploadPath: String? = null
/**
* 북마크 목록을 페이지네이션으로 조회하는 API
* (: GET /api/bookmarks?page=0&size=10)
*/
@GetMapping("/list")
suspend fun getBookmarkList(
@AuthenticationPrincipal userDetails: UserDetails?,
pageable: Pageable
): ResponseEntity<Page<WebBookmark>> {
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
return ResponseEntity.ok(bookmarksPage)
}
data class BookmarkDataDto(
val url: String,
val userComment: String?,
val visibility: String?
)
@PostMapping("/with-image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun saveBookmarkWithImage(
@RequestPart("imageFile") imageFile: MultipartFile,
@RequestPart("bookmarkData") bookmarkDataJson: String, // 북마크 데이터는 JSON 문자열로 받음
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
logService.log("uploadPath >>> ${uploadPath}")
if (user == null || uploadPath.isNullOrBlank()) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
// Gson과 같은 JSON 라이브러리를 사용해 문자열을 DTO 객체로 변환할 수 있습니다.
// val gson = Gson()
// val bookmarkData = gson.fromJson(bookmarkDataJson, BookmarkData::class.java)
println("${user.username} 사용자가 엔드포인트를 호출했습니다.")
println("전달받은 URL: ${/*bookmarkData.url*/ bookmarkDataJson}") // 예시 출력
println("전달받은 이미지: ${imageFile.originalFilename} (크기: ${imageFile.size} 바이트)")
// 1. 이미지 파일 저장
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
imageFile.transferTo(targetPath.toFile())
} catch (e: Exception) {
e.printStackTrace()
println("IMAGE TRAN FAIL")
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
}
// 2. 북마크 데이터 (JSON 문자열)를 DTO 객체로 변환
val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson)
// 3. WebBookmark 객체 생성
val newBookmark = WebBookmark(
userId = user.username,
url = bookmarkData.url,
userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "PENDING",
// 저장된 이미지의 서버 URL을 저장
userSelectedImageUrl = "/api/images/$uniqueFilename"
)
println("newBookmark ${newBookmark}")
// 4. 북마크 정보 DB에 저장
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }.apply {
println("OK")
}
}
}

View File

@ -1,80 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import com.google.gson.Gson
import jakarta.servlet.http.HttpServletRequest
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.Page
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
// Spring Data Paging을 위한 Import 추가
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import reactor.core.publisher.Flux
@RestController
@RequestMapping("/bums")
class BumsPrivate {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var locationService: LocationLogService
@GetMapping("where.bs")
fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기
val m = ResultMV("content/private/where")
// (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순)
// 예시: 날짜 필드명이 "createdAt"일 경우
val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending())
// (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출)
// 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다.
val locationPage: Page<LocationLog> = locationService.findAll(pageable)
// (4) 모델에 Page 객체 전체를 전달
m.modelMap.put("locationPage", locationPage)
m.setTitle("돼지 여기있다요~!!")
return m
}
@ResponseBody
@PostMapping("save/loc.api")
fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log("${httpServletRequest.requestURI}")
logService.log(jsonString)
var location : LocationLog? = null
jsonString.plainText().let {
Gson().fromJson<LocationLog>(it, LocationLog::class.java)?.let { model ->
location = model
logService.log(model.toString())
locationService.save(model)
}
}
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
})
// CoroutineScope(Dispatchers.IO).launch {
// location?.let {
// val client = WebClient.create()
// client.get()
// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
return responce
}
}

View File

@ -1,48 +0,0 @@
package kr.lunaticbum.back.lun.controllers
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
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
class GameRankController(private val gameRankService: GameRankService) {
/**
* [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가)
*/
@PostMapping("/submit")
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<Any>> {
return gameRankService.submitRank(rankDto)
.map { savedRank -> ResponseEntity.ok<Any>(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("랭킹 등록 중 서버 오류가 발생했습니다."))
}
}
/**
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
* : /api/ranks/list?gameType=SUDOKU&contextId=123
* : /api/ranks/list?gameType=GAME_2048
*/
@GetMapping("/list")
fun getUnifiedRanks(
@RequestParam gameType: GameType,
@RequestParam contextId: String? = null
): Flux<GameRank> {
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
val effectiveContextId = if (contextId == "null") null else contextId
return gameRankService.getRanks(gameType, effectiveContextId)
}
}

View File

@ -0,0 +1,72 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.MessageService
import kr.lunaticbum.back.lun.model.ResultMV
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono
// 쪽지 전송 시 Body 데이터를 받기 위한 DTO
data class MessageRequest(val receiverId: String, val title: String, val content: String)
@RestController
@RequestMapping("/messages")
@PreAuthorize("isAuthenticated()") // 모든 메시지 기능은 로그인한 사용자만 가능
class MessageController(private val messageService: MessageService) {
/**
* 읽은 쪽지 개수를 확인하는 API (헤더 아이콘 표시용)
*/
@GetMapping("/unread-count")
fun getUnreadCount(@AuthenticationPrincipal userDetails: UserDetails): Mono<Map<String, Long>> {
return messageService.getUnreadMessageCount(userDetails.username)
.map { count -> mapOf("count" to count) }
}
/**
* 쪽지함 페이지를 보여주는 핸들러
*/
@GetMapping
fun getInboxPage(@AuthenticationPrincipal userDetails: UserDetails): Mono<ResultMV> {
val vm = ResultMV("content/messages/inbox")
return messageService.getMessagesForUser(userDetails.username)
.collectList()
.map { messages ->
vm.modelMap["messages"] = messages
vm.modelMap["pageTitle"] = "내 쪽지함"
vm
}
}
/**
* 쪽지를 보내는 API
*/
@PostMapping("/send")
fun sendMessage(
@AuthenticationPrincipal userDetails: UserDetails,
@RequestBody request: MessageRequest
): Mono<ResponseEntity<String>> {
return messageService.sendMessage(
userDetails.username,
request.receiverId,
request.title,
request.content
).map { ResponseEntity.ok("메시지를 보냈습니다.") }
}
/**
* 특정 쪽지를 읽음 처리하는 API
*/
@PostMapping("/{messageId}/read")
fun markAsRead(
@PathVariable messageId: String,
@AuthenticationPrincipal userDetails: UserDetails
): Mono<ResponseEntity<Void>> {
return messageService.markMessageAsRead(messageId, userDetails.username)
.map { ResponseEntity.ok().build<Void>() }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
}

View File

@ -4,11 +4,14 @@ 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.HttpStatus
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 reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.nio.file.Paths
/**
@ -233,4 +236,42 @@ class PuzzleController(
val vm = ResultMV("content/puzzle/upload")
return vm
}
}
@RestController
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
class GameRankController(private val gameRankService: GameRankService) {
/**
* [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가)
*/
@PostMapping("/submit")
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<Any>> {
return gameRankService.submitRank(rankDto)
.map { savedRank -> ResponseEntity.ok<Any>(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("랭킹 등록 중 서버 오류가 발생했습니다."))
}
}
/**
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
* : /api/ranks/list?gameType=SUDOKU&contextId=123
* : /api/ranks/list?gameType=GAME_2048
*/
@GetMapping("/list")
fun getUnifiedRanks(
@RequestParam gameType: GameType,
@RequestParam contextId: String? = null
): Flux<GameRank> {
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
val effectiveContextId = if (contextId == "null") null else contextId
return gameRankService.getRanks(gameType, effectiveContextId)
}
}

View File

@ -242,78 +242,7 @@ class Telegram {
}
}
} else if(msg.text?.startsWith("/") == true) {
// msg.text?.split(" ")?.let { cmds ->
// cmds[0].let { cmd ->
// when(cmd.trim()) {
// "/reqGapiKeys" -> {
// sendSimpleMsg(globalEvv.telegramBotKey!!,globalEvv.telegramMyId!!,"${msg.from!!.id.toString()}님이 서비스 키를 요첨항./setGaipKeys {key}")
// }
// "/setGaipKeys" -> {
// var pref = Preferences.userNodeForPackage(Telegram::class.java)
// pref.put("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()), cmds[1])
// pref.sync()
// println("test prefKey ${"GAPI_KEY".plus("_").plus(msg.from!!.id.toString())}")
// println("test prefKey ${cmds[1]}")
// println("test prefKey ${pref.get("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()),"")}")
//
// }
// "/get" ->{}
// "/jf" ->{
//// CoroutineScope(Dispatchers.IO).launch {
//// logService.log("${cmd} Start ${cmds[1]}")
//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9qYXZtb3N0LnRvL3NlYXJjaC9tb3ZpZS8lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService) }
//// logService.log("${cmd} END ${cmds[1]}")
//// }
//// CoroutineScope(Dispatchers.IO).launch {
//// logService.log("on Cmd JF with SO")
//// logService.log("${cmd} Start ${cmds[1]}")
//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9rcjcwLnNvZ2lybC5zby8/cz0lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService)}
//// logService.log("${cmd} END ${cmds[1]}")
//// }
// }
// "/lama" -> {
// val req = BumlamaReq(msg.text!!.replace(cmd,""))
// CoroutineScope(Dispatchers.IO).launch {
//
// val fullUrl =
// "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=lama 에게 전송 ${req.reqMsg}"
// logService.log("fullUrl >>> ${fullUrl}")
// WebClient.create().get()
// .uri(fullUrl)
// .retrieve()
// .bodyToMono(String::class.java).block()
// }
// CoroutineScope(Dispatchers.IO).launch {
// logService.log("${cmd} Start ${cmds[1]}")
//// msg.chat?.id
// try {
// val client = WebClient.create()
// client.post()
// .uri(lamaGenerated)
// .body(BodyInserters.fromValue(Gson().toJson(req)))
// .retrieve()
// .bodyToMono(String::class.java).timeout(Duration.ofSeconds(6000L)).block()?.let { result ->
// Gson().fromJson(result, BumlamaResp::class.java)?.let { sss ->
// println(Gson().toJson(sss))
// val fullUrl = "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${sss.response}"
// logService.log("fullUrl >>> ${fullUrl}")
// WebClient.create().get()
// .uri(fullUrl)
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
// } catch (e: Exception) {
// e.printStackTrace()
// }
//
// logService.log("${cmd[0]} END ${cmd[1]}")
// }
// }
// else -> {}
// }
// }
// }
} else if (msg.text?.contains("어디") == true) {
msg.from?.id?.let { sendMsg(it.toString()) }
} else {
@ -341,18 +270,6 @@ class Telegram {
return "Success"
}
// fun chatClient(): ChatClient {
// return OllamaChatClient(OllamaApi("https://lama.lunaticbum.kr"))
// .withDefaultOptions(
// OllamaOptions.create()
// .withModel("phi4:14b")
// .withNumThread(5)
// .withSeed(5)
// .withTemperature(0.9f)
// )
// }
@Autowired
lateinit var lama : Lama
@ -361,32 +278,6 @@ class Telegram {
@GetMapping("query/{path}")
fun googleQueryTest(@PathVariable path: String): String {
var originalQuery = path
// POST /collections
//
// Content-Type: application/json
//
// {
// "name": "movies",
// "vector_size": 3072,
// "distance": "Cosine"
// }
// println(lama.makeCollection())
// val gSearch = "https://psn.lunaticbum.kr/search?q=${originalQuery?.replace("오늘", SimpleDateFormat("yyyMMdd").format(Date()))}&language=auto&time_range=month&safesearch=0&categories=general&format=json"
// println("gSearch >>> ${gSearch}")
// var additionalInfo = StringBuffer()
// additionalInfo.append("참고자료")
// var idx = 0
// WebClient.create().get()
// .uri(gSearch)
// .retrieve()
// .bodyToMono(SearXng::class.java).timeout(Duration.ofMinutes(20L)).block()?.let { gsResult ->
// gsResult.results?.filter { it.score > 0.5}?.forEach {
// additionalInfo.append(idx).append(":").append(Gson().toJson(it))
// idx += 1
// }
// }
CoroutineScope(Dispatchers.IO).async {
lama.generateResponse(originalQuery.replace("오늘","오늘(${SimpleDateFormat("yyyy-MM-dd").format(Date())})"))
}

View File

@ -37,7 +37,8 @@ import java.time.format.DateTimeFormatter
import java.util.*
import javax.naming.AuthenticationException
import kotlin.collections.emptyList
import kr.lunaticbum.back.lun.model.Message
import reactor.core.publisher.Flux
@RestController
@RequestMapping("/user")
@ -47,8 +48,10 @@ class UserController(
private val postManager: PostManager,
private val commentService: CommentService,
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
private val messageService: MessageService,
private val webBookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService
) {
@ -267,6 +270,14 @@ class UserController(
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myComments"] = myComments ?: emptyList()
// [신규] 받은 쪽지와 보낸 쪽지를 모두 조회하고, 시간순으로 합쳐서 모델에 추가합니다.
val receivedMessages : List<Message> = (messageService.getMessagesForUser(username).collectList().block() ?: emptyList()) as List<Message>
val sentMessages : List<Message> = (messageService.getSentMessagesByUser(username).collectList().block() ?: emptyList()) as List<Message>
// 두 리스트를 합친 후, 최신순으로 정렬합니다.
val allMessages = (receivedMessages + sentMessages).sortedByDescending { it.timestamp }
vm.modelMap["myMessages"] = allMessages
// 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개)
val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block()
vm.modelMap["myRanks"] = myRanks ?: emptyList()
@ -282,7 +293,8 @@ class UserController(
vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block()
vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회
vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block()
// [신규 추가] 사이트 소개글 히스토리를 모델에 추가
vm.modelMap["aboutPostHistory"] = postManager.findAboutPostHistory().collectList().block()
}
return vm
@ -292,9 +304,33 @@ class UserController(
@PostMapping("/request-write")
@ResponseBody
fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono<ResponseEntity<String>> {
if (userDetails == null) return Mono.just(ResponseEntity.status(401).build())
if (userDetails == null) {
return Mono.just(ResponseEntity.status(401).build())
}
return userManager.requestWritePermission(userDetails.username)
.map { ResponseEntity.ok("요청이 완료되었습니다.") }
.map { savedUser -> // DB에 저장된 유저 정보를 받음
// --- 텔레그램 알림 전송 로직 ---
try {
val message = "[권한 요청] 사용자 '${savedUser.user_id}'님이 글쓰기 권한을 요청했습니다."
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${message}")
.retrieve()
.bodyToMono(String::class.java)
.subscribe( // non-blocking (Fire-and-Forget) 방식으로 호출
{ response -> logService.log("Telegram notification sent successfully for user ${savedUser.user_id}. Response: $response") },
{ error -> logService.log("Error sending Telegram notification for user ${savedUser.user_id}: ${error.message}") }
)
} catch (e: Exception) {
// WebClient 생성 또는 설정 중 발생할 수 있는 동기적 예외 처리
logService.log("Exception while preparing Telegram notification for user ${savedUser.user_id}: ${e.message}")
}
// --- 알림 로직 끝 ---
// 기존과 동일하게 클라이언트에게 성공 응답을 반환
ResponseEntity.ok("요청이 완료되었습니다.")
}
.defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다."))
}
@ -319,4 +355,77 @@ class UserController(
.defaultIfEmpty(ResponseEntity.notFound().build())
}
/**
* 북마크 저장을 위해 클라이언트로부터 받는 데이터를 담는 DTO
*/
data class BookmarkSaveRequest(
val url: String,
val title: String?,
val description: String?,
val thumbnailUrl: String?,
val userComment: String?,
val visibility: String?
)
@PostMapping("/bookmarks/save")
@ResponseBody
fun saveBookmark(
// [수정] DTO 대신 URL과 코멘트만 간단히 받도록 변경
@RequestBody request: Map<String, String>,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null) {
return Mono.just(ResponseEntity.status(401).build())
}
val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build())
// [수정] URL과 사용자 정보, PENDING 상태만으로 북마크 객체를 생성하여 저장
val newBookmark = WebBookmark(
userId = user.username,
url = url,
userComment = request["userComment"],
visibility = request["visibility"] ?: Visibility.PRIVATE.name,
userSelectedImageUrl = request["userSelectedImageUrl"],
metadataStatus = MetadataStatus.PENDING.name // 초기 상태는 PENDING
)
// DB에 저장하고 즉시 사용자에게 성공 응답을 반환
return webBookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.ok(savedBookmark) }
}
}
// --- API 요청/응답을 위한 DTO ---
data class LoginRequest(val userId: String, val userPw: String)
data class LoginResponse(val token: String)
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authenticationManager: AuthenticationManager,
private val userManager: UserManager,
private val jwtUtil: JwtUtil,
private val logService: LogService
) {
@PostMapping("/login")
fun createAuthenticationToken(@RequestBody loginRequest: LoginRequest): ResponseEntity<*> {
// 1. 사용자 인증
authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(loginRequest.userId, loginRequest.userPw)
)
logService.log("loginRequest.userId >>> ${loginRequest.userId}")
// 2. 인증 성공 시 UserDetails 객체 로드
val userDetails = userManager.loadUserByUsername(loginRequest.userId)
logService.log("userDetails.username >>> ${userDetails.username}")
// 3. JWT 토큰 생성
val token = jwtUtil.generateToken(userDetails)
// 4. 토큰을 응답으로 반환
return ResponseEntity.ok(LoginResponse(token))
}
}

View File

@ -1,2 +0,0 @@
package kr.lunaticbum.back.lun.model

View File

@ -1,451 +1,28 @@
package kr.lunaticbum.back.lun.model
import com.google.gson.Gson
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
import org.bson.codecs.pojo.annotations.BsonIgnore
import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.text.SimpleDateFormat
import java.time.Duration
import java.util.*
import org.springframework.data.domain.PageImpl
import java.time.format.DateTimeFormatter
class BumsPrivate {
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "LocationLog")
class LocationLog {
var mFeatureName: String? = null
var mAddressLines: ArrayList<String> = arrayListOf()
var mAdminArea: String? = null
var mSubAdminArea: String? = null
var mLocality: String? = null
var mSubLocality: String? = null
var mThoroughfare: String? = null
var mSubThoroughfare: String? = null
var mPremises: String? = null
var mPostalCode: String? = null
var mCountryCode: String? = null
var mCountryName: String? = null
var mLatitude = 0.0
var mLongitude = 0.0
var mPhone: String? = null
var timeString : String? = null
var mUrl: String? = null
var time : Long = 0L
var userId : String? = null
var bettween : String? = null
val displayTime: String
get() {
// 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다.
if (!this.timeString.isNullOrBlank()) {
return this.timeString!!
}
// 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다.
if (this.time != null) {
// 원하는 날짜/시간 포맷 정의
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return formatter.format(Date(this.time))
}
// 3. 둘 다 없으면 "시간 없음"을 반환한다.
return "[시간 정보 없음]"
}
override fun toString(): String {
val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n")
buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n")
buffer.append(mAdminArea).append("|").append("\n")
buffer.append(mSubAdminArea).append("|").append("\n")
buffer.append(mLocality).append("|").append("\n")
buffer.append(mSubLocality).append("|").append("\n")
buffer.append(mThoroughfare).append("|").append("\n")
buffer.append(mSubThoroughfare).append("|").append("\n")
buffer.append(mPremises).append("|").append("\n")
buffer.append(mPostalCode).append("|").append("\n")
buffer.append(mCountryCode).append("|").append("\n")
buffer.append(mCountryName).append("|").append("\n")
buffer.append(mLatitude).append("|").append("\n")
buffer.append(mLongitude).append("|").append("\n")
buffer.append(mPhone).append("|").append("\n")
buffer.append(mUrl).append("|").append("\n")
return buffer.toString()
}
}
@Repository
interface LocationLogRepository : ReactiveMongoRepository<LocationLog, String> {
@Query("{ 'time' : { \$gte: ?0 } }")
fun findRecent(since: Long, sort: Sort): Flux<LocationLog>
// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC")
// fun findRecent(@Param("since") since: String): Flux<LocationLog>
fun findTop30ByOrderByTimeDesc(): Flux<LocationLog>
fun findAllBy() : Mono<LocationLog>
fun findFirstByOrderByTimeDesc() : Mono<LocationLog>
fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono<LocationLog>
fun save(log: LocationLog): Mono<LocationLog>
}
interface LocationService {
}
@Service
class LocationLogService : LocationService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var logRepository: LocationLogRepository
fun findAll(pageable: Pageable): Page<LocationLog> {
// 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()')
// Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다.
val items: List<LocationLog> = logRepository
.findAll(pageable.getSort())
.skip(pageable.getOffset())
.take(pageable.getPageSize().toLong())
.collectList() // Flux<T>를 Mono<List<T>>로 변환
.block() ?: emptyList() // Mono를 block()하여 실제 List<T>를 추출
// 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요)
val totalCount: Long = logRepository
.count() // Flux<Long> (count)
.block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출
// 3. Page 구현체(PageImpl)로 조합하여 반환
return PageImpl(items, pageable, totalCount)
}
fun find10() : List<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100)
println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
// val since = LocalDateTime.now().minusHours(24).format(formatter)
// println("since >> $since")
val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0)
return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf()
}
fun getLocationLog() : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun getLocationLogBy(userId : String) : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun filterByDistanceReactive(flux: Flux<LocationLog>, minDistanceMeter: Double): Flux<LocationLog> {
return flux
.buffer(2, 1)
.filter { pair ->
if (pair.size < 2) true
else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter
}
.map { pair ->
val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude)
val base = pair[0]
println("base >>> ${base.time} ${base.timeString}")
base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시
base
}
}
// Haversine 거리계산 함수 (단위:m)
fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // 지구 반지름(m)
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
fun save(log: LocationLog) {
println("saved msg before ${log}")
logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
})
}
}
interface RssDataInterface {
fun title() : String
fun thumbnailUrl() : String
fun originPage() : String
fun description() : String
fun pubDate() : Long
fun category() : RssDataType
fun getCho() : String?
}
enum class RssDataType {
NO_DATA,
YOUTUBE,
NewsFeed,
GURU,
Most,
TAGS,
REDDIT,
REDDIT_nsfw,
Dotax,
FmKorae,
DcInside,
RuliWeb,
Clien,
TheQoo,
Arca;
// fun getResId() = when (this) {
// YOUTUBE -> R.drawable.youtube
// REDDIT, REDDIT_nsfw -> R.drawable.reddit
// Dotax -> R.drawable.daum
// FmKorae -> R.drawable.fmk
// DcInside -> R.drawable.dcinside
// Arca -> R.drawable.arca
// else -> {
// 0
// }
// }
fun defaultImgSize() = when (this) {
YOUTUBE -> 200
REDDIT_nsfw,GURU,Most -> 360
else -> { 120 }
}
// fun getDefaultVisibiliy() = when (this) {
// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE
// else -> { View.VISIBLE }
// }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "RssData")
class RssData : RssDataInterface {
@Id
var originPage : String? = null
var title : String? = null
var description : String? = null
var thumbnail : String? = null
var pubDate : Long = 0L
var category : String? = null
var chosung : String? = null
@BsonIgnore
var mRssDataType : RssDataType? = null
override fun title(): String {
return when(category()){
RssDataType.NewsFeed -> {
if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: ""
}
else -> title ?: ""
}.apply {
// chosung = JamoUtils.split(this).joinToString("")
}
}
override fun thumbnailUrl(): String {
return thumbnail ?: ""
}
override fun originPage(): String {
return originPage ?: ""
}
override fun description(): String {
return when(category()){
RssDataType.YOUTUBE -> {
if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: ""
}
RssDataType.NewsFeed -> {
category().name
}
else -> description.plus(" / ").plus(category().name)
}
}
override fun pubDate(): Long {
return pubDate
}
override fun category(): RssDataType {
if (mRssDataType == null)
mRssDataType = RssDataType.valueOf(category!!)
return mRssDataType!!
}
override fun getCho(): String? {
return chosung
}
}
val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get()
object FeedParseManager {
val parsers = listOf<SoInterface>(QVZTb2dpcmw,SkFWTW9zdA)
fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) {
try {
parsers.filter { doc.title().contains(it.getName()) }.first()?.let {
it.parse(doc,service)
}
} catch (e : Exception) {
e.printStackTrace()
}
}
}
interface SoInterface{
fun getName() : String
fun parse(doc : org.jsoup.nodes.Document,service: RssDataService)
}
object QVZTb2dpcmw : SoInterface {
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByTag("article").forEach { article ->
val title = article.getElementsByTag("a").get(0).attr("title")
val href = article.getElementsByTag("a").get(0).attr("href")
val img = article.getElementsByTag("img").get(0).attr("data-src")
service.save(RssData().apply {
this.originPage = href
this.title = title
this.description = "Sogirl"
this.thumbnail = img
this.pubDate = Date().time
this.category = RssDataType.GURU.name
}) {
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${img}\n${href}")
// }
}
}
// lists.map {
// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}")
// }
}
}
object SkFWTW9zdA : SoInterface {
var dmy = SimpleDateFormat("dd-MM-yyyy")
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByClass("card").forEach { card ->
var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else ""
if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb
var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else ""
if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) {
val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href")
val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title")
val date = card.getElementsByTag("span").get(0).text()
service.save(RssData().apply {
lists.add(this)
description = model
thumbnail = thumb
originPage = link
this.title = title
category = RssDataType.Most.name
try {
pubDate = dmy.parse(date).time
}catch (e : Exception) {e.printStackTrace()}
}){
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${thumb}\n${link}")
// }
}
}
}
// service.sendMsg(lists.map {
// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n"
// }.joinToString(" \n "))
}
}
@Repository
interface RssDataRepository : ReactiveMongoRepository<RssData, String> {
fun findFirstByOriginPageEquals(originPage : String): Mono<RssData>
fun findAllByOrderByPubDate() : Mono<List<RssData>>
fun save(log: RssData): Mono<RssData>
}
@Service
class RssDataService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var rssDataRepository: RssDataRepository
fun hasItem(originPage : String) {
}
fun getLocationLog() : List<RssData>? {
return rssDataRepository.findAllByOrderByPubDate().block()
}
fun save(log: RssData, callback : (Boolean)->Unit) {
println("saved msg before ${Gson().toJson(log)}")
log.originPage?.let {
if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) {
rssDataRepository.save(log)
.subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, {
println("saved msg comp")
callback(true)
})
} else {
println("있어???")
}
}
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
suspend fun sendMsg(data : String) {
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
//package kr.lunaticbum.back.lun.model
//
//import com.google.gson.Gson
//import kr.lunaticbum.back.lun.configs.GlobalEnvironment
//import kr.lunaticbum.back.lun.utils.LogService
//import lombok.AllArgsConstructor
//import lombok.Data
//import lombok.NoArgsConstructor
//import org.bson.codecs.pojo.annotations.BsonIgnore
//import org.jsoup.Jsoup
//import org.springframework.beans.factory.annotation.Autowired
//import org.springframework.data.annotation.Id
//import org.springframework.data.domain.Page
//import org.springframework.data.domain.Pageable
//import org.springframework.data.domain.Sort
//import org.springframework.data.mongodb.core.mapping.Document
//import org.springframework.data.mongodb.repository.Query
//import org.springframework.data.mongodb.repository.ReactiveMongoRepository
//import org.springframework.stereotype.Repository
//import org.springframework.stereotype.Service
//import org.springframework.web.reactive.function.client.WebClient
//import reactor.core.publisher.Flux
//import reactor.core.publisher.Mono
//import java.text.SimpleDateFormat
//import java.time.Duration
//import java.util.*
//import org.springframework.data.domain.PageImpl
//import java.time.format.DateTimeFormatter

View File

@ -11,140 +11,140 @@ import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
/**
* 모든 게임의 랭킹을 저장하는 통합 모델
*/
@Document(collection = "game_ranks")
data class GameRank(
@Id
val id: String? = null,
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
val playerName: String, // 표준화된 플레이어 이름 필드
/** * 기본 점수 필드 (정렬 1순위).
* - 2048: 점수 (높을수록 좋음)
* - Sudoku: 완료 시간() (낮을수록 좋음)
* - Spider: 이동 횟수 (낮을수록 좋음)
*/
val primaryScore: Long,
/** * 보조 점수 필드 (정렬 2순위. : 스파이더의 완료 시간).
*/
val secondaryScore: Long? = null,
val timestamp: Instant = Instant.now()
)
/**
* 지원하는 게임 타입을 정의하는 Enum
*/
enum class GameType {
GAME_2048,
SUDOKU,
SPIDER,
NONOGRAM
}
/**
* 랭킹 등록 모든 프론트엔드에서 공통으로 사용할 DTO
*/
data class UnifiedRankDto(
val gameType: GameType,
val contextId: String?,
val playerName: String,
val primaryScore: Long,
val secondaryScore: Long? = null
)
@Repository
interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
fun save(gameRank: GameRank): Mono<GameRank>
// 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
}
@Service
class GameRankService(
private val rankRepository: GameRankRepository,
private val userManager: UserManager ) {
/**
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
*/
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
return when (gameType) {
// 점수가 높아야 하는 게임 (2048)
GameType.GAME_2048 ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
// 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
}
}
/**
* [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
*/
fun submitRank(rankDto: UnifiedRankDto): Mono<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<GameRank> { 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<GameRank> {
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
}
}
//
///**
// * 모든 게임의 랭킹을 저장하는 통합 모델
// */
//@Document(collection = "game_ranks")
//data class GameRank(
// @Id
// val id: String? = null,
// val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
// val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
// val playerName: String, // 표준화된 플레이어 이름 필드
//
// /** * 기본 점수 필드 (정렬 1순위).
// * - 2048: 점수 (높을수록 좋음)
// * - Sudoku: 완료 시간(초) (낮을수록 좋음)
// * - Spider: 이동 횟수 (낮을수록 좋음)
// */
// val primaryScore: Long,
//
// /** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간).
// */
// val secondaryScore: Long? = null,
//
// val timestamp: Instant = Instant.now()
//)
//
///**
// * 지원하는 게임 타입을 정의하는 Enum
// */
//enum class GameType {
// GAME_2048,
// SUDOKU,
// SPIDER,
// NONOGRAM
//}
//
///**
// * 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO
// */
//data class UnifiedRankDto(
// val gameType: GameType,
// val contextId: String?,
// val playerName: String,
// val primaryScore: Long,
// val secondaryScore: Long? = null
//)
//
//@Repository
//interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
//
// fun save(gameRank: GameRank): Mono<GameRank>
// // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
// fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
// gameType: GameType,
// contextId: String?
// ): Flux<GameRank>
//
// // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
// fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
// gameType: GameType,
// contextId: String?
// ): Flux<GameRank>
//
// // [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
// fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
//}
//
//
//@Service
//class GameRankService(
// private val rankRepository: GameRankRepository,
// private val userManager: UserManager ) {
// /**
// * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
// */
// fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
// return when (gameType) {
// // 점수가 높아야 하는 게임 (2048)
// GameType.GAME_2048 ->
// rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
//
// // 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
// GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
// rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
// }
// }
//
// /**
// * [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
// */
// fun submitRank(rankDto: UnifiedRankDto): Mono<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<GameRank> { 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<GameRank> {
// return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
// }
//}

View File

@ -72,7 +72,8 @@ interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
class ImageMetaService(
private val repository: ImageMetaRepository,
private val logService: LogService, // LogService 주입
@Value("\${image.upload.path}") private val uploadPath: String // application.properties의 업로드 경로 주입
@Value("\${image.upload.path}") private val uploadPath: String, // application.properties의 업로드 경로 주입
@Value("\${build.config.run}") private val build_config_run: String
) {
// [신규 추가] 백그라운드 작업용 Coroutine Scope 정의
@ -95,14 +96,17 @@ class ImageMetaService(
return repository.findRandomImage()
}
// application.properties의 업로드 경로 주입
/**
* [신규 추가] Spring Boot가 준비되었을 (부팅 완료) 실행되는 리스너
*/
@Profile("!local")
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
logService.log("Application ready. Launching initial image DB sync task...")
launchSyncTask()
logService.log("Application ${build_config_run} ready. Launching initial image DB sync task...")
if (build_config_run.contains("prd")) {
launchSyncTask()
}
}
/**

View File

@ -0,0 +1,70 @@
package kr.lunaticbum.back.lun.model
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Document(collection = "Messages")
data class Message(
@Id
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var senderId: String,
var receiverId: String,
var title: String,
var content: String,
var timestamp: Long = System.currentTimeMillis(),
var isRead: Boolean = false
)
@Repository
interface MessageRepository : ReactiveMongoRepository<Message, String> {
fun findByReceiverIdOrderByTimestampDesc(receiverId: String): Flux<Message>
fun countByReceiverIdAndIsRead(receiverId: String, isRead: Boolean): Mono<Long>
fun findBySenderIdOrderByTimestampDesc(senderId: String): Flux<Message>
}
@Service
class MessageService(private val messageRepository: MessageRepository) {
fun getMessagesForUser(userId: String): Flux<Message> {
return messageRepository.findByReceiverIdOrderByTimestampDesc(userId)
}
fun getUnreadMessageCount(userId: String): Mono<Long> {
return messageRepository.countByReceiverIdAndIsRead(userId, false)
}
fun sendMessage(senderId: String, receiverId: String, title: String, content: String): Mono<Message> {
val message = Message(senderId = senderId, receiverId = receiverId, title = title, content = content)
return messageRepository.save(message)
}
fun markMessageAsRead(messageId: String, userId: String): Mono<Message> {
return messageRepository.findById(messageId)
.filter { it.receiverId == userId } // 본인 쪽지만 읽음 처리하도록 보안 강화
.flatMap { message ->
if (!message.isRead) {
message.isRead = true
messageRepository.save(message)
} else {
Mono.just(message)
}
}
}
// [신규] 사용자가 보낸 쪽지를 가져오는 서비스를 추가합니다.
fun getSentMessagesByUser(userId: String): Flux<Message> {
return messageRepository.findBySenderIdOrderByTimestampDesc(userId)
}
}

View File

@ -1,6 +1,7 @@
package kr.lunaticbum.back.lun.model
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.gson.Gson
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor
@ -10,17 +11,21 @@ import lombok.NoArgsConstructor
import okio.Timeout
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonIgnore
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.FindAndModifyOptions // [추가됨]
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
@ -35,10 +40,19 @@ 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 org.springframework.data.mongodb.core.query.Query
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.reactive.function.client.WebClient
import java.text.SimpleDateFormat
import java.util.ArrayList
import java.util.Base64
import java.util.Date
enum class PostType {
STANDARD, // 일반 블로그 글
ABOUT_SITE // 사이트 소개 글
}
@Document(collection = "Post")
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
data class Post(
@ -74,7 +88,9 @@ data class Post(
var readCount : Long = 0,
var voteCount : Long = 0,
var unlikeCount : Long = 0,
var isBlocked: Boolean = false
var isBlocked: Boolean = false,
// [추가] 게시물 타입을 구분하는 필드. 기본값은 'STANDARD'
var postType: String = PostType.STANDARD.name
)
@Document(collection = "Comment")
@ -157,7 +173,7 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
fun countByOrderByModifyTimeDesc(): Mono<Long>
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
@Aggregation(pipeline = [
"{ \$match: { posting: true, isBlocked: false } }", // [수정됨]
@ -264,6 +280,18 @@ class PostManager(
@Autowired
private lateinit var bCryptPasswordEncoder: PasswordEncoder
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
fun findLatestAboutPost(): Mono<Post> {
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
}
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
fun findAboutPostHistory(): Flux<Post> {
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
}
// [신규] 게시물 차단
fun blockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
@ -589,3 +617,543 @@ object PayloadDecoder {
return objectMapper.readValue(originalJson, clazz)
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "LocationLog")
class LocationLog {
var mFeatureName: String? = null
var mAddressLines: ArrayList<String> = arrayListOf()
var mAdminArea: String? = null
var mSubAdminArea: String? = null
var mLocality: String? = null
var mSubLocality: String? = null
var mThoroughfare: String? = null
var mSubThoroughfare: String? = null
var mPremises: String? = null
var mPostalCode: String? = null
var mCountryCode: String? = null
var mCountryName: String? = null
var mLatitude = 0.0
var mLongitude = 0.0
var mPhone: String? = null
var timeString : String? = null
var mUrl: String? = null
var time : Long = 0L
var userId : String? = null
var bettween : String? = null
val displayTime: String
get() {
// 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다.
if (!this.timeString.isNullOrBlank()) {
return this.timeString!!
}
// 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다.
if (this.time != null) {
// 원하는 날짜/시간 포맷 정의
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return formatter.format(Date(this.time))
}
// 3. 둘 다 없으면 "시간 없음"을 반환한다.
return "[시간 정보 없음]"
}
override fun toString(): String {
val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n")
buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n")
buffer.append(mAdminArea).append("|").append("\n")
buffer.append(mSubAdminArea).append("|").append("\n")
buffer.append(mLocality).append("|").append("\n")
buffer.append(mSubLocality).append("|").append("\n")
buffer.append(mThoroughfare).append("|").append("\n")
buffer.append(mSubThoroughfare).append("|").append("\n")
buffer.append(mPremises).append("|").append("\n")
buffer.append(mPostalCode).append("|").append("\n")
buffer.append(mCountryCode).append("|").append("\n")
buffer.append(mCountryName).append("|").append("\n")
buffer.append(mLatitude).append("|").append("\n")
buffer.append(mLongitude).append("|").append("\n")
buffer.append(mPhone).append("|").append("\n")
buffer.append(mUrl).append("|").append("\n")
return buffer.toString()
}
}
@Repository
interface LocationLogRepository : ReactiveMongoRepository<LocationLog, String> {
@Aggregation(pipeline = [
"{ \$match: { 'time' : { \$gte: ?0 } } }"
])
fun findRecent(since: Long, sort: Sort): Flux<LocationLog>
// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC")
// fun findRecent(@Param("since") since: String): Flux<LocationLog>
fun findTop30ByOrderByTimeDesc(): Flux<LocationLog>
fun findAllBy() : Mono<LocationLog>
fun findFirstByOrderByTimeDesc() : Mono<LocationLog>
fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono<LocationLog>
fun save(log: LocationLog): Mono<LocationLog>
}
interface LocationService {
}
@Service
class LocationLogService : LocationService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var logRepository: LocationLogRepository
fun findAll(pageable: Pageable): Page<LocationLog> {
// 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()')
// Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다.
val items: List<LocationLog> = logRepository
.findAll(pageable.getSort())
.skip(pageable.getOffset())
.take(pageable.getPageSize().toLong())
.collectList() // Flux<T>를 Mono<List<T>>로 변환
.block() ?: emptyList() // Mono를 block()하여 실제 List<T>를 추출
// 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요)
val totalCount: Long = logRepository
.count() // Flux<Long> (count)
.block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출
// 3. Page 구현체(PageImpl)로 조합하여 반환
return PageImpl(items, pageable, totalCount)
}
fun find10() : List<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100)
println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
// val since = LocalDateTime.now().minusHours(24).format(formatter)
// println("since >> $since")
val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0)
return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf()
}
fun getLocationLog() : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun getLocationLogBy(userId : String) : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun filterByDistanceReactive(flux: Flux<LocationLog>, minDistanceMeter: Double): Flux<LocationLog> {
return flux
.buffer(2, 1)
.filter { pair ->
if (pair.size < 2) true
else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter
}
.map { pair ->
val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude)
val base = pair[0]
println("base >>> ${base.time} ${base.timeString}")
base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시
base
}
}
// Haversine 거리계산 함수 (단위:m)
fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // 지구 반지름(m)
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
fun save(log: LocationLog) {
println("saved msg before ${log}")
logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
})
}
}
interface RssDataInterface {
fun title() : String
fun thumbnailUrl() : String
fun originPage() : String
fun description() : String
fun pubDate() : Long
fun category() : RssDataType
fun getCho() : String?
}
enum class RssDataType {
NO_DATA,
YOUTUBE,
NewsFeed,
GURU,
Most,
TAGS,
REDDIT,
REDDIT_nsfw,
Dotax,
FmKorae,
DcInside,
RuliWeb,
Clien,
TheQoo,
Arca;
// fun getResId() = when (this) {
// YOUTUBE -> R.drawable.youtube
// REDDIT, REDDIT_nsfw -> R.drawable.reddit
// Dotax -> R.drawable.daum
// FmKorae -> R.drawable.fmk
// DcInside -> R.drawable.dcinside
// Arca -> R.drawable.arca
// else -> {
// 0
// }
// }
fun defaultImgSize() = when (this) {
YOUTUBE -> 200
REDDIT_nsfw,GURU,Most -> 360
else -> { 120 }
}
// fun getDefaultVisibiliy() = when (this) {
// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE
// else -> { View.VISIBLE }
// }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "RssData")
class RssData : RssDataInterface {
@Id
var originPage : String? = null
var title : String? = null
var description : String? = null
var thumbnail : String? = null
var pubDate : Long = 0L
var category : String? = null
var chosung : String? = null
@BsonIgnore
var mRssDataType : RssDataType? = null
override fun title(): String {
return when(category()){
RssDataType.NewsFeed -> {
if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: ""
}
else -> title ?: ""
}.apply {
// chosung = JamoUtils.split(this).joinToString("")
}
}
override fun thumbnailUrl(): String {
return thumbnail ?: ""
}
override fun originPage(): String {
return originPage ?: ""
}
override fun description(): String {
return when(category()){
RssDataType.YOUTUBE -> {
if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: ""
}
RssDataType.NewsFeed -> {
category().name
}
else -> description.plus(" / ").plus(category().name)
}
}
override fun pubDate(): Long {
return pubDate
}
override fun category(): RssDataType {
if (mRssDataType == null)
mRssDataType = RssDataType.valueOf(category!!)
return mRssDataType!!
}
override fun getCho(): String? {
return chosung
}
}
val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get()
object FeedParseManager {
val parsers = listOf<SoInterface>(QVZTb2dpcmw,SkFWTW9zdA)
fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) {
try {
parsers.filter { doc.title().contains(it.getName()) }.first()?.let {
it.parse(doc,service)
}
} catch (e : Exception) {
e.printStackTrace()
}
}
}
interface SoInterface{
fun getName() : String
fun parse(doc : org.jsoup.nodes.Document,service: RssDataService)
}
object QVZTb2dpcmw : SoInterface {
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByTag("article").forEach { article ->
val title = article.getElementsByTag("a").get(0).attr("title")
val href = article.getElementsByTag("a").get(0).attr("href")
val img = article.getElementsByTag("img").get(0).attr("data-src")
service.save(RssData().apply {
this.originPage = href
this.title = title
this.description = "Sogirl"
this.thumbnail = img
this.pubDate = Date().time
this.category = RssDataType.GURU.name
}) {
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${img}\n${href}")
// }
}
}
// lists.map {
// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}")
// }
}
}
object SkFWTW9zdA : SoInterface {
var dmy = SimpleDateFormat("dd-MM-yyyy")
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByClass("card").forEach { card ->
var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else ""
if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb
var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else ""
if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) {
val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href")
val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title")
val date = card.getElementsByTag("span").get(0).text()
service.save(RssData().apply {
lists.add(this)
description = model
thumbnail = thumb
originPage = link
this.title = title
category = RssDataType.Most.name
try {
pubDate = dmy.parse(date).time
}catch (e : Exception) {e.printStackTrace()}
}){
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${thumb}\n${link}")
// }
}
}
}
// service.sendMsg(lists.map {
// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n"
// }.joinToString(" \n "))
}
}
@Repository
interface RssDataRepository : ReactiveMongoRepository<RssData, String> {
fun findFirstByOriginPageEquals(originPage : String): Mono<RssData>
fun findAllByOrderByPubDate() : Mono<List<RssData>>
fun save(log: RssData): Mono<RssData>
}
@Service
class RssDataService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var rssDataRepository: RssDataRepository
fun hasItem(originPage : String) {
}
fun getLocationLog() : List<RssData>? {
return rssDataRepository.findAllByOrderByPubDate().block()
}
fun save(log: RssData, callback : (Boolean)->Unit) {
println("saved msg before ${Gson().toJson(log)}")
log.originPage?.let {
if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) {
rssDataRepository.save(log)
.subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, {
println("saved msg comp")
callback(true)
})
} else {
println("있어???")
}
}
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
suspend fun sendMsg(data : String) {
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
enum class Visibility {
PUBLIC, // 전체 공개
MEMBERS, // 회원 공개
PRIVATE // 비공개 (나만 보기)
}
enum class MetadataStatus {
PENDING, // 처리 대기 중
COMPLETED, // 처리 완료
FAILED // 처리 실패
}
@Document(collection = "WebBookmark")
data class WebBookmark(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var userId: String, // 누가 저장했는지
var url: String, // 원본 페이지 URL
var title: String? = null, // 페이지 제목
var description: String? = null, // 페이지 요약 (메타 태그)
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
var userComment: String? = null, // 사용자가 남긴 짧은 의견
var tags: List<String>? = null, // 태그 (예: #kotlin, #spring)
var savedAt: Long = System.currentTimeMillis(), // 저장 시간
// [신규 추가] 공개 범위 필드. 기본값은 PRIVATE.
var visibility: String = Visibility.PRIVATE.name,
// [신규 추가] 좋아요/싫어요 카운트 필드
var voteCount: Long = 0,
var unlikeCount: Long = 0,
var userSelectedImageUrl: String? = null,
var metadataStatus: String = MetadataStatus.PENDING.name
)
@Repository
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
fun findByMetadataStatus(status: String): Flux<WebBookmark>
}
@Service
class WebBookmarkService(private val repository: WebBookmarkRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
) {
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
return repository.findByUserIdOrderBySavedAtDesc(userId)
}
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
return repository.save(bookmark)
}
// 필요하다면 삭제, 수정 기능 추가
fun deleteBookmark(id: String): Mono<Void> {
return repository.deleteById(id)
}
// [신규 추가] 사용자의 권한에 따라 볼 수 있는 북마크 목록을 페이지네이션으로 조회
fun getVisibleBookmarks(userDetails: UserDetails?, pageable: Pageable): Mono<Page<WebBookmark>> {
val visibleScopes = when {
// 관리자일 경우 모든 북마크 조회 가능 (필요 시 추가)
// userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true ->
// listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name, Visibility.PRIVATE.name)
// 로그인 사용자일 경우 PUBLIC과 MEMBERS 조회 가능
userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name)
// 비로그인 사용자일 경우 PUBLIC만 조회 가능
else -> listOf(Visibility.PUBLIC.name)
}
val bookmarks = repository.findByVisibilityInOrderBySavedAtDesc(visibleScopes, pageable).collectList()
val totalCount = repository.countByVisibilityIn(visibleScopes)
// Mono.zip을 사용하여 두 비동기 작업(목록 조회, 카운트)을 병렬로 실행
return Mono.zip(bookmarks, totalCount).map { tuple ->
PageImpl(tuple.t1, pageable, tuple.t2)
}
}
/**
* [신규 추가]
* 북마크의 좋아요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
*/
fun incrementVote(bookmarkId: String): Mono<WebBookmark> {
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
val update = Update().inc("voteCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
}
/**
* [신규 추가]
* 북마크의 싫어요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
*/
fun incrementUnlike(bookmarkId: String): Mono<WebBookmark> {
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
val update = Update().inc("unlikeCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
}
}

View File

@ -26,7 +26,12 @@ import javax.imageio.ImageIO
import kotlin.random.Random
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.mongodb.core.index.Indexed
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 java.io.File
import java.time.Instant
import java.util.UUID
@ -501,3 +506,141 @@ interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String>
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
}
/**
* 모든 게임의 랭킹을 저장하는 통합 모델
*/
@Document(collection = "game_ranks")
data class GameRank(
@Id
val id: String? = null,
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
val playerName: String, // 표준화된 플레이어 이름 필드
/** * 기본 점수 필드 (정렬 1순위).
* - 2048: 점수 (높을수록 좋음)
* - Sudoku: 완료 시간() (낮을수록 좋음)
* - Spider: 이동 횟수 (낮을수록 좋음)
*/
val primaryScore: Long,
/** * 보조 점수 필드 (정렬 2순위. : 스파이더의 완료 시간).
*/
val secondaryScore: Long? = null,
val timestamp: Instant = Instant.now()
)
/**
* 지원하는 게임 타입을 정의하는 Enum
*/
enum class GameType {
GAME_2048,
SUDOKU,
SPIDER,
NONOGRAM
}
/**
* 랭킹 등록 모든 프론트엔드에서 공통으로 사용할 DTO
*/
data class UnifiedRankDto(
val gameType: GameType,
val contextId: String?,
val playerName: String,
val primaryScore: Long,
val secondaryScore: Long? = null
)
@Repository
interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
fun save(gameRank: GameRank): Mono<GameRank>
// 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
}
@Service
class GameRankService(
private val rankRepository: GameRankRepository,
private val userManager: UserManager ) {
/**
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
*/
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
return when (gameType) {
// 점수가 높아야 하는 게임 (2048)
GameType.GAME_2048 ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
// 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
}
}
/**
* [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
*/
fun submitRank(rankDto: UnifiedRankDto): Mono<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<GameRank> { 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<GameRank> {
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
}
}

View File

@ -48,7 +48,7 @@ class From {
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "TelegramMessage")
class Message {
class TlgMessage {
@Id
var message_id: String = ""
@ -89,7 +89,7 @@ class TelegramLocation {
class Result {
var update_id: Int = 0
var message: Message? = null
var message: TlgMessage? = null
}
class TelegramUpdate {
@ -100,17 +100,17 @@ class TelegramUpdate {
}
@Repository
interface TelegramRepository : ReactiveMongoRepository<Message,String> {
interface TelegramRepository : ReactiveMongoRepository<TlgMessage,String> {
@Query("{id :?0}")
override fun findById(id: String): Mono<Message>
override fun findById(id: String): Mono<TlgMessage>
@Query("{id :?0}")
fun count(id: Int): Mono<Long>
fun save(message: Message): Mono<Message>
fun save(message: TlgMessage): Mono<TlgMessage>
}
interface MsgService {
fun findById(id: String): Mono<Message>?
fun findById(id: String): Mono<TlgMessage>?
}
@Service
@ -123,7 +123,7 @@ class TelegramMsgService : MsgService {
override fun findById(id: String): Mono<Message>? {
override fun findById(id: String): Mono<TlgMessage>? {
return telegramRepository.findById(id)
}
@ -132,7 +132,7 @@ class TelegramMsgService : MsgService {
return telegramRepository.count(id)
}
fun save(msg: Message) {
fun save(msg: TlgMessage) {
println("saved msg before ${msg}")
telegramRepository.save(msg).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
@ -166,3 +166,59 @@ class GSRItemPageMap {
var cse_image : ArrayList<Map<String,String>>? = null
}
class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
class CurrentWeather {
var location: Location? = null
var current: Current? = null
fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" +
"체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}"
}

View File

@ -13,6 +13,7 @@ import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
@ -179,8 +180,13 @@ class UserManager(
override fun loadUserByUsername(username: String?): UserDetails {
logService.log("username ${username}")
var user = findById(username!!)?.blockOptional(Duration.ofMillis(5000L))?.get() ?: User()
if (username == null) {
throw UsernameNotFoundException("Username cannot be null")
}
// 사용자를 찾지 못하면 예외를 던지도록 수정
val user = findById(username)
.blockOptional(Duration.ofMillis(5000L))
.orElseThrow { UsernameNotFoundException("User not found: $username") }
val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN"

View File

@ -1,57 +0,0 @@
package kr.lunaticbum.back.lun.model
class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
class CurrentWeather {
var location: Location? = null
var current: Current? = null
fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" +
"체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}"
}

View File

@ -0,0 +1,69 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.MetadataStatus
import kr.lunaticbum.back.lun.model.WebBookmark
import kr.lunaticbum.back.lun.model.WebBookmarkRepository
import kr.lunaticbum.back.lun.utils.LogService
import org.jsoup.Jsoup
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
@Service
class BookmarkProcessorService(
private val bookmarkRepository: WebBookmarkRepository,
private val logService: LogService
) {
// fixedDelayString = "60000" -> 1분에 한 번씩 실행
@Scheduled(fixedDelayString = "60000")
fun processPendingBookmarks() {
logService.log("Starting scheduled job: Process Pending Bookmarks...")
bookmarkRepository.findByMetadataStatus(MetadataStatus.PENDING.name) // PENDING 상태인 북마크 조회
.flatMap { bookmark ->
// 각 북마크에 대해 메타데이터를 가져오고 DB를 업데이트하는 비동기 작업을 수행
fetchAndApplyMetadata(bookmark)
}
.subscribe(
{ updatedBookmark -> logService.log("Successfully processed bookmark ID: ${updatedBookmark.id}") },
{ error -> logService.log("Error during bookmark processing: ${error.message}") }
)
}
private fun fetchAndApplyMetadata(bookmark: WebBookmark): Mono<WebBookmark> {
return Mono.fromCallable {
// Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고
// 별도 스레드에서 실행되도록 subscribeOn을 사용
logService.log("Fetching metadata for: ${bookmark.url}")
val doc = Jsoup.connect(bookmark.url).timeout(10000).get() // 10초 타임아웃
// 메타데이터 추출
val title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() }
val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content")
// 북마크 객체 업데이트
bookmark.title = title
bookmark.description = description
bookmark.thumbnailUrl = imageUrl
bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경
bookmark
}
.subscribeOn(Schedulers.boundedElastic())
.flatMap { updatedBookmark ->
// 업데이트된 북마크를 DB에 저장
bookmarkRepository.save(updatedBookmark)
}
.onErrorResume { error ->
// 오류 발생 시 상태를 FAILED로 변경하여 저장
logService.log("Failed to fetch metadata for URL: ${bookmark.url}. Error: ${error.message}")
bookmark.metadataStatus = MetadataStatus.FAILED.name
bookmarkRepository.save(bookmark)
}
}
}

View File

@ -1,87 +1,65 @@
// kr/lunaticbum/back/lun/utils/JwtUtil.kt
package kr.lunaticbum.back.lun.utils
import io.jsonwebtoken.*
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import jakarta.servlet.http.Cookie
import kr.lunaticbum.back.lun.configs.JwtRule
import kr.lunaticbum.back.lun.configs.TokenStatus
import lombok.RequiredArgsConstructor
import lombok.extern.slf4j.Slf4j
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.charset.StandardCharsets
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.security.Key
import java.util.*
import java.util.function.Function
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Component
class JwtUtil {
fun getTokenStatus(token: String?, secretKey: Key?): TokenStatus {
try {
var cls = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
cls.body.keys.forEach {
println("${it} >>> ${cls.body.get(it).toString()}")
}
return TokenStatus.AUTHENTICATED
} catch (e: ExpiredJwtException) {
// log.error(INVALID_EXPIRED_JWT.getMessage())
return TokenStatus.EXPIRED
} catch (e: IllegalArgumentException) {
// log.error(INVALID_EXPIRED_JWT.getMessage())
return TokenStatus.EXPIRED
} catch (e: JwtException) {
throw BusinessException(ErrorCode.INVALID_JWT)
}
@Value("\${jwt.secret}")
private lateinit var secret: String
@Value("\${jwt.expiration}")
private lateinit var expirationTime: String
private fun getSigningKey(): Key {
return Keys.hmacShaKeyFor(secret.toByteArray())
}
fun resolveTokenFromCookie(cookies: Array<Cookie>?, tokenPrefix: JwtRule): String {
return Arrays.stream(cookies)
.filter { cookie -> cookie.getName().equals(tokenPrefix.value, ignoreCase = true) }
.findFirst()
.map { it.value }
.orElse("")
fun generateToken(userDetails: UserDetails): String {
val claims = mutableMapOf<String, Any>()
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.username)
.setIssuedAt(Date(System.currentTimeMillis()))
.setExpiration(Date(System.currentTimeMillis() + expirationTime.toLong()))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact()
}
fun getSigningKey(secretKey: String): Key {
val encodedKey = encodeToBase64(secretKey)
return Keys.hmacShaKeyFor(encodedKey.toByteArray(StandardCharsets.UTF_8))
fun extractUsername(token: String): String {
return extractClaim(token, Claims::getSubject)
}
private fun encodeToBase64(secretKey: String): String {
return Base64.getEncoder().encodeToString(secretKey.toByteArray())
fun isTokenValid(token: String, userDetails: UserDetails): Boolean {
val username = extractUsername(token)
return (username == userDetails.username && !isTokenExpired(token))
}
fun resetToken(tokenPrefix: JwtRule): Cookie {
val cookie: Cookie = Cookie(tokenPrefix.value, null)
cookie.setMaxAge(0)
cookie.setPath("/")
return cookie
private fun isTokenExpired(token: String): Boolean {
return extractExpiration(token).before(Date())
}
fun extractToken(token: String?, secretKey: Key?): Jws<Claims>? {
try {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
} catch (e: JwtException) {
throw BusinessException(ErrorCode.INVALID_JWT)
}
private fun extractExpiration(token: String): Date {
return extractClaim(token, Claims::getExpiration)
}
}
class BusinessException(error : ErrorCode) : Exception(error.name)
enum class ErrorCode {
JWT_TOKEN_NOT_FOUND,
NOT_AUTHENTICATED_USER,
INVALID_EXPIRED_JWT,
INVALID_JWT
private fun <T> extractClaim(token: String, claimsResolver: Function<Claims, T>): T {
val claims = extractAllClaims(token)
return claimsResolver.apply(claims)
}
private fun extractAllClaims(token: String): Claims {
return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).body
}
}

View File

@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s
api.base-url=ss
api.base-url=ss
build.config.run=local
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000

View File

@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s
api.base-url=ss
api.base-url=ss
build.config.run=prd
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000

View File

@ -100,4 +100,8 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s
api.base-url=ss
api.base-url=ss
build.config.run=local
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000

View File

@ -233,6 +233,8 @@ window.addEventListener('DOMContentLoaded', () => {
closePopup();
});
}
checkUnreadMessages(); // 함수 호출 추가
});
/* --- (DOMContentLoaded 끝) --- */
@ -815,6 +817,7 @@ function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); }
function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); }
function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); }
function gotoBUMSpace() { document.location.replace(`${getMainPath()}/bums/face.bs`); }
function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); }
// [추가] 네모로직 업로드 페이지로 이동하는 함수
function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); }
@ -1551,4 +1554,227 @@ async function showConfirm(title, text) {
cancelButtonText: '취소'
});
return result.isConfirmed;
}
function sendTlg(form, type,keyword) {
console.log(form)
let data = {
'name': form.querySelector("#name").value,
'email': form.querySelector("#email").value,
'message': form.querySelector("#message").value,
}
if (data.name != null && data.email != null && data.message != null && data.message.length > 0) {
if(confirm(JSON.stringify(data) + "\n해당 내용으로\n메시지 보내쉴?")) {
post(getMainPath()+"/tlg/repotToMe.bjx",type,JSON.stringify(data),keyword, function (resultData) {
showAlert("서버에 전달됨.")
})
} else {
}
}
return false
}
async function checkUnreadMessages() {
const isLoggedIn = !!document.querySelector('a[href="javascript:logout()"]');
if (!isLoggedIn) return; // 비로그인 상태면 실행 중단
try {
const response = await fetch('/messages/unread-count');
if (response.ok) {
const data = await response.json();
if (data.count > 0) {
const icon = document.getElementById('message-icon');
if (icon) {
icon.style.display = 'inline-block'; // 아이콘 표시
}
}
}
} catch (error) {
console.error('Failed to check for unread messages:', error);
}
}
function handleBookmarkVote(buttonElement, voteType) {
const controls = buttonElement.closest('.vote-controls');
const bookmarkId = controls.dataset.bookmarkId;
controls.querySelectorAll('button').forEach(btn => btn.disabled = true); // 중복 클릭 방지
// [수정] 북마크용 API 엔드포인트 사용
const url = `${getMainPath()}/bookmarks/${bookmarkId}/${voteType === 'like' ? 'like' : 'unlike'}`;
// CSRF 토큰 준비
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const headers = { 'X-CSRF-TOKEN': csrfToken };
fetch(url, { method: 'POST', headers: headers })
.then(res => res.json())
.then(data => {
controls.querySelector('.like-count').innerText = data.voteCount;
controls.querySelector('.unlike-count').innerText = data.unlikeCount;
})
.catch(error => console.error('Error handling bookmark vote:', error))
.finally(() => {
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
});
}
/**
* 특정 북마크의 댓글 섹션을 열거나 닫습니다.
*/
function toggleCommentSection(bookmarkId) {
const section = document.getElementById(`comment-section-${bookmarkId}`);
if (section.style.display === 'none') {
section.style.display = 'block';
fetchBookmarkComments(bookmarkId); // 처음 열 때 댓글 로드
} else {
section.style.display = 'none';
}
}
/**
* 특정 북마크의 댓글 목록을 불러옵니다.
*/
async function fetchBookmarkComments(bookmarkId) {
const listContainer = document.getElementById(`comments-list-${bookmarkId}`);
listContainer.innerHTML = '댓글 로딩 중...';
const response = await fetch(`${getMainPath()}/bookmarks/${bookmarkId}/comments`);
const data = await response.json();
listContainer.innerHTML = '';
if (data.resultCode === 0 && data.comments.length > 0) {
data.comments.forEach(comment => {
// 기존 블로그 댓글 HTML 생성 함수 재사용
listContainer.innerHTML += createCommentHTML(comment);
});
} else {
listContainer.innerHTML = '아직 댓글이 없습니다.';
}
}
/**
* 북마크에 댓글을 등록합니다.
*/
function submitBookmarkComment(bookmarkId) {
const input = document.getElementById(`comment-input-${bookmarkId}`);
const content = input.value.trim();
if (!content) {
showAlert('알림', '댓글 내용을 입력하세요.');
return;
}
// 블로그 댓글과 동일한 DTO 및 암호화 방식 사용
const commentData = { content: content, parentId: null };
const uploadUrl = `${getMainPath()}/bookmarks/${bookmarkId}/comments`;
// 기존 `post` 유틸리티 함수를 재사용하여 서버에 전송
post(uploadUrl, serverData.enc, JSON.stringify(commentData), serverData.keyword, (resultData) => {
const response = JSON.parse(resultData);
if (response.resultCode === 0) {
input.value = '';
fetchBookmarkComments(bookmarkId); // 댓글 목록 새로고침
} else {
showAlert('오류', '댓글 등록에 실패했습니다: ' + response.resultMsg);
}
});
}
/**
* 북마크 클릭 사용자에게 선택지를 보여주는 함수
* @param {HTMLElement} element - 클릭된 <a> 요소
*/
async function showBookmarkOptions(element) {
const url = element.dataset.url;
const title = element.dataset.title;
const result = await Swal.fire({
title: '어떻게 보시겠어요?',
text: title,
icon: 'question',
showDenyButton: true,
confirmButtonText: '새 탭에서 열기',
denyButtonText: '여기서 보기 (Iframe)',
confirmButtonColor: '#3085d6',
denyButtonColor: '#555',
});
if (result.isConfirmed) {
// '새 탭에서 열기' 선택 시
window.open(url, '_blank');
} else if (result.isDenied) {
// '여기서 보기 (Iframe)' 선택 시
openBookmarkInIframe(url, title);
}
}
/**
* iframe 로드 실패 일관된 처리를 위한 헬퍼 함수
* @param {string} title - 북마크 제목
* @param {string} url - 북마크 URL
*/
function handleIframeLoadFailure(title, url) {
closePopup(); // 팝업 닫기
if (confirm(`'${title}' 페이지를 내부에서 여는 데 실패했습니다.\n\n새 탭에서 여시겠습니까?`)) {
window.open(url, '_blank');
}
}
/**
* 지정된 URL을 Iframe 팝업으로 여는 함수 (try-catch 로직 적용)
* @param {string} url - 표시할 URL
* @param {string} title - 표시할 제목
*/
function openBookmarkInIframe(url, title) {
const popup = document.getElementById('iframe-viewer-popup');
const titleElement = document.getElementById('iframe-viewer-title');
const iframe = document.getElementById('bookmark-iframe');
const overlay = document.querySelector('.dim_layer');
const newTabLink = document.getElementById('iframe-open-new-tab-link');
if (!popup || !titleElement || !iframe || !overlay || !newTabLink) {
console.error('Iframe viewer elements not found!');
return;
}
// iframe의 로딩을 시작하기 전에 src를 초기화하여 이전 상태를 지웁니다.
iframe.src = 'about:blank';
// iframe의 onload 이벤트 핸들러
iframe.onload = () => {
console.log("iframe onload 이벤트 발생. 내부 문서 접근을 시도합니다...");
try {
// 동일 출처 정책(Same-Origin Policy)을 위반하는 접근 시도
// 이 코드가 오류를 발생시키면, 다른 출처의 문서가 로드된 것 (성공 또는 오류 페이지)
const dummyAccess = iframe.contentWindow.location.href;
// 만약 위 코드에서 오류가 발생하지 않았다면, iframe이 동일 출처이거나 비어있다는 의미.
// 외부 사이트 로드는 실패한 것으로 간주합니다.
console.warn("iframe 접근이 차단되지 않았습니다. 로드 실패로 간주합니다.");
handleIframeLoadFailure(title, url);
} catch (e) {
// SecurityError가 발생! 다른 출처의 문서가 성공적으로 로드되었다고 간주합니다.
// (이것이 실제 콘텐츠일 수도, 브라우저의 오류 페이지일 수도 있습니다)
console.log("iframe 접근이 보안 정책에 의해 차단되었습니다. 일단 성공으로 간주합니다.", e);
// 팝업을 그대로 유지
}
};
// 네트워크 오류 등으로 iframe 로드 자체가 실패했을 때를 위한 핸들러
iframe.onerror = () => {
console.error("iframe onerror 이벤트 발생. 로드 실패로 처리합니다.");
handleIframeLoadFailure(title, url);
};
// 제목과 새 탭 링크 설정
titleElement.textContent = title;
newTabLink.href = url;
// 실제 URL로 로딩 시작
iframe.src = url;
// 팝업과 오버레이 표시
overlay.style.display = 'block';
popup.style.display = 'block';
}

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
</th:block>
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<div class="container">
<header class="major">
<h2 th:text="${srcPost.title}">소개글 제목</h2>
<p>
최종 수정일: <span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.modifyTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')}"></span>
</p>
</header>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<article>
<div id="editor"></div>
</article>
</div>
</section>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script>
// DOM 로드 완료 후 Quill 에디터를 읽기 전용(false)으로 초기화
document.addEventListener('DOMContentLoaded', function() {
initEditor(false);
});
</script>
</th:block>
</html>

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<head>
<title>Bookmarks</title>
</head>
<th:block layout:fragment="content">
<section class="wrapper style2">
<div class="container">
<header class="major">
<h2>Bookmarks</h2>
<p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p>
</header>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<div class="row">
<div class="col-12" th:if="${bookmarksPage.empty}">
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
</div>
<div class="col-4 col-6-medium col-12-small" th:each="bookmark : ${bookmarksPage.content}">
<section class="box feature">
<a href="javascript:void(0);"
th:data-url="${bookmark.url}"
th:data-title="${bookmark.title}"
onclick="showBookmarkOptions(this)" class="image featured">
<img th:src="${bookmark.thumbnailUrl ?: '/images/pic01.jpg'}" alt="Thumbnail" />
</a>
<div class="inner">
<header>
<h3 th:text="${bookmark.title}">북마크 제목</h3>
<p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}" style="font-style: italic; color: #007bff;"></p>
</header>
<p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p>
<div class="bookmark-controls" style="margin-top: 1em; padding-top: 1em; border-top: 1px solid #eee;">
<div class="vote-controls" th:data-bookmark-id="${bookmark.id}" style="text-align: center; margin-bottom: 1em;">
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'like')">
👍 (<span class="like-count" th:text="${bookmark.voteCount}">0</span>)
</button>
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'unlike')" style="margin-left: 0.5em;">
👎 (<span class="unlike-count" th:text="${bookmark.unlikeCount}">0</span>)
</button>
</div>
<a href="javascript:void(0);" th:onclick="toggleCommentSection('[[${bookmark.id}]]')" class="button small fit">댓글 보기</a>
<div th:id="'comment-section-' + ${bookmark.id}" class="comment-section" style="display: none; margin-top: 1em;">
<div th:id="'comments-list-' + ${bookmark.id}" class="comments-list"></div>
<textarea th:id="'comment-input-' + ${bookmark.id}" placeholder="댓글을 입력하세요..." style="margin-top: 1em;"></textarea>
<button class="button small" th:onclick="submitBookmarkComment('[[${bookmark.id}]]')">등록</button>
</div>
</div>
<footer style="font-size: 0.8em; color: #888; text-align: right; margin-top: 1em;">
by <span th:text="${bookmark.userId}"></span>
</footer>
</div>
</section>
</div>
</div>
<nav th:if="${bookmarksPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em;">
<ul class="pagination">
<li th:classappend="${bookmarksPage.first} ? 'disabled'">
<a th:href="@{/bookmarks(page=${bookmarksPage.number - 1})}" class="button alt small">Prev</a>
</li>
<li th:each="pageNum : ${#numbers.sequence(0, bookmarksPage.totalPages - 1)}">
<a th:href="@{/bookmarks(page=${pageNum})}"
th:text="${pageNum + 1}"
th:class="${pageNum == bookmarksPage.number} ? 'button small' : 'button alt small'"></a>
</li>
<li th:classappend="${bookmarksPage.last} ? 'disabled'">
<a th:href="@{/bookmarks(page=${bookmarksPage.number + 1})}" class="button alt small">Next</a>
</li>
</ul>
</nav>
</div>
</section>
</th:block>
</html>

View File

@ -7,11 +7,7 @@
>
<th:block layout:fragment="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
<body>
@ -110,6 +106,11 @@
</div>
</div>
</div>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
</body>
</html>

View File

@ -1,7 +1,10 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}">
<head>
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
</head>
<body>
<th:block layout:fragment="content" id="content">

View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
<style>
.message-list { list-style: none; padding-left: 0; }
.message-item { border: 1px solid #ddd; border-radius: 5px; margin-bottom: 1em; }
.message-header { padding: 10px 15px; background: #f7f7f7; cursor: pointer; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; }
.message-item.unread .message-header { font-weight: bold; background: #fffbe5; border-left: 3px solid #FFA500; }
.message-content { padding: 15px; display: none; border-top: 1px solid #ddd; }
.message-content.active { display: block; }
.reply-form { margin-top: 1em; }
.message-title { flex-grow: 1; }
</style>
</th:block>
<th:block layout:fragment="content">
<section class="wrapper style1">
<div class="container">
<header class="major">
<h2 th:text="${pageTitle}"></h2>
</header>
<div class="box">
<ul class="message-list">
<li th:if="${#lists.isEmpty(messages)}">받은 쪽지가 없습니다.</li>
<li th:each="msg : ${messages}" class="message-item" th:classappend="${!msg.isRead} ? 'unread'" th:data-message-id="${msg.id}">
<div class="message-header" onclick="toggleMessage(this)">
<div class="message-title">
<span th:text="${msg.title}"></span>
<small style="margin-left: 1em; color: #777;" th:text="'보낸 사람: ' + ${msg.senderId}"></small>
</div>
<small th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(msg.timestamp).atZone(T(java.time.ZoneId).systemDefault()), 'yyyy-MM-dd HH:mm')}"></small>
</div>
<div class="message-content">
<p style="white-space: pre-wrap;" th:text="${msg.content}"></p>
<hr/>
<div class="reply-form">
<h4>답장 보내기</h4>
<form onsubmit="sendMessage(event, this)">
<input type="hidden" name="receiverId" th:value="${msg.senderId}" />
<div class="row gtr-50">
<div class="col-12">
<input type="text" name="title" placeholder="제목" th:value="'RE: ' + ${msg.title}" required />
</div>
<div class="col-12">
<textarea name="content" placeholder="내용" rows="4" required></textarea>
</div>
<div class="col-12">
<button type="submit" class="button primary">답장 전송</button>
</div>
</div>
</form>
</div>
</div>
</li>
</ul>
</div>
</div>
</section>
<script th:inline="javascript">
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
async function toggleMessage(headerElement) {
const messageItem = headerElement.closest('.message-item');
const content = messageItem.querySelector('.message-content');
const messageId = messageItem.dataset.messageId;
const isOpening = !content.classList.contains('active');
// 모든 열린 쪽지 닫기
document.querySelectorAll('.message-content.active').forEach(c => {
if(c !== content) c.classList.remove('active');
});
content.classList.toggle('active');
if (isOpening && messageItem.classList.contains('unread')) {
const response = await fetch(`/messages/${messageId}/read`, {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
});
if (response.ok) {
messageItem.classList.remove('unread');
// 헤더의 아이콘도 업데이트 할 수 있지만, 페이지 새로고침 전까지는 유지됩니다.
}
}
}
async function sendMessage(event, form) {
event.preventDefault();
const formData = new FormData(form);
const data = {
receiverId: formData.get('receiverId'),
title: formData.get('title'),
content: formData.get('content')
};
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = '전송 중...';
const response = await fetch('/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify(data)
});
if(response.ok) {
alert('답장을 성공적으로 보냈습니다.');
form.querySelector('textarea').value = ''; // 내용만 초기화
toggleMessage(form.closest('.message-item').querySelector('.message-header')); // 답장 후 창 닫기
} else {
alert('답장 보내기에 실패했습니다.');
}
submitButton.disabled = false;
submitButton.textContent = '답장 전송';
}
</script>
</th:block>
</html>

View File

@ -15,6 +15,49 @@
.user-list li, .post-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
.user-list li:last-child, .post-list li:last-child { border-bottom: none; }
.button.small { margin-left: 0.5em; }
.custom-radio {
display: none; /* 기본 라디오 버튼 숨기기 */
}
.custom-label {
position: relative;
padding-left: 25px; /* 라벨 왼쪽에 가짜 버튼을 위한 공간 확보 */
cursor: pointer;
line-height: 20px;
display: inline-block;
color: #555; /* 라벨 텍스트 색상 */
}
/* 가짜 라디오 버튼 (원) 만들기 */
.custom-label::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-radius: 50%; /* 원 모양 */
background: #fff;
}
/* 선택되었을 때 가짜 라디오 버튼 스타일 변경 */
.custom-radio:checked + .custom-label::before {
border-color: #FFA500; /* 테두리 색상 변경 (사이트의 포인트 색상) */
background: #FFA500; /* 배경 색상 채우기 */
}
/* 선택되었을 때 원 안에 작은 점 추가 */
.custom-radio:checked + .custom-label::after {
content: '';
position: absolute;
left: 6px;
top: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
</style>
</th:block>
@ -31,10 +74,15 @@
<div class="tab-link" onclick="openTab(event, 'myPosts')">내가 쓴 글</div>
<div class="tab-link" onclick="openTab(event, 'myComments')">내가 쓴 댓글</div>
<div class="tab-link" onclick="openTab(event, 'myRanks')">내 게임 랭킹</div>
<div class="tab-link" onclick="openTab(event, 'myMessages')">쪽지함</div>
<div class="tab-link" onclick="openTab(event, 'myBookmarks')">저장한 페이지</div>
<th:block sec:authorize="hasRole('ADMIN')">
<div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div>
<div class="tab-link" onclick="openTab(event, 'postManagement')">게시물 관리</div>
<div class="tab-link" onclick="openTab(event, 'bannerManagement')">배너 관리</div>
<div class="tab-link" onclick="openTab(event, 'aboutManagement')">사이트 소개 관리</div>
</th:block>
</div>
@ -78,7 +126,57 @@
</ul>
</div>
</div>
<div id="myBookmarks" class="tab-content">
<div class="box">
<h4>새 페이지 저장하기</h4>
<div id="bookmark-form">
<input type="url" id="bookmark-url-input" placeholder="저장할 페이지 URL을 입력하세요" style="margin-bottom: 1em;">
<div id="og-preview" style="display:none; border: 1px solid #ddd; padding: 1em; margin-bottom: 1em; border-radius: 5px;">
<img id="og-image" src="" style="max-width: 150px; float: left; margin-right: 1em;">
<h5 id="og-title"></h5>
<p id="og-description" style="font-size: 0.9em; color: #555;"></p>
</div>
<textarea id="bookmark-comment-input" placeholder="이 페이지에 대한 나의 생각 (선택)" rows="3"></textarea>
<div id="visibility-selector" style="margin-top: 1em; display: flex; align-items: center; flex-wrap: wrap;">
<strong style="margin-right: 1.5em;">공개 범위:</strong>
<div style="display: flex; align-items: center; margin-right: 1.5em;">
<input type="radio" name="visibility" id="visibility-private" value="PRIVATE" class="custom-radio" checked>
<label for="visibility-private" class="custom-label">비공개</label>
</div>
<div style="display: flex; align-items: center; margin-right: 1.5em;">
<input type="radio" name="visibility" id="visibility-members" value="MEMBERS" class="custom-radio">
<label for="visibility-members" class="custom-label">회원 공개</label>
</div>
<div style="display: flex; align-items: center;">
<input type="radio" name="visibility" id="visibility-public" value="PUBLIC" class="custom-radio">
<label for="visibility-public" class="custom-label">전체 공개</label>
</div>
</div>
<button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button>
</div>
</div>
<div class="box" style="margin-top: 2em;">
<h4>저장된 목록</h4>
<div id="bookmarks-list" class="row">
<div class="col-4 col-12-medium">
<section class="box feature">
<a href="#" class="image featured"><img src="/images/pic01.jpg" alt="" /></a>
<div class="inner">
<header>
<h2>카드 제목</h2>
<p>사용자 코멘트가 여기에 들어갑니다.</p>
</header>
<p style="font-size: 0.8em; color: #888;">원본 페이지 설명...</p>
</div>
</section>
</div>
</div>
</div>
</div>
<div id="myRanks" class="tab-content">
<div class="box">
<ul class="post-list">
@ -104,7 +202,27 @@
</ul>
</div>
</div>
<div id="myMessages" class="tab-content">
<div class="box">
<ul class="post-list">
<li th:if="${#lists.isEmpty(myMessages)}">주고받은 쪽지가 없습니다.</li>
<li th:each="msg : ${myMessages}">
<div style="display: flex; align-items: center; gap: 1em;">
<span th:if="${msg.senderId == user.user_id}" class="tag-item" style="background: #e0f7fa;">보냄</span>
<span th:if="${msg.receiverId == user.user_id}" class="tag-item" style="background: #fffbe5;">받음</span>
<div style="min-width: 150px;">
<strong th:if="${msg.senderId == user.user_id}" th:text="'To: ' + ${msg.receiverId}"></strong>
<strong th:if="${msg.receiverId == user.user_id}" th:text="'From: ' + ${msg.senderId}"></strong>
</div>
<a th:href="@{/messages}" th:text="${msg.title}">쪽지 제목</a>
</div>
<span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(msg.timestamp).atZone(T(java.time.ZoneId).systemDefault()), 'yyyy-MM-dd HH:mm')}"></span>
</li>
</ul>
</div>
</div>
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>권한 요청</h4>
@ -154,7 +272,8 @@
<ul class="post-list">
<li th:each="image : ${allImages}" th:id="'image-row-' + ${image.id}">
<div style="display: flex; align-items: center; gap: 1em;">
<img th:src="@{'/api/images/' + ${image.fileName}}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/>
<!-- <img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />-->
<img th:src="${apiBaseUrl + '/api/images/' + image.fileName + '?type=thumbnail'}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/>
<div>
<strong th:text="${image.fileName}"></strong><br>
<span th:if="${image.isBannerCandidate}" style="color: #2a9d8f; font-weight: bold;">(배너로 사용 중)</span>
@ -170,6 +289,27 @@
</ul>
</div>
</div>
<div id="aboutManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>사이트 소개글 관리</h4>
<p>
'사이트 소개' 페이지에 표시될 내용입니다. 글을 수정하면 이전 버전은 히스토리로 여기에 남게 됩니다.
</p>
<th:block th:with="latestAbout=${!#lists.isEmpty(aboutPostHistory) ? aboutPostHistory[0] : null}">
<a th:if="${latestAbout != null}" th:href="@{/blog/edit/{postId}(postId=${latestAbout.id})}" class="button primary">최신 소개글 수정</a>
<a th:if="${latestAbout == null}" th:href="@{/blog/edit(type='ABOUT_SITE')}" class="button">새 소개글 작성</a>
</th:block>
<h5 style="margin-top: 2em;">수정 히스토리</h5>
<ul class="post-list">
<li th:each="post : ${aboutPostHistory}">
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">수정된 버전 제목</a>
<span th:text="|수정일: ${#dates.format(post.modifyTime, 'yyyy-MM-dd HH:mm')}|"></span>
</li>
<li th:if="${#lists.isEmpty(aboutPostHistory)}">작성된 소개글이 없습니다.</li>
</ul>
</div>
</div>
</div>
</section>
@ -315,6 +455,83 @@
alert('작업 중 오류가 발생했습니다.');
});
}
document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('bookmark-url-input');
const preview = document.getElementById('og-preview');
let ogData = {}; // OG 파싱 결과를 저장할 변수
// URL 입력 필드에서 포커스가 벗어났을 때(onblur) OG 정보 파싱 API 호출
urlInput.addEventListener('blur', async function() {
const url = this.value.trim();
if (!url) return;
try {
const response = await fetch(`/api/og/parse?url=${encodeURIComponent(url)}`);
if (!response.ok) throw new Error('파싱 실패');
ogData = await response.json();
// 미리보기 UI 업데이트
document.getElementById('og-title').textContent = ogData.title || '제목 없음';
document.getElementById('og-description').textContent = ogData.description || '';
const ogImage = document.getElementById('og-image');
if (ogData.thumbnailUrl) {
ogImage.src = ogData.thumbnailUrl;
ogImage.style.display = 'block';
} else {
ogImage.style.display = 'none';
}
preview.style.display = 'block';
} catch (error) {
console.error(error);
preview.style.display = 'none';
alert('페이지 정보를 가져오는 데 실패했습니다. URL을 확인해주세요.');
}
});
// 저장 버튼 클릭 이벤트
document.getElementById('save-bookmark-btn').addEventListener('click', async function() {
const comment = document.getElementById('bookmark-comment-input').value.trim();
// [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가
const visibility = document.querySelector('input[name="visibility"]:checked').value;
const bookmarkData = {
url: urlInput.value.trim(),
// title, description 등은 ogData에서 가져오는 것은 그대로 유지
title: ogData.title,
description: ogData.description,
thumbnailUrl: ogData.thumbnailUrl,
userComment: comment,
// [수정] bookmarkData 객체에 visibility 프로퍼티 추가
visibility: visibility
};
if (!bookmarkData.url) {
alert('URL을 입력해주세요.');
return;
}
// 서버에 북마크 저장 요청 (이하 코드는 동일)
const response = await fetch('/user/bookmarks/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify(bookmarkData)
});
if (response.ok) {
alert('페이지가 저장되었습니다.');
location.reload();
} else {
alert('저장에 실패했습니다.');
}
});
});
</script>
</th:block>
</html>

View File

@ -5,14 +5,7 @@
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block>
<th:block layout:fragment="content" id="content">
@ -108,5 +101,14 @@
</div>
</div>
</section>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block>
</html>

View File

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">>
<th:block th:fragment="footer">
<script th:inline="javascript">
/*<![CDATA[*/
@ -9,60 +10,66 @@
/*]]>*/
</script>
<div id="footer">
<div class="container">
<div class="row">
<section class="col-3 col-6-narrower col-12-mobilep">
<h3 id="ranking-title">Rank of Views</h3>
<ul class="rank_of_view" >
</ul>
</section>
<section class="col-3 col-6-narrower col-12-mobilep">
<h3>Recent of Posts</h3>
<ul class="recent_posts">
<div id="footer">
<div class="container">
<div class="row">
<section class="col-3 col-6-narrower col-12-mobilep">
<h3 id="ranking-title">Rank of Views</h3>
<ul class="rank_of_view" >
</ul>
</section>
<section class="col-3 col-6-narrower col-12-mobilep">
<h3>Recent of Posts</h3>
<ul class="recent_posts">
</ul>
</section>
<section class="col-6 col-12-narrower">
<h3>SEND TO ME(TELEGRAM BOT)</h3>
<div id="tlg_form" >
<div class="row gtr-50">
<div class="col-6 col-12-mobilep">
</ul>
</section>
<section class="col-6 col-12-narrower">
<h3>SEND TO ME(TELEGRAM BOT)</h3>
<div id="tlg_form" >
<div class="row gtr-50">
<div class="col-6 col-12-mobilep">
<div sec:authorize="isAuthenticated()">
<input type="text" name="name" id="name" placeholder="Name" th:value="${#authentication.principal.username}" readonly />
</div>
<div sec:authorize="isAnonymous()">
<input type="text" name="name" id="name" placeholder="Name" />
</div>
<div class="col-6 col-12-mobilep">
<input type="email" name="email" id="email" placeholder="Email" />
</div>
<div class="col-12">
<textarea name="message" id="message" placeholder="Message" rows="5"></textarea>
</div>
<div class="col-12">
<ul class="actions">
<li><input type="submit" class="button alt" value="Send Message" onclick="callSendTlg()" /></li>
</ul>
</div>
</div>
<div class="col-6 col-12-mobilep">
<input type="email" name="email" id="email" placeholder="Email" />
</div>
<div class="col-12">
<textarea name="message" id="message" placeholder="Message" rows="5"></textarea>
</div>
<div class="col-12">
<ul class="actions">
<li><input type="submit" class="button alt" value="Send Message" onclick="callSendTlg()" /></li>
</ul>
</div>
</div>
</section>
</div>
</div>
<!-- Icons -->
<ul class="icons">
<li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
<li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="#" class="icon brands fa-github"><span class="label">GitHub</span></a></li>
<li><a href="#" class="icon brands fa-linkedin-in"><span class="label">LinkedIn</span></a></li>
<li><a href="#" class="icon brands fa-google-plus-g"><span class="label">Google+</span></a></li>
</ul>
<!-- Copyright -->
<div class="copyright">
<ul class="menu">
<li>&copy;lunaticbum All rights reserved</li><li>Origin Design from:<a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div>
</section>
</div>
</div>
<!-- Icons -->
<ul class="icons">
<li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
<li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="#" class="icon brands fa-github"><span class="label">GitHub</span></a></li>
<li><a href="#" class="icon brands fa-linkedin-in"><span class="label">LinkedIn</span></a></li>
<li><a href="#" class="icon brands fa-google-plus-g"><span class="label">Google+</span></a></li>
</ul>
<!-- Copyright -->
<div class="copyright">
<ul class="menu">
<li>&copy;lunaticbum All rights reserved</li><li>Origin Design from:<a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div>
</div>
<script type="text/javascript">

View File

@ -15,19 +15,21 @@
<ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li>
<!-- <li id="menu_sec"><a href="left-sidebar">Left Sidebar</a></li>-->
<!-- <li id="menu_thr"><a href="right-sidebar">Right Sidebar</a></li>-->
<!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>-->
<li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>
<li id="menu_drop">
<a href="#">Game</a>
<ul>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li>
</ul>
</li>
<li id="menu_drop">
<a href="#">About</a>
<ul>
<li><a href="javascript:gotoWhere()">bums's where</a></li>
<li><a href="#">Magna phasellus</a></li>
<li><a href="#">Etiam sed tempus</a></li>
<li><a href="javascript:gotoBUMSpace()">BUM'sPase</a></li>
<li>
<a href="#">Submenu</a>
<ul>
@ -46,9 +48,12 @@
</ul>
</li>
<th:block sec:authorize="isAuthenticated()">
<li><a th:href="@{/user/info}">내 정보</a></li>
</th:block>
<li sec:authorize="isAuthenticated()">
<a href="/user/info" th:text="${#authentication.principal.username}">사용자ID</a>
<a href="/messages" id="message-icon" style="display: none; color: #FFA500; margin-left: 5px;" title="새 쪽지">
쪽지함<i class="icon solid fa-envelope"></i>
</a>
</li>
<th:block sec:authorize="!isAuthenticated()">
<li id="menu_login">
<a class="open-login-popup" to="#loginPopup">Login</a>

View File

@ -73,7 +73,23 @@
</div>
</div>
</div>
<div id="iframe-viewer-popup" class="pop_layer" style="width: 90%; height: 90%; max-width: 1400px;">
<div class="pop_container" style="height: 100%; display: flex; flex-direction: column;">
<div class="pop_header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; border-bottom: 1px solid #eee; background: #f8f8f8;">
<h4 id="iframe-viewer-title" style="margin: 0; font-size: 1em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></h4>
<a href="#" class="btn_layerClose" style="font-size: 1.5em;" onclick="closePopup()">×</a>
</div>
<div class="pop_conts" style="flex-grow: 1; padding: 0;">
<iframe id="bookmark-iframe" src="" style="width: 100%; height: 100%; border: none;">
이 브라우저는 iframe을 지원하지 않습니다.
</iframe>
</div>
<div class="pop_footer" style="padding: 10px 20px; border-top: 1px solid #eee; background: #f8f8f8; text-align: center; font-size: 0.9em;">
콘텐츠가 표시되지 않나요?
<a id="iframe-open-new-tab-link" href="#" target="_blank" class="button small alt" style="margin-left: 1em; vertical-align: middle;" onclick="closePopup()">새 탭에서 열기</a>
</div>
</div>
</div>
<div id="unified-game-success-modal" class="pop_layer">
<div class="pop_container"> <div class="pop_conts"> <h2 id="ugsm-title">🎉 성공! 🎉</h2>
<p id="ugsm-message">여기에 성공 메시지가 표시됩니다.</p>