...
This commit is contained in:
parent
17aea8b43b
commit
5e0db4ff03
104
build.gradle.kts
104
build.gradle.kts
@ -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 {
|
||||
|
||||
@ -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
|
||||
// }
|
||||
//}
|
||||
117
src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt
Normal file
117
src/main/kotlin/kr/lunaticbum/back/lun/ApiIntegrationTest.kt
Normal 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테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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())})"))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
// }
|
||||
//}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
70
src/main/kotlin/kr/lunaticbum/back/lun/model/Message.kt
Normal file
70
src/main/kotlin/kr/lunaticbum/back/lun/model/Message.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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}"
|
||||
}
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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';
|
||||
}
|
||||
38
src/main/resources/templates/content/about_view.html
Normal file
38
src/main/resources/templates/content/about_view.html
Normal 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>
|
||||
83
src/main/resources/templates/content/bookmarks.html
Normal file
83
src/main/resources/templates/content/bookmarks.html
Normal 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>
|
||||
@ -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>
|
||||
@ -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">
|
||||
|
||||
124
src/main/resources/templates/content/messages/inbox.html
Normal file
124
src/main/resources/templates/content/messages/inbox.html
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>©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>©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">
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user