....
This commit is contained in:
parent
5e0db4ff03
commit
4b652c4df5
@ -1,117 +1,117 @@
|
|||||||
//import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
//import okhttp3.*
|
import okhttp3.*
|
||||||
//import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
//import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
//import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
//import java.io.File
|
import java.io.File
|
||||||
//import java.io.IOException
|
import java.io.IOException
|
||||||
//
|
|
||||||
//// Gson 파싱을 위한 데이터 클래스
|
// Gson 파싱을 위한 데이터 클래스
|
||||||
//data class LoginRequest(val userId: String, val userPw: String)
|
data class LoginRequest(val userId: String, val userPw: String)
|
||||||
//data class LoginResponse(val token: String?)
|
data class LoginResponse(val token: String?)
|
||||||
//
|
|
||||||
///**
|
/**
|
||||||
// * API 통합 테스트를 실행하는 메인 함수입니다.
|
* API 통합 테스트를 실행하는 메인 함수입니다.
|
||||||
// * IDE에서 직접 실행(▶)할 수 있습니다.
|
* IDE에서 직접 실행(▶)할 수 있습니다.
|
||||||
// */
|
*/
|
||||||
//fun main() {
|
fun main() {
|
||||||
// val tester = ApiIntegrationTest()
|
val tester = ApiIntegrationTest()
|
||||||
// tester.runBookmarkTest()
|
tester.runBookmarkTest()
|
||||||
//}
|
}
|
||||||
//
|
|
||||||
//class ApiIntegrationTest {
|
class ApiIntegrationTest {
|
||||||
//
|
|
||||||
// private val client = OkHttpClient()
|
private val client = OkHttpClient()
|
||||||
// private val gson = Gson()
|
private val gson = Gson()
|
||||||
// private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
//
|
|
||||||
// // --- 테스트 환경 설정 ---
|
// --- 테스트 환경 설정 ---
|
||||||
// private val baseUrl = "http://localhost:443"
|
private val baseUrl = "http://localhost:443"
|
||||||
// private val testUserId = "lunaticbum"
|
private val testUserId = "lunaticbum"
|
||||||
// private val testUserPw = "VioPup*383"
|
private val testUserPw = "VioPup*383"
|
||||||
// private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일
|
private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일
|
||||||
//
|
|
||||||
// /**
|
/**
|
||||||
// * 로그인 API를 호출하여 JWT 토큰을 반환합니다.
|
* 로그인 API를 호출하여 JWT 토큰을 반환합니다.
|
||||||
// */
|
*/
|
||||||
// private fun loginAndGetToken(): String? {
|
private fun loginAndGetToken(): String? {
|
||||||
// println("1. 로그인을 시도합니다...")
|
println("1. 로그인을 시도합니다...")
|
||||||
//
|
|
||||||
// val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw)
|
val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw)
|
||||||
// val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType)
|
val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType)
|
||||||
//
|
|
||||||
// val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
// .url("$baseUrl/api/auth/login")
|
.url("$baseUrl/api/auth/login")
|
||||||
// .post(requestBody)
|
.post(requestBody)
|
||||||
// .build()
|
.build()
|
||||||
//
|
|
||||||
// try {
|
try {
|
||||||
// client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
// if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
// println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}")
|
println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}")
|
||||||
// return null
|
return null
|
||||||
// }
|
}
|
||||||
// val responseBody = response.body?.string()
|
val responseBody = response.body?.string()
|
||||||
// val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java)
|
val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java)
|
||||||
// println("✅ 로그인 성공!")
|
println("✅ 로그인 성공!")
|
||||||
// return loginResponse.token
|
return loginResponse.token
|
||||||
// }
|
}
|
||||||
// } catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// println("❌ 로그인 중 오류 발생: ${e.message}")
|
println("❌ 로그인 중 오류 발생: ${e.message}")
|
||||||
// return null
|
return null
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// /**
|
/**
|
||||||
// * 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다.
|
* 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다.
|
||||||
// */
|
*/
|
||||||
// private fun saveBookmarkWithToken(token: String) {
|
private fun saveBookmarkWithToken(token: String) {
|
||||||
// println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}")
|
println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}")
|
||||||
//
|
|
||||||
// if (!imageToUpload.exists()) {
|
if (!imageToUpload.exists()) {
|
||||||
// println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.")
|
println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.")
|
||||||
// return
|
return
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // Multipart 요청 본문 생성
|
// Multipart 요청 본문 생성
|
||||||
// val requestBody = MultipartBody.Builder()
|
val requestBody = MultipartBody.Builder()
|
||||||
// .setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
// .addFormDataPart(
|
.addFormDataPart(
|
||||||
// "bookmarkData",
|
"bookmarkData",
|
||||||
// """{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}"""
|
"""{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}"""
|
||||||
// )
|
)
|
||||||
// .addFormDataPart(
|
.addFormDataPart(
|
||||||
// "imageFile",
|
"imageFile",
|
||||||
// imageToUpload.name,
|
imageToUpload.name,
|
||||||
// imageToUpload.asRequestBody("image/jpeg".toMediaType())
|
imageToUpload.asRequestBody("image/jpeg".toMediaType())
|
||||||
// )
|
)
|
||||||
// .build()
|
.build()
|
||||||
//
|
|
||||||
// val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
// .url("$baseUrl/api/bookmarks/with-image")
|
.url("$baseUrl/api/bookmarks/with-image")
|
||||||
// .header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가
|
.header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가
|
||||||
// .post(requestBody)
|
.post(requestBody)
|
||||||
// .build()
|
.build()
|
||||||
//
|
|
||||||
// try {
|
try {
|
||||||
// client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
// println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}")
|
println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}")
|
||||||
// println("응답 내용: ${response.body?.string()}")
|
println("응답 내용: ${response.body?.string()}")
|
||||||
// }
|
}
|
||||||
// } catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// println("❌ 북마크 저장 중 오류 발생: ${e.message}")
|
println("❌ 북마크 저장 중 오류 발생: ${e.message}")
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// /**
|
/**
|
||||||
// * 전체 테스트 시나리오를 실행합니다.
|
* 전체 테스트 시나리오를 실행합니다.
|
||||||
// */
|
*/
|
||||||
// fun runBookmarkTest() {
|
fun runBookmarkTest() {
|
||||||
// val token = loginAndGetToken()
|
val token = loginAndGetToken()
|
||||||
// if (token != null) {
|
if (token != null) {
|
||||||
// saveBookmarkWithToken(token)
|
saveBookmarkWithToken(token)
|
||||||
// } else {
|
} else {
|
||||||
// println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
|
println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
@ -37,57 +37,14 @@ class AppConfig : WebMvcConfigurer {
|
|||||||
registry.addInterceptor(authInterceptor())
|
registry.addInterceptor(authInterceptor())
|
||||||
.addPathPatterns(
|
.addPathPatterns(
|
||||||
"/home.bs",
|
"/home.bs",
|
||||||
"/bums/where.bs" ,
|
"/bums/where.bs",
|
||||||
|
"/user/info", // "내 정보" 페이지도 추가하면 좋습니다.
|
||||||
"/tlg/repotToMe.bjx",
|
"/tlg/repotToMe.bjx",
|
||||||
"/user/login.bs", "/user/signup.bs","/user/login.bjx",
|
"/user/login.bs", "/user/signup.bs", "/user/login.bjx",
|
||||||
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx"
|
"/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx"
|
||||||
)
|
)
|
||||||
// super.addInterceptors(registry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// @Bean
|
|
||||||
// fun qdrantClient(): QdrantClient {
|
|
||||||
// return QdrantClient("https://ollama.lunaticbum.kr:6334")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// @Bean
|
|
||||||
// fun chatClient(): OllamaApi {
|
|
||||||
// return OllamaApi("https://lama.lunaticbum.kr")
|
|
||||||
//
|
|
||||||
//// .withDefaultOptions(
|
|
||||||
//// OllamaOptions.create()
|
|
||||||
//// .withModel("phi4:14b")
|
|
||||||
//// .withNumThread(5)
|
|
||||||
//// .withSeed(5)
|
|
||||||
//// .withTemperature(0.9f))
|
|
||||||
// }
|
|
||||||
// @Bean
|
|
||||||
// fun getProperty() : Map<String,String>{
|
|
||||||
// println("telegramBotKey >>>> $telegramBotKey")
|
|
||||||
// println("telegramMyId >>>> $telegramMyId")
|
|
||||||
// println("weatherApiKey >>>> $weatherApiKey")
|
|
||||||
//
|
|
||||||
// return hashMapOf(Pair("telegramMyId",telegramMyId))
|
|
||||||
// }
|
|
||||||
// @Bean
|
|
||||||
// fun memberRepository(): MemberRepository {
|
|
||||||
// return MemoryMemberRepository()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @Bean
|
|
||||||
// fun discountPolicy(): DiscountPolicy {
|
|
||||||
// return RateDiscountPolicy()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @Bean
|
|
||||||
// fun memberService(): MemberService {
|
|
||||||
// return MemberServiceImpl(memberRepository())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @Bean
|
|
||||||
// fun orderService(): OrderService {
|
|
||||||
// return OrderServiceImpl(memberRepository(), discountPolicy())
|
|
||||||
// }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,62 +0,0 @@
|
|||||||
//package kr.lunaticbum.back.lun.configs
|
|
||||||
//
|
|
||||||
//import lombok.RequiredArgsConstructor
|
|
||||||
//import lombok.extern.slf4j.Slf4j
|
|
||||||
//import org.springframework.batch.core.Job
|
|
||||||
//import org.springframework.batch.core.Step
|
|
||||||
//import org.springframework.batch.core.StepContribution
|
|
||||||
//import org.springframework.batch.core.configuration.DuplicateJobException
|
|
||||||
//import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing
|
|
||||||
//import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration
|
|
||||||
//import org.springframework.batch.core.job.builder.JobBuilder
|
|
||||||
//import org.springframework.batch.core.repository.JobRepository
|
|
||||||
//import org.springframework.batch.core.scope.context.ChunkContext
|
|
||||||
//import org.springframework.batch.core.step.builder.StepBuilder
|
|
||||||
//import org.springframework.batch.core.step.tasklet.Tasklet
|
|
||||||
//import org.springframework.batch.repeat.RepeatStatus
|
|
||||||
//import org.springframework.context.annotation.Bean
|
|
||||||
//import org.springframework.context.annotation.Configuration
|
|
||||||
//import org.springframework.transaction.PlatformTransactionManager
|
|
||||||
//import org.springframework.web.reactive.function.client.WebClient
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//@Configuration
|
|
||||||
//@RequiredArgsConstructor
|
|
||||||
//class BatchConfig : DefaultBatchConfiguration() {
|
|
||||||
//
|
|
||||||
// @Bean
|
|
||||||
// @Throws(DuplicateJobException::class)
|
|
||||||
// fun testJob(jobRepository: JobRepository, transactionManager: PlatformTransactionManager?): Job {
|
|
||||||
// val job: Job = JobBuilder("testJob", jobRepository!!)
|
|
||||||
// .start(testStep(jobRepository, transactionManager))
|
|
||||||
// .build()
|
|
||||||
// return job
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// fun testStep(jobRepository: JobRepository?, transactionManager: PlatformTransactionManager?): Step {
|
|
||||||
// val step: Step = StepBuilder("testStep", jobRepository!!)
|
|
||||||
// .tasklet(testTasklet(), transactionManager!!)
|
|
||||||
// .build()
|
|
||||||
// return step
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// fun testTasklet(): Tasklet {
|
|
||||||
// return (Tasklet { contribution: StepContribution?, chunkContext: ChunkContext? ->
|
|
||||||
// println("***** hello batch! *****")
|
|
||||||
// val client0 = WebClient.create()
|
|
||||||
// val result = client0.get()
|
|
||||||
// .uri("http://api.weatherapi.com/v1/current.json?key=de574a260b1f474d99955729241909&q=seoul&aqi=no")
|
|
||||||
// .retrieve()
|
|
||||||
// .bodyToMono(String::class.java).block() ?: "FAIL"
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// val client = WebClient.create()
|
|
||||||
// client.get()
|
|
||||||
// .uri("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w/sendMessage?chat_id=71476436&text=${result}")
|
|
||||||
// .retrieve()
|
|
||||||
// .bodyToMono(String::class.java).block() ?: "FAIL"
|
|
||||||
//
|
|
||||||
// RepeatStatus.FINISHED
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@ -8,9 +8,11 @@ import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
|
|||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
|
||||||
import kr.lunaticbum.back.lun.model.UserManager
|
import kr.lunaticbum.back.lun.model.UserManager
|
||||||
|
import kr.lunaticbum.back.lun.utils.JwtUtil
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.lang.Nullable
|
import org.springframework.lang.Nullable
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.security.web.authentication.RememberMeServices
|
import org.springframework.security.web.authentication.RememberMeServices
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@ -18,11 +20,14 @@ import org.springframework.web.servlet.HandlerInterceptor
|
|||||||
import org.springframework.web.servlet.ModelAndView
|
import org.springframework.web.servlet.ModelAndView
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class BumsInterceptor : HandlerInterceptor {
|
class BumsInterceptor(
|
||||||
|
|
||||||
|
) : HandlerInterceptor {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var globalEvv : GlobalEnvironment
|
lateinit var globalEvv : GlobalEnvironment
|
||||||
|
@Autowired
|
||||||
|
lateinit var jwtUtil: JwtUtil
|
||||||
val WRITE_PERMISSION_KEY = "PERMISSION"
|
val WRITE_PERMISSION_KEY = "PERMISSION"
|
||||||
|
|
||||||
@Throws(Exception::class)
|
@Throws(Exception::class)
|
||||||
@ -48,28 +53,27 @@ class BumsInterceptor : HandlerInterceptor {
|
|||||||
handler: Any,
|
handler: Any,
|
||||||
@Nullable modelAndView: ModelAndView?
|
@Nullable modelAndView: ModelAndView?
|
||||||
) {
|
) {
|
||||||
|
println("modelAndView modelMap size >>> ${modelAndView?.modelMap?.keys?.size}")
|
||||||
|
// [수정] modelAndView가 null이 아닐 경우에만 로직을 실행하도록 변경합니다.
|
||||||
modelAndView?.modelMap?.put(EncTypeKey, EncType11)
|
if (modelAndView != null && modelAndView.hasView()) {
|
||||||
modelAndView?.modelMap?.put(ApiKeyWordKey,"Def")
|
|
||||||
|
|
||||||
if (modelAndView != null) {
|
|
||||||
modelAndView.modelMap.put(EncTypeKey, EncType11)
|
modelAndView.modelMap.put(EncTypeKey, EncType11)
|
||||||
modelAndView.modelMap.put(ApiKeyWordKey, "Def")
|
modelAndView.modelMap.put(ApiKeyWordKey, "Def")
|
||||||
println("modelMap 내용 추가 완료: ${modelAndView.modelMap}")
|
|
||||||
} else {
|
val authentication = SecurityContextHolder.getContext().authentication
|
||||||
|
val principal = authentication?.principal
|
||||||
|
|
||||||
|
var jwtToken: String? = null
|
||||||
|
if (principal is UserDetails) {
|
||||||
|
jwtToken = jwtUtil.generateToken(principal)
|
||||||
|
}
|
||||||
|
modelAndView.modelMap.put("jwtToken", jwtToken)
|
||||||
|
}else {
|
||||||
|
|
||||||
println("modelAndView가 null이라 모델에 값 추가 불가")
|
println("modelAndView가 null이라 모델에 값 추가 불가")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
super.postHandle(request, response, handler, modelAndView)
|
super.postHandle(request, response, handler, modelAndView)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cookieUpdate(cookie: Cookie?) : Cookie? {
|
|
||||||
cookie?.maxAge = (globalEvv.ACCESS_EXPIRATION / 1000).toInt()
|
|
||||||
cookie?.domain = "lunaticbum.kr"
|
|
||||||
cookie?.secure = true
|
|
||||||
cookie?.path = "/"
|
|
||||||
return cookie
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,71 +1,71 @@
|
|||||||
package kr.lunaticbum.back.lun.configs
|
//package kr.lunaticbum.back.lun.configs
|
||||||
|
//
|
||||||
import io.jsonwebtoken.Jwts
|
//import io.jsonwebtoken.Jwts
|
||||||
import io.jsonwebtoken.SignatureAlgorithm
|
//import io.jsonwebtoken.SignatureAlgorithm
|
||||||
import kr.lunaticbum.back.lun.model.User
|
//import kr.lunaticbum.back.lun.model.User
|
||||||
import lombok.Getter
|
//import lombok.Getter
|
||||||
import lombok.RequiredArgsConstructor
|
//import lombok.RequiredArgsConstructor
|
||||||
import org.springframework.stereotype.Component
|
//import org.springframework.stereotype.Component
|
||||||
import java.security.Key
|
//import java.security.Key
|
||||||
import java.util.*
|
//import java.util.*
|
||||||
import kotlin.collections.HashMap
|
//import kotlin.collections.HashMap
|
||||||
|
//
|
||||||
|
//
|
||||||
@Component
|
//@Component
|
||||||
class JwtGenerator {
|
//class JwtGenerator {
|
||||||
fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String {
|
// fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String {
|
||||||
val now = System.currentTimeMillis()
|
// val now = System.currentTimeMillis()
|
||||||
|
//
|
||||||
return Jwts.builder()
|
// return Jwts.builder()
|
||||||
.setHeader(createHeader())
|
// .setHeader(createHeader())
|
||||||
.setClaims(createClaims(user))
|
// .setClaims(createClaims(user))
|
||||||
.setSubject(user.userId)
|
// .setSubject(user.userId)
|
||||||
.setExpiration(Date(now + ACCESS_EXPIRATION))
|
// .setExpiration(Date(now + ACCESS_EXPIRATION))
|
||||||
.signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
|
// .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
|
||||||
.compact()
|
// .compact()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String {
|
// fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String {
|
||||||
val now = System.currentTimeMillis()
|
// val now = System.currentTimeMillis()
|
||||||
|
//
|
||||||
return Jwts.builder()
|
// return Jwts.builder()
|
||||||
.setHeader(createHeader())
|
// .setHeader(createHeader())
|
||||||
.setClaims(createClaims(user))
|
// .setClaims(createClaims(user))
|
||||||
.setSubject(user.getIdentifier())
|
// .setSubject(user.getIdentifier())
|
||||||
.setExpiration(Date(now + REFRESH_EXPIRATION))
|
// .setExpiration(Date(now + REFRESH_EXPIRATION))
|
||||||
.signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
|
// .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
|
||||||
.compact()
|
// .compact()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
private fun createHeader(): Map<String, Any> {
|
// private fun createHeader(): Map<String, Any> {
|
||||||
val header: MutableMap<String, Any> = HashMap()
|
// val header: MutableMap<String, Any> = HashMap()
|
||||||
header["typ"] = "JWT"
|
// header["typ"] = "JWT"
|
||||||
header["alg"] = "HS256"
|
// header["alg"] = "HS256"
|
||||||
return header
|
// return header
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private fun createClaims(user: User): Map<String, Any?> {
|
// private fun createClaims(user: User): Map<String, Any?> {
|
||||||
val claims: MutableMap<String, Any?> = HashMap()
|
// val claims: MutableMap<String, Any?> = HashMap()
|
||||||
claims["Identifier"] = user.getIdentifier()
|
// claims["Identifier"] = user.getIdentifier()
|
||||||
claims["Role"] = user.getRole()
|
// claims["Role"] = user.getRole()
|
||||||
return claims
|
// return claims
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
@RequiredArgsConstructor
|
//@RequiredArgsConstructor
|
||||||
@Getter
|
//@Getter
|
||||||
enum class TokenStatus {
|
//enum class TokenStatus {
|
||||||
AUTHENTICATED,
|
// AUTHENTICATED,
|
||||||
EXPIRED,
|
// EXPIRED,
|
||||||
INVALID
|
// INVALID
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
@RequiredArgsConstructor
|
//@RequiredArgsConstructor
|
||||||
@Getter
|
//@Getter
|
||||||
enum class JwtRule(val value: String) {
|
//enum class JwtRule(val value: String) {
|
||||||
JWT_ISSUE_HEADER("Set-Cookie"),
|
// JWT_ISSUE_HEADER("Set-Cookie"),
|
||||||
JWT_RESOLVE_HEADER("Cookie"),
|
// JWT_RESOLVE_HEADER("Cookie"),
|
||||||
ACCESS_PREFIX("access"),
|
// ACCESS_PREFIX("access"),
|
||||||
REFRESH_PREFIX("refresh");
|
// REFRESH_PREFIX("refresh");
|
||||||
}
|
//}
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package kr.lunaticbum.back.lun.configs
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories
|
|
||||||
import org.springframework.scheduling.annotation.EnableAsync
|
|
||||||
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableMongoRepositories( basePackages = arrayOf("kr.lunaticbum.back.lun"))
|
|
||||||
@EnableAsync
|
|
||||||
class RootAppContext {
|
|
||||||
// @Bean
|
|
||||||
// fun mongoClient(): MongoClient {
|
|
||||||
// return MongoClient("localhost")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// fun mongoDbFactory(): MongoDbFactory {
|
|
||||||
// return SimpleMongoDbFactory(mongoClient(), "test")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// @Bean
|
|
||||||
// fun mongoTemplate(): MongoTemplate {
|
|
||||||
// return MongoTemplate(mongoDbFactory())
|
|
||||||
// }
|
|
||||||
|
|
||||||
// fun mongoTemplate() :MongoTemplate {
|
|
||||||
// return MongoTemplate()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@ -38,11 +38,19 @@ import jakarta.servlet.FilterChain
|
|||||||
import kr.lunaticbum.back.lun.utils.JwtUtil
|
import kr.lunaticbum.back.lun.utils.JwtUtil
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
|
import org.springframework.security.core.context.SecurityContext
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
|
||||||
|
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
|
||||||
|
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository
|
||||||
|
import org.springframework.security.web.context.SecurityContextRepository
|
||||||
|
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
|
||||||
|
import org.springframework.security.web.util.matcher.NegatedRequestMatcher
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.filter.OncePerRequestFilter
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
import java.security.SignatureException
|
import java.security.SignatureException
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@ -51,11 +59,17 @@ class SecurityConfig(
|
|||||||
private val jwtUtil: JwtUtil,
|
private val jwtUtil: JwtUtil,
|
||||||
private val userManager: UserManager,
|
private val userManager: UserManager,
|
||||||
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
|
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
|
||||||
private val tokenRepository: MongoPersistentTokenRepository
|
private val tokenRepository: MongoPersistentTokenRepository,
|
||||||
|
private val customAccessDeniedHandler: CustomAccessDeniedHandler
|
||||||
) {
|
) {
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var logService: LogService
|
lateinit var logService: LogService
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun securityContextRepository(): SecurityContextRepository {
|
||||||
|
return ApiAndWebSecurityContextRepository()
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun webSecurityCustomizer(): WebSecurityCustomizer {
|
fun webSecurityCustomizer(): WebSecurityCustomizer {
|
||||||
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
|
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
|
||||||
@ -89,18 +103,22 @@ class SecurityConfig(
|
|||||||
@Bean
|
@Bean
|
||||||
@Order(1) // API 보안 설정을 먼저 적용
|
@Order(1) // API 보안 설정을 먼저 적용
|
||||||
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
|
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
http
|
http.securityContext { context ->
|
||||||
|
context.securityContextRepository(securityContextRepository())
|
||||||
|
}
|
||||||
.securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨
|
.securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨
|
||||||
.csrf { it.disable() }
|
.csrf { it.disable() }
|
||||||
.cors { it.configurationSource(corsConfigurationSource()) }
|
.cors { it.configurationSource(corsConfigurationSource()) }
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
auth
|
auth
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/images/**").permitAll()
|
||||||
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
|
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
|
||||||
.anyRequest().authenticated() // 나머지 API는 인증 필요
|
.anyRequest().authenticated() // 나머지 API는 인증 필요
|
||||||
}
|
}
|
||||||
.exceptionHandling { handling ->
|
.exceptionHandling { handling ->
|
||||||
handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
|
// handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
|
||||||
|
handling.accessDeniedHandler(accessDeniedHandler2)
|
||||||
}
|
}
|
||||||
// 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가
|
// 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가
|
||||||
.addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java)
|
.addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java)
|
||||||
@ -126,9 +144,12 @@ class SecurityConfig(
|
|||||||
@Bean
|
@Bean
|
||||||
@Order(2) // 웹 페이지 보안 설정
|
@Order(2) // 웹 페이지 보안 설정
|
||||||
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
|
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**")))
|
||||||
|
|
||||||
http.cors { }
|
http.cors { }
|
||||||
.csrf { csrf ->
|
.csrf { csrf ->
|
||||||
csrf.ignoringRequestMatchers(
|
csrf.ignoringRequestMatchers(
|
||||||
|
"/api/**", // <-- 이 줄을 추가하세요!
|
||||||
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
|
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
|
||||||
"/api/ranks/submit",
|
"/api/ranks/submit",
|
||||||
"/bums/save/loc.api",
|
"/bums/save/loc.api",
|
||||||
@ -140,7 +161,6 @@ class SecurityConfig(
|
|||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
|
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
|
||||||
// 2. 공개 GET API 및 페이지 = permitAll
|
// 2. 공개 GET API 및 페이지 = permitAll
|
||||||
.requestMatchers(HttpMethod.GET,
|
.requestMatchers(HttpMethod.GET,
|
||||||
"/api/images/**",
|
"/api/images/**",
|
||||||
@ -200,13 +220,28 @@ class SecurityConfig(
|
|||||||
}.logout { logout ->
|
}.logout { logout ->
|
||||||
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
|
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
|
||||||
}.exceptionHandling { handling ->
|
}.exceptionHandling { handling ->
|
||||||
handling
|
handling.accessDeniedHandler(customAccessDeniedHandler)
|
||||||
.authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시
|
// .authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시
|
||||||
.accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시
|
// .accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시
|
||||||
}
|
}
|
||||||
return http.build()
|
return http.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val accessDeniedHandler2 =
|
||||||
|
AccessDeniedHandler { request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException? ->
|
||||||
|
println("${accessDeniedException?.message }\nSpring security forbidden...")
|
||||||
|
val fail: ErrorResponse = ErrorResponse.create( Throwable("권한이 없습니다."),
|
||||||
|
HttpStatus.FORBIDDEN, "${accessDeniedException?.message }\nSpring security forbidden..."
|
||||||
|
)
|
||||||
|
response.status = HttpStatus.FORBIDDEN.value()
|
||||||
|
val json = ObjectMapper().writeValueAsString(fail)
|
||||||
|
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||||
|
val writer = response.writer
|
||||||
|
writer.write(json)
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun authenticationManager(http: HttpSecurity): AuthenticationManager {
|
fun authenticationManager(http: HttpSecurity): AuthenticationManager {
|
||||||
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
|
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
|
||||||
@ -296,3 +331,63 @@ class JwtAuthenticationFilter(
|
|||||||
println("JWT Token validated")
|
println("JWT Token validated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Component // 이 클래스를 Spring Bean으로 등록
|
||||||
|
class CustomAccessDeniedHandler : AccessDeniedHandler {
|
||||||
|
|
||||||
|
override fun handle(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
accessDeniedException: AccessDeniedException
|
||||||
|
) {
|
||||||
|
// 1. 요청(Request) 객체에 오류 정보를 속성(Attribute)으로 담습니다.
|
||||||
|
request.setAttribute("timestamp", Date())
|
||||||
|
request.setAttribute("exception", accessDeniedException)
|
||||||
|
request.setAttribute("path", request.requestURI)
|
||||||
|
|
||||||
|
// 2. 응답 상태 코드를 403 (Forbidden)으로 설정합니다.
|
||||||
|
response.status = HttpServletResponse.SC_FORBIDDEN
|
||||||
|
|
||||||
|
// 3. /access-denied 경로로 요청을 전달(Forward)합니다.
|
||||||
|
// Redirect가 아닌 Forward를 사용해야 request에 담은 정보가 유지됩니다.
|
||||||
|
val dispatcher = request.getRequestDispatcher("/access-denied")
|
||||||
|
dispatcher.forward(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiAndWebSecurityContextRepository : SecurityContextRepository {
|
||||||
|
|
||||||
|
// API 요청은 /api/** 패턴에 매칭됩니다.
|
||||||
|
private val apiRequestMatcher = AntPathRequestMatcher("/api/**")
|
||||||
|
|
||||||
|
// API 요청에 대해서는 세션을 전혀 사용하지 않고, 오직 요청 기간 동안만 SecurityContext를 저장합니다. (완벽한 STATELESS)
|
||||||
|
private val apiContextRepository = RequestAttributeSecurityContextRepository()
|
||||||
|
|
||||||
|
// 그 외 모든 웹 요청에 대해서는 기본 HttpSession 리포지토리를 사용합니다 (STATEFUL).
|
||||||
|
private val webContextRepository = HttpSessionSecurityContextRepository()
|
||||||
|
|
||||||
|
override fun loadContext(requestResponseHolder: org.springframework.security.web.context.HttpRequestResponseHolder): SecurityContext {
|
||||||
|
val request = requestResponseHolder.request
|
||||||
|
return if (apiRequestMatcher.matches(request)) {
|
||||||
|
apiContextRepository.loadContext(requestResponseHolder)
|
||||||
|
} else {
|
||||||
|
webContextRepository.loadContext(requestResponseHolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveContext(context: SecurityContext, request: HttpServletRequest, response: HttpServletResponse) {
|
||||||
|
if (apiRequestMatcher.matches(request)) {
|
||||||
|
apiContextRepository.saveContext(context, request, response)
|
||||||
|
} else {
|
||||||
|
webContextRepository.saveContext(context, request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun containsContext(request: HttpServletRequest): Boolean {
|
||||||
|
return if (apiRequestMatcher.matches(request)) {
|
||||||
|
apiContextRepository.containsContext(request)
|
||||||
|
} else {
|
||||||
|
webContextRepository.containsContext(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
package kr.lunaticbum.back.lun.configs
|
|
||||||
|
|
||||||
|
|
||||||
//// Spring MVC 프로젝트에 관련된 설정을 하는 클래스
|
|
||||||
//@Configuration // Controller 어노테이션이 셋팅되어 있는 클래스를 Controller로 등록한다.
|
|
||||||
////@ComponentScan("kr.lunaticbum.back.lun.controllers")
|
|
||||||
//internal class ServletAppContext : WebMvcConfigurer {
|
|
||||||
// // // Controller의 메서드가 반환하는 jsp의 이름 앞뒤에 경로와 확장자를 붙혀주도록 설정한다.
|
|
||||||
//// override fun configureViewResolvers(registry: ViewResolverRegistry) {
|
|
||||||
//// // TODO Auto-generated method stub
|
|
||||||
//// super.configureViewResolvers(registry)
|
|
||||||
////// registry.viewResolver { viewName, locale -> }
|
|
||||||
////// registry.jsp("/WEB-INF/views/", ".jsp")
|
|
||||||
//// }
|
|
||||||
////
|
|
||||||
//// // 정적 파일의 경로를 매핑한다.
|
|
||||||
// override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
|
|
||||||
// // TODO Auto-generated method stub
|
|
||||||
//// super.addResourceHandlers(registry)
|
|
||||||
// registry
|
|
||||||
// .addResourceHandler("/")
|
|
||||||
//// .addResourceHandler("/**")
|
|
||||||
// .addResourceLocations("classpath:/META-INF/resources/")
|
|
||||||
// .addResourceLocations("classpath:/static/")
|
|
||||||
// .addResourceLocations("classpath:/templates/")
|
|
||||||
// .addResourceLocations("classpath:/templates/user/")
|
|
||||||
// .setCacheControl(CacheControl.maxAge(10,TimeUnit.SECONDS))
|
|
||||||
// super.addResourceHandlers(registry)
|
|
||||||
// }
|
|
||||||
//// @Autowired
|
|
||||||
//// @Qualifier(value = "authInterceptor")
|
|
||||||
//// private val authInterceptor: HandlerInterceptor? = null
|
|
||||||
////
|
|
||||||
//// override fun addInterceptors(registry: InterceptorRegistry) {
|
|
||||||
//// registry.addInterceptor(authInterceptor).addPathPatterns("/**")
|
|
||||||
//// }
|
|
||||||
//}
|
|
||||||
@ -6,6 +6,7 @@ import com.google.gson.Gson
|
|||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import kotlinx.coroutines.reactive.awaitFirstOrNull
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
|
||||||
@ -873,20 +874,41 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
|
|||||||
private val imageMetaService: ImageMetaService,
|
private val imageMetaService: ImageMetaService,
|
||||||
private val commentService: CommentService, // [신규 추가] CommentService 주입
|
private val commentService: CommentService, // [신규 추가] CommentService 주입
|
||||||
private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입
|
private val objectMapper: ObjectMapper // [신규 추가] JSON 처리를 위해 주입
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
suspend fun bookmarkListPage(
|
suspend fun bookmarkListPage(
|
||||||
@RequestParam(value = "page", defaultValue = "0") page: Int,
|
@RequestParam(value = "page", defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(required = false) category: String?, // 카테고리 파라미터 받기
|
||||||
|
@RequestParam(required = false) tag: String?, // 태그 파라미터 받기
|
||||||
@AuthenticationPrincipal userDetails: UserDetails?
|
@AuthenticationPrincipal userDetails: UserDetails?
|
||||||
): ResultMV {
|
): ResultMV {
|
||||||
val vm = ResultMV("content/bookmarks") // 북마크 전용 뷰 템플릿
|
val vm = ResultMV("content/bookmarks")
|
||||||
val pageable = PageRequest.of(page, 9) // 한 페이지에 9개씩 (3x3 그리드)
|
val pageable = PageRequest.of(page, 9)
|
||||||
|
|
||||||
// 서비스 레이어를 호출하여 현재 사용자 권한에 맞는 북마크 목록을 가져옴
|
// [수정] 서비스 호출 시 필터 파라미터 전달
|
||||||
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
|
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable, category, tag).awaitSingle()
|
||||||
|
|
||||||
|
vm.modelMap["bookmarksPage"] = bookmarksPage.map {
|
||||||
|
|
||||||
|
it.contentUrls = arrayListOf<String>().apply {
|
||||||
|
if (it.thumbnailUrl.isNullOrEmpty() == false) {
|
||||||
|
add(it.thumbnailUrl!!)
|
||||||
|
}
|
||||||
|
if (it.userSelectedImageUrl.isNullOrEmpty() == false) {
|
||||||
|
add(it.userSelectedImageUrl!!)
|
||||||
|
}
|
||||||
|
addAll(it.contentUrls)
|
||||||
|
}
|
||||||
|
it
|
||||||
|
}
|
||||||
|
|
||||||
|
// [추가] 뷰에서 사용할 필터 목록과 현재 선택된 필터 전달
|
||||||
|
vm.modelMap["allCategories"] = bookmarkService.findAllDistinctCategories().collectList().awaitSingle()
|
||||||
|
vm.modelMap["allTags"] = bookmarkService.findAllDistinctTags().collectList().awaitSingle()
|
||||||
|
vm.modelMap["currentCategory"] = category
|
||||||
|
vm.modelMap["currentTag"] = tag
|
||||||
|
|
||||||
vm.modelMap["bookmarksPage"] = bookmarksPage
|
|
||||||
vm.setTitle("저장된 페이지 목록")
|
vm.setTitle("저장된 페이지 목록")
|
||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
@ -949,7 +971,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
|
|||||||
@AuthenticationPrincipal userDetails: UserDetails?,
|
@AuthenticationPrincipal userDetails: UserDetails?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): ResponseEntity<Page<WebBookmark>> {
|
): ResponseEntity<Page<WebBookmark>> {
|
||||||
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable).awaitSingle()
|
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
|
||||||
return ResponseEntity.ok(bookmarksPage)
|
return ResponseEntity.ok(bookmarksPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -980,8 +1002,51 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
|
|||||||
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
|
.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,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/categories")
|
||||||
|
fun getBookmarkCategories(): Mono<List<String>> {
|
||||||
|
return bookmarkService.findAllDistinctCategories().collectList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 모든 북마크의 고유 태그 목록을 반환하는 API
|
||||||
|
@GetMapping("/tags")
|
||||||
|
fun getBookmarkTags(): Mono<List<String>> {
|
||||||
|
return bookmarkService.findAllDistinctTags().collectList()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 // Spring이 ?page=X&size=Y 파라미터를 자동으로 Pageable 객체로 변환해 줌
|
||||||
|
): ResponseEntity<Page<WebBookmark>> {
|
||||||
|
// 기존 서비스 메서드를 그대로 사용하여 사용자 권한에 맞는 북마크 목록을 가져옴
|
||||||
|
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
|
||||||
|
return ResponseEntity.ok(bookmarksPage)
|
||||||
|
}
|
||||||
|
|
||||||
data class BookmarkDataDto(
|
data class BookmarkDataDto(
|
||||||
val url: String,
|
val url: String,
|
||||||
|
val bookmarkType : String,
|
||||||
val userComment: String?,
|
val userComment: String?,
|
||||||
val visibility: String?
|
val visibility: String?
|
||||||
)
|
)
|
||||||
@ -1000,7 +1065,7 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
|
|||||||
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
|
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
|
||||||
val targetPath = Paths.get(uploadPath, uniqueFilename)
|
val targetPath = Paths.get(uploadPath, uniqueFilename)
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(targetPath.parent)
|
// Files.createDirectories(targetPath.parent)
|
||||||
imageFile.transferTo(targetPath.toFile())
|
imageFile.transferTo(targetPath.toFile())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
|
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
|
||||||
@ -1025,47 +1090,12 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
|
|||||||
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
|
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// BlogController.kt의 BookmarkApiController 내부
|
||||||
@RestController
|
@PostMapping("/with-content", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
@RequestMapping("/api/bookmarks")
|
fun saveBookmarkWithContent(
|
||||||
class BookmarkApiController(
|
@RequestPart("files") files: List<MultipartFile>, // [수정] 단일 파일 -> 파일 목록
|
||||||
private val bookmarkService: WebBookmarkService,
|
@RequestPart("bookmarkData") bookmarkDataJson: String,
|
||||||
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?
|
@AuthenticationPrincipal user: UserDetails?
|
||||||
): Mono<ResponseEntity<WebBookmark>> {
|
): Mono<ResponseEntity<WebBookmark>> {
|
||||||
logService.log("uploadPath >>> ${uploadPath}")
|
logService.log("uploadPath >>> ${uploadPath}")
|
||||||
@ -1073,23 +1103,22 @@ class BookmarkApiController(
|
|||||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
|
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 전달받은 파일들을 서버에 저장하고, 각 파일의 경로 목록을 생성합니다.
|
||||||
|
val savedFilePaths = files.map { file ->
|
||||||
|
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
|
||||||
|
val targetPath = Paths.get(uploadPath, uniqueFilename)
|
||||||
|
try {
|
||||||
|
file.transferTo(targetPath.toFile())
|
||||||
|
"/api/images/$uniqueFilename" // 저장 성공 시 반환될 경로
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
// 에러 발생 시 null 반환 (이후 filterNotNull로 걸러냄)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.filterNotNull() // 저장에 실패한 파일(null)은 목록에서 제외
|
||||||
|
|
||||||
// Gson과 같은 JSON 라이브러리를 사용해 문자열을 DTO 객체로 변환할 수 있습니다.
|
if (savedFilePaths.isEmpty()) {
|
||||||
// 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())
|
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1100,18 +1129,157 @@ class BookmarkApiController(
|
|||||||
val newBookmark = WebBookmark(
|
val newBookmark = WebBookmark(
|
||||||
userId = user.username,
|
userId = user.username,
|
||||||
url = bookmarkData.url,
|
url = bookmarkData.url,
|
||||||
|
// [수정] bookmarkType을 DTO에서 받아오도록 변경 (예: IMAGE, VIDEO)
|
||||||
|
bookmarkType = bookmarkData.bookmarkType ?: BookmarkType.IMAGE.name,
|
||||||
|
contentUrls = savedFilePaths, // [수정] 저장된 파일 경로 목록을 contentUrls에 할당
|
||||||
userComment = bookmarkData.userComment,
|
userComment = bookmarkData.userComment,
|
||||||
visibility = bookmarkData.visibility ?: "PRIVATE",
|
visibility = bookmarkData.visibility ?: "PRIVATE",
|
||||||
metadataStatus = "PENDING",
|
metadataStatus = "COMPLETED", // 파일이 직접 업로드되었으므로 메타데이터 처리는 완료됨
|
||||||
// 저장된 이미지의 서버 URL을 저장
|
thumbnailUrl = savedFilePaths.first() // 첫 번째 이미지를 대표 썸네일로 사용
|
||||||
userSelectedImageUrl = "/api/images/$uniqueFilename"
|
|
||||||
)
|
)
|
||||||
println("newBookmark ${newBookmark}")
|
|
||||||
// 4. 북마크 정보 DB에 저장
|
// 4. 북마크 정보 DB에 저장
|
||||||
return bookmarkService.saveBookmark(newBookmark)
|
return bookmarkService.saveBookmark(newBookmark)
|
||||||
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }.apply {
|
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
|
||||||
println("OK")
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* [수정] ID로 단일 북마크를 가져옵니다.
|
||||||
|
* 이 엔드포인트가 누락되어 401/404 오류가 발생했습니다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
suspend fun getBookmarkById(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@AuthenticationPrincipal userDetails: UserDetails?
|
||||||
|
): ResponseEntity<WebBookmark> {
|
||||||
|
val bookmark = bookmarkService.findById(id).awaitSingleOrNull()
|
||||||
|
?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
|
// 현재 사용자가 이 북마크를 볼 권한이 있는지 확인합니다.
|
||||||
|
val isOwner = userDetails?.username == bookmark.userId
|
||||||
|
val canView = when (bookmark.visibility) {
|
||||||
|
Visibility.PUBLIC.name -> true
|
||||||
|
Visibility.MEMBERS.name -> userDetails != null
|
||||||
|
Visibility.PRIVATE.name -> isOwner
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (canView) {
|
||||||
|
ResponseEntity.ok(bookmark)
|
||||||
|
} else {
|
||||||
|
ResponseEntity.status(HttpStatus.FORBIDDEN).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BookmarkUpdateRequest(
|
||||||
|
val title: String?,
|
||||||
|
val userComment: String?,
|
||||||
|
val visibility: String?,
|
||||||
|
val category: String?,
|
||||||
|
val tags: List<String>?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List<String>)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
suspend fun deleteBookmark(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@AuthenticationPrincipal userDetails: UserDetails?
|
||||||
|
): ResponseEntity<Map<String, Any>> { // [수정] 반환 타입 변경
|
||||||
|
logService.log("북마크 삭제 요청: ID=$id, 사용자=${userDetails?.username}")
|
||||||
|
|
||||||
|
// 1. 사용자 인증 정보 확인
|
||||||
|
if (userDetails == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(mapOf("message" to "인증이 필요합니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 북마크 존재 여부 확인
|
||||||
|
val bookmark = bookmarkService.findById(id).awaitSingleOrNull()
|
||||||
|
if (bookmark == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(mapOf("message" to "삭제할 북마크를 찾을 수 없습니다: ID=$id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 소유권 확인
|
||||||
|
if (userDetails.username != bookmark.userId) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(mapOf("message" to "이 북마크를 삭제할 권한이 없습니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 삭제 실행
|
||||||
|
return try {
|
||||||
|
bookmarkService.deleteBookmark(id).awaitFirstOrNull()
|
||||||
|
logService.log("DB 삭제 성공: ID=$id")
|
||||||
|
ResponseEntity.ok(mapOf("message" to "북마크가 성공적으로 삭제되었습니다.", "id" to id))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logService.log("DB 삭제 중 예외 발생: ID=$id, 오류=${e.message}")
|
||||||
|
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(mapOf("message" to "북마크 삭제 중 서버 오류가 발생했습니다."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
suspend fun updateBookmark(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestBody request: BookmarkUpdateRequest,
|
||||||
|
@AuthenticationPrincipal userDetails: UserDetails?
|
||||||
|
): ResponseEntity<*> { // [수정] 반환 타입 변경
|
||||||
|
logService.log("북마크 업데이트 요청: ID=$id, 사용자=${userDetails?.username}")
|
||||||
|
|
||||||
|
// 1. 사용자 인증 정보 확인
|
||||||
|
if (userDetails == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(mapOf("message" to "인증이 필요합니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 북마크 존재 여부 확인
|
||||||
|
val existingBookmark = bookmarkService.findById(id).awaitSingleOrNull()
|
||||||
|
if (existingBookmark == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(mapOf("message" to "수정할 북마크를 찾을 수 없습니다: ID=$id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 소유권 확인
|
||||||
|
if (userDetails.username != existingBookmark.userId) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(mapOf("message" to "이 북마크를 수정할 권한이 없습니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 업데이트 실행
|
||||||
|
val updatedBookmark = existingBookmark.copy(
|
||||||
|
title = request.title ?: existingBookmark.title,
|
||||||
|
userComment = request.userComment,
|
||||||
|
visibility = request.visibility ?: existingBookmark.visibility,
|
||||||
|
category = request.category,
|
||||||
|
tags = request.tags ?: existingBookmark.tags
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val savedBookmark = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
|
||||||
|
logService.log("DB 업데이트 성공: ID=$id")
|
||||||
|
ResponseEntity.ok(savedBookmark)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logService.log("DB 업데이트 중 예외 발생: ID=$id, 오류=${e.message}")
|
||||||
|
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(mapOf("message" to "북마크 업데이트 중 서버 오류가 발생했습니다."))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
class CustomErrorController {
|
||||||
|
|
||||||
|
// SecurityConfig에서 지정한 "/access-denied" 경로를 처리합니다.
|
||||||
|
@GetMapping("/access-denied")
|
||||||
|
fun accessDeniedPage(model: org.springframework.ui.Model): String {
|
||||||
|
model.addAttribute("statusCode", "403")
|
||||||
|
model.addAttribute("errorMessage", "이 페이지에 접근할 권한이 없습니다.")
|
||||||
|
model.addAttribute("errorDescription", "요청하신 리소스에 대한 접근 권한이 부족합니다. 관리자에게 문의하거나 다른 계정으로 로그인해 주세요.")
|
||||||
|
return "content/error_page" // 보여줄 HTML 파일의 경로
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,6 @@ import kr.lunaticbum.back.lun.configs.GlobalEnvironment
|
|||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
|
||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
||||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
|
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
|
||||||
import kr.lunaticbum.back.lun.configs.JwtRule
|
|
||||||
import kr.lunaticbum.back.lun.model.*
|
import kr.lunaticbum.back.lun.model.*
|
||||||
import kr.lunaticbum.back.lun.utils.JwtUtil
|
import kr.lunaticbum.back.lun.utils.JwtUtil
|
||||||
import kr.lunaticbum.back.lun.utils.LogService
|
import kr.lunaticbum.back.lun.utils.LogService
|
||||||
@ -50,8 +49,8 @@ class UserController(
|
|||||||
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
|
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
|
||||||
private val messageService: MessageService,
|
private val messageService: MessageService,
|
||||||
private val webBookmarkService: WebBookmarkService,
|
private val webBookmarkService: WebBookmarkService,
|
||||||
private val imageMetaService: ImageMetaService
|
private val imageMetaService: ImageMetaService,
|
||||||
|
private val jwtUtil: JwtUtil
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
||||||
@ -133,6 +132,10 @@ class UserController(
|
|||||||
|
|
||||||
val principal = authResult?.principal
|
val principal = authResult?.principal
|
||||||
if (principal is UserDetails) {
|
if (principal is UserDetails) {
|
||||||
|
val token = jwtUtil.generateToken(principal)
|
||||||
|
loginResult.token = token // 2. 응답 객체에 토큰 추가
|
||||||
|
|
||||||
|
|
||||||
println("target.remeberMe >>> ${target.rememberMe}")
|
println("target.remeberMe >>> ${target.rememberMe}")
|
||||||
loginResult.rememberMe = target.rememberMe
|
loginResult.rememberMe = target.rememberMe
|
||||||
if (target.rememberMe == true) {
|
if (target.rememberMe == true) {
|
||||||
@ -266,6 +269,14 @@ class UserController(
|
|||||||
val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
||||||
vm.modelMap["myPosts"] = myPosts ?: emptyList()
|
vm.modelMap["myPosts"] = myPosts ?: emptyList()
|
||||||
|
|
||||||
|
// 사용자가 저장한 모든 북마크 목록을 가져옵니다.
|
||||||
|
val myBookmarks = webBookmarkService.getBookmarksForUser(username) // 1. 모든 북마크를 Flux로 가져옴
|
||||||
|
.collectList() // 2. Flux 스트림을 Mono<List<WebBookmark>>으로 변환
|
||||||
|
.block() // 3. 최종적으로 List<WebBookmark>으로 변환
|
||||||
|
|
||||||
|
// 모델에 "myBookmarks" 라는 키로 저장된 북마크 리스트를 추가합니다.
|
||||||
|
vm.modelMap["myBookmarks"] = myBookmarks ?: emptyList()
|
||||||
|
|
||||||
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
|
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
|
||||||
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
||||||
vm.modelMap["myComments"] = myComments ?: emptyList()
|
vm.modelMap["myComments"] = myComments ?: emptyList()
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
//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
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
package kr.lunaticbum.back.lun.model
|
|
||||||
|
|
||||||
import org.springframework.data.annotation.Id
|
|
||||||
import org.springframework.data.mongodb.core.mapping.Document
|
|
||||||
import java.time.Instant
|
|
||||||
import org.springframework.data.repository.reactive.ReactiveSortingRepository
|
|
||||||
import org.springframework.security.authentication.AnonymousAuthenticationToken
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import reactor.core.publisher.Flux
|
|
||||||
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)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@ -1055,15 +1055,22 @@ enum class MetadataStatus {
|
|||||||
FAILED // 처리 실패
|
FAILED // 처리 실패
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class BookmarkType {
|
||||||
|
URL, // 기존 웹 페이지 링크
|
||||||
|
IMAGE, // 하나 이상의 이미지
|
||||||
|
VIDEO // 하나 이상의 비디오
|
||||||
|
}
|
||||||
@Document(collection = "WebBookmark")
|
@Document(collection = "WebBookmark")
|
||||||
data class WebBookmark(
|
data class WebBookmark(
|
||||||
@BsonId
|
@BsonId
|
||||||
@BsonRepresentation(BsonType.OBJECT_ID)
|
@BsonRepresentation(BsonType.OBJECT_ID)
|
||||||
var id: String? = null,
|
var id: String? = null,
|
||||||
|
|
||||||
var userId: String, // 누가 저장했는지
|
var userId: String, // 누가 저장했는지
|
||||||
var url: String, // 원본 페이지 URL
|
var url: String?, // 원본 페이지 URL
|
||||||
|
// [신규] 북마크 타입 (URL, IMAGE, VIDEO 등)
|
||||||
|
var bookmarkType: String = BookmarkType.URL.name,
|
||||||
|
// [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능)
|
||||||
|
var contentUrls: List<String> = emptyList(),
|
||||||
var title: String? = null, // 페이지 제목
|
var title: String? = null, // 페이지 제목
|
||||||
var description: String? = null, // 페이지 요약 (메타 태그)
|
var description: String? = null, // 페이지 요약 (메타 태그)
|
||||||
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
|
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
|
||||||
@ -1076,8 +1083,32 @@ data class WebBookmark(
|
|||||||
var voteCount: Long = 0,
|
var voteCount: Long = 0,
|
||||||
var unlikeCount: Long = 0,
|
var unlikeCount: Long = 0,
|
||||||
var userSelectedImageUrl: String? = null,
|
var userSelectedImageUrl: String? = null,
|
||||||
var metadataStatus: String = MetadataStatus.PENDING.name
|
var metadataStatus: String = MetadataStatus.PENDING.name,
|
||||||
)
|
|
||||||
|
// [추가] 카테고리 필드 (하나만 가질 수 있도록 String으로 설정)
|
||||||
|
var category: String? = null
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [이 부분을 추가하세요]
|
||||||
|
* 화면에 표시할 최종 이미지 URL을 계산하는 프로퍼티입니다.
|
||||||
|
* @get:BsonIgnore 어노테이션으로 이 필드는 DB에 저장되지 않습니다.
|
||||||
|
*/
|
||||||
|
@get:BsonIgnore
|
||||||
|
val displayImageUrl: String
|
||||||
|
get() {
|
||||||
|
return when {
|
||||||
|
// 1순위: 사용자가 선택한 이미지
|
||||||
|
!userSelectedImageUrl.isNullOrBlank() -> userSelectedImageUrl!!
|
||||||
|
// 2순위: 자동 추출된 썸네일
|
||||||
|
!thumbnailUrl.isNullOrBlank() -> thumbnailUrl!!
|
||||||
|
// 3순위: 콘텐츠 URL 목록의 첫 번째 이미지
|
||||||
|
contentUrls.isNotEmpty() -> contentUrls.first()
|
||||||
|
// 4순위: 기본 이미지
|
||||||
|
else -> "/images/pic01.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
|
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
|
||||||
@ -1086,6 +1117,13 @@ interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
|
|||||||
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
|
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
|
||||||
|
|
||||||
fun findByMetadataStatus(status: String): Flux<WebBookmark>
|
fun findByMetadataStatus(status: String): Flux<WebBookmark>
|
||||||
|
|
||||||
|
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
|
||||||
|
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
|
||||||
|
fun findDistinctTags(): Flux<Map<String, Any>>
|
||||||
|
|
||||||
|
@Aggregation("{ \$group: { _id: '\$category' } }")
|
||||||
|
fun findDistinctCategories(): Flux<Map<String, Any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -1093,6 +1131,25 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
|
|||||||
private val reactiveMongoTemplate: ReactiveMongoTemplate
|
private val reactiveMongoTemplate: ReactiveMongoTemplate
|
||||||
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
|
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
|
||||||
) {
|
) {
|
||||||
|
// [이 메소드를 추가하세요]
|
||||||
|
fun findById(id: String): Mono<WebBookmark> {
|
||||||
|
return repository.findById(id)
|
||||||
|
}
|
||||||
|
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
|
||||||
|
fun findAllDistinctCategories(): Flux<String> {
|
||||||
|
return repository.findDistinctCategories()
|
||||||
|
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
|
||||||
|
fun findAllDistinctTags(): Flux<String> {
|
||||||
|
return repository.findDistinctTags()
|
||||||
|
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
|
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
|
||||||
return repository.findByUserIdOrderBySavedAtDesc(userId)
|
return repository.findByUserIdOrderBySavedAtDesc(userId)
|
||||||
}
|
}
|
||||||
@ -1107,24 +1164,37 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
|
|||||||
return repository.deleteById(id)
|
return repository.deleteById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [신규 추가] 사용자의 권한에 따라 볼 수 있는 북마크 목록을 페이지네이션으로 조회
|
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
|
||||||
fun getVisibleBookmarks(userDetails: UserDetails?, pageable: Pageable): Mono<Page<WebBookmark>> {
|
fun getVisibleBookmarks(
|
||||||
|
userDetails: UserDetails?,
|
||||||
|
pageable: Pageable,
|
||||||
|
category: String?, // 카테고리 파라미터 추가
|
||||||
|
tag: String? // 태그 파라미터 추가
|
||||||
|
): Mono<Page<WebBookmark>> {
|
||||||
val visibleScopes = when {
|
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)
|
userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name)
|
||||||
|
|
||||||
// 비로그인 사용자일 경우 PUBLIC만 조회 가능
|
|
||||||
else -> listOf(Visibility.PUBLIC.name)
|
else -> listOf(Visibility.PUBLIC.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
val bookmarks = repository.findByVisibilityInOrderBySavedAtDesc(visibleScopes, pageable).collectList()
|
// 동적 쿼리 생성 시작
|
||||||
val totalCount = repository.countByVisibilityIn(visibleScopes)
|
val query = Query(Criteria.where("visibility").`in`(visibleScopes))
|
||||||
|
.with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요.
|
||||||
|
.with(pageable)
|
||||||
|
|
||||||
|
// 카테고리 조건 추가
|
||||||
|
if (!category.isNullOrBlank()) {
|
||||||
|
query.addCriteria(Criteria.where("category").`is`(category))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인)
|
||||||
|
if (!tag.isNullOrBlank()) {
|
||||||
|
query.addCriteria(Criteria.where("tags").`in`(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 조회 및 카운트
|
||||||
|
val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList()
|
||||||
|
val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java)
|
||||||
|
|
||||||
// Mono.zip을 사용하여 두 비동기 작업(목록 조회, 카운트)을 병렬로 실행
|
|
||||||
return Mono.zip(bookmarks, totalCount).map { tuple ->
|
return Mono.zip(bookmarks, totalCount).map { tuple ->
|
||||||
PageImpl(tuple.t1, pageable, tuple.t2)
|
PageImpl(tuple.t1, pageable, tuple.t2)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ open class PostsResult : BaseResult() {
|
|||||||
@Getter
|
@Getter
|
||||||
open class LoginResult : ResponceResult() {
|
open class LoginResult : ResponceResult() {
|
||||||
var rememberMe: Boolean? = null
|
var rememberMe: Boolean? = null
|
||||||
|
|
||||||
|
var token: String? = null // [추가] JWT 토큰을 담을 필드
|
||||||
}
|
}
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import org.springframework.stereotype.Service
|
|||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import reactor.core.publisher.Mono
|
import reactor.core.publisher.Mono
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@ -32,6 +32,7 @@ data class User (
|
|||||||
@BsonRepresentation(BsonType.OBJECT_ID)
|
@BsonRepresentation(BsonType.OBJECT_ID)
|
||||||
var userId: String? = null,
|
var userId: String? = null,
|
||||||
|
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
var user_id: String? = null,
|
var user_id: String? = null,
|
||||||
var user_pw: String? = null,
|
var user_pw: String? = null,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package kr.lunaticbum.back.lun.service
|
package kr.lunaticbum.back.lun.service
|
||||||
|
|
||||||
|
|
||||||
|
import kr.lunaticbum.back.lun.model.BookmarkType
|
||||||
import kr.lunaticbum.back.lun.model.MetadataStatus
|
import kr.lunaticbum.back.lun.model.MetadataStatus
|
||||||
import kr.lunaticbum.back.lun.model.WebBookmark
|
import kr.lunaticbum.back.lun.model.WebBookmark
|
||||||
import kr.lunaticbum.back.lun.model.WebBookmarkRepository
|
import kr.lunaticbum.back.lun.model.WebBookmarkRepository
|
||||||
@ -39,20 +40,23 @@ class BookmarkProcessorService(
|
|||||||
return Mono.fromCallable {
|
return Mono.fromCallable {
|
||||||
// Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고
|
// Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고
|
||||||
// 별도 스레드에서 실행되도록 subscribeOn을 사용
|
// 별도 스레드에서 실행되도록 subscribeOn을 사용
|
||||||
logService.log("Fetching metadata for: ${bookmark.url}")
|
if(bookmark.bookmarkType.equals(BookmarkType.URL.name, ignoreCase = true)){
|
||||||
val doc = Jsoup.connect(bookmark.url).timeout(10000).get() // 10초 타임아웃
|
logService.log("Fetching metadata for: ${bookmark.contentUrls.first()}")
|
||||||
|
val doc = Jsoup.connect(bookmark.contentUrls.first()).timeout(10000).get() // 10초 타임아웃
|
||||||
|
|
||||||
// 메타데이터 추출
|
// 메타데이터 추출
|
||||||
val title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() }
|
val title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() }
|
||||||
val description = doc.select("meta[property=og:description]").attr("content")
|
val description = doc.select("meta[property=og:description]").attr("content")
|
||||||
val imageUrl = doc.select("meta[property=og:image]").attr("content")
|
val imageUrl = doc.select("meta[property=og:image]").attr("content")
|
||||||
|
|
||||||
// 북마크 객체 업데이트
|
// 북마크 객체 업데이트
|
||||||
bookmark.title = title
|
bookmark.title = title
|
||||||
bookmark.description = description
|
bookmark.description = description
|
||||||
bookmark.thumbnailUrl = imageUrl
|
bookmark.thumbnailUrl = imageUrl
|
||||||
bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경
|
bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경
|
||||||
|
}
|
||||||
bookmark
|
bookmark
|
||||||
|
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
.flatMap { updatedBookmark ->
|
.flatMap { updatedBookmark ->
|
||||||
|
|||||||
@ -104,3 +104,4 @@ api.base-url=ss
|
|||||||
build.config.run=local
|
build.config.run=local
|
||||||
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
||||||
jwt.expiration=86400000
|
jwt.expiration=86400000
|
||||||
|
logging.level.org.springframework.security=DEBUG
|
||||||
@ -104,3 +104,4 @@ api.base-url=ss
|
|||||||
build.config.run=prd
|
build.config.run=prd
|
||||||
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
||||||
jwt.expiration=86400000
|
jwt.expiration=86400000
|
||||||
|
logging.level.org.springframework.security=DEBUG
|
||||||
@ -105,3 +105,4 @@ api.base-url=ss
|
|||||||
build.config.run=local
|
build.config.run=local
|
||||||
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
|
||||||
jwt.expiration=86400000
|
jwt.expiration=86400000
|
||||||
|
logging.level.org.springframework.security=DEBUG
|
||||||
@ -1778,3 +1778,134 @@ function openBookmarkInIframe(url, title) {
|
|||||||
overlay.style.display = 'block';
|
overlay.style.display = 'block';
|
||||||
popup.style.display = 'block';
|
popup.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 팝업과 폼 필드를 연결하기 위한 전역 변수
|
||||||
|
let bookmarkPopupTargets = {
|
||||||
|
displayId: null,
|
||||||
|
inputId: null
|
||||||
|
};
|
||||||
|
let stagedBookmarkCategory = '';
|
||||||
|
let stagedBookmarkTags = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 북마크 카테고리 팝업을 여는 함수
|
||||||
|
* @param {string} displayId - 선택된 카테고리를 보여줄 div의 ID
|
||||||
|
* @param {string} inputId - 실제 값을 저장할 hidden input의 ID
|
||||||
|
*/
|
||||||
|
async function openBookmarkCategoryPopup(displayId, inputId) {
|
||||||
|
bookmarkPopupTargets = { displayId, inputId }; // 현재 작업 대상 필드를 저장
|
||||||
|
|
||||||
|
const currentCategory = document.getElementById(inputId).value;
|
||||||
|
stagedBookmarkCategory = currentCategory || '';
|
||||||
|
renderStagedBookmarkCategory();
|
||||||
|
|
||||||
|
// 기존 카테고리 목록 불러오기
|
||||||
|
const listEl = document.getElementById('bookmark-category-list');
|
||||||
|
listEl.innerHTML = '로딩...';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bookmarks/categories',{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const categories = await response.json();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
categories.forEach(cat => {
|
||||||
|
const tagEl = document.createElement('span');
|
||||||
|
tagEl.className = 'tag-item';
|
||||||
|
tagEl.textContent = cat;
|
||||||
|
tagEl.onclick = () => {
|
||||||
|
stagedBookmarkCategory = cat;
|
||||||
|
renderStagedBookmarkCategory();
|
||||||
|
};
|
||||||
|
listEl.appendChild(tagEl);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '카테고리를 불러오는데 실패했습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyEl = document.createElement('div');
|
||||||
|
dummyEl.setAttribute('to', '#bookmark-category-popup');
|
||||||
|
openPopup(dummyEl);
|
||||||
|
}
|
||||||
|
document.getElementById('new-bookmark-category-input')?.addEventListener('keyup', e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
stagedBookmarkCategory = e.target.value.trim();
|
||||||
|
renderStagedBookmarkCategory();
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function renderStagedBookmarkCategory() {
|
||||||
|
const area = document.getElementById('selected-bookmark-category-area');
|
||||||
|
area.innerHTML = stagedBookmarkCategory ? `<span class="tag-item">${stagedBookmarkCategory} <span class="remove-tag" onclick="stagedBookmarkCategory=''; renderStagedBookmarkCategory();">X</span></span>` : '<i>선택된 카테고리 없음</i>';
|
||||||
|
}
|
||||||
|
function applyBookmarkCategory() {
|
||||||
|
document.getElementById(bookmarkPopupTargets.inputId).value = stagedBookmarkCategory;
|
||||||
|
document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkCategory ? `<span class="tag-item">${stagedBookmarkCategory}</span>` : '카테고리 선택';
|
||||||
|
closePopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 북마크 태그 팝업을 여는 함수
|
||||||
|
* @param {string} displayId - 선택된 태그를 보여줄 div의 ID
|
||||||
|
* @param {string} inputId - 실제 값을 저장할 hidden input의 ID
|
||||||
|
*/
|
||||||
|
async function openBookmarkTagPopup(displayId, inputId) {
|
||||||
|
bookmarkPopupTargets = { displayId, inputId };
|
||||||
|
|
||||||
|
const currentTags = document.getElementById(inputId).value;
|
||||||
|
stagedBookmarkTags = currentTags ? currentTags.split(',').map(t => t.trim()) : [];
|
||||||
|
renderStagedBookmarkTags();
|
||||||
|
|
||||||
|
// 기존 태그 목록 불러오기
|
||||||
|
const listEl = document.getElementById('bookmark-tag-list');
|
||||||
|
listEl.innerHTML = '로딩...';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bookmarks/tags',{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tags = await response.json();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const tagEl = document.createElement('span');
|
||||||
|
tagEl.className = 'tag-item';
|
||||||
|
tagEl.textContent = '#' + tag;
|
||||||
|
tagEl.onclick = () => addStagedBookmarkTag(tag);
|
||||||
|
listEl.appendChild(tagEl);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '태그를 불러오는데 실패했습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyEl = document.createElement('div');
|
||||||
|
dummyEl.setAttribute('to', '#bookmark-tag-popup');
|
||||||
|
openPopup(dummyEl);
|
||||||
|
}
|
||||||
|
document.getElementById('new-bookmark-tag-input')?.addEventListener('keyup', e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
addStagedBookmarkTag(e.target.value.trim());
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function addStagedBookmarkTag(tag) {
|
||||||
|
if (tag && !stagedBookmarkTags.includes(tag)) {
|
||||||
|
stagedBookmarkTags.push(tag);
|
||||||
|
renderStagedBookmarkTags();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeStagedBookmarkTag(index) {
|
||||||
|
stagedBookmarkTags.splice(index, 1);
|
||||||
|
renderStagedBookmarkTags();
|
||||||
|
}
|
||||||
|
function renderStagedBookmarkTags() {
|
||||||
|
const area = document.getElementById('selected-bookmark-tags-area');
|
||||||
|
area.innerHTML = stagedBookmarkTags.map((tag, i) => `<span class="tag-item">#${tag} <span class="remove-tag" onclick="removeStagedBookmarkTag(${i})">X</span></span>`).join(' ') || '<i>선택된 태그 없음</i>';
|
||||||
|
}
|
||||||
|
function applyBookmarkTags() {
|
||||||
|
const tagsString = stagedBookmarkTags.join(',');
|
||||||
|
document.getElementById(bookmarkPopupTargets.inputId).value = tagsString;
|
||||||
|
document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkTags.map(tag => `<span class="tag-item">#${tag}</span>`).join(' ') || '태그 선택';
|
||||||
|
closePopup();
|
||||||
|
}
|
||||||
@ -2,9 +2,35 @@
|
|||||||
<html
|
<html
|
||||||
xmlns:th="http://www.thymeleaf.org"
|
xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
layout:decorate="~{layout/default_layout}">
|
layout:decorate="~{layout/default_layout}">
|
||||||
<head>
|
<head>
|
||||||
<title>Bookmarks</title>
|
<title>Bookmarks</title>
|
||||||
|
<style>
|
||||||
|
.scrollable-content {
|
||||||
|
max-height: 500px; /* 콘텐츠 영역의 최대 높이를 지정 */
|
||||||
|
max-width: 500px;
|
||||||
|
overflow-y: auto; /* 세로 내용이 넘칠 경우 스크롤바 자동 생성 */
|
||||||
|
-webkit-overflow-scrolling: touch; /* 모바일에서 부드러운 스크롤 효과 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swiper/swiper-bundle.min.css" />
|
||||||
|
<script src="https://unpkg.com/swiper/swiper-bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const swiper = new Swiper('.bookmark-swiper', {
|
||||||
|
loop: false,
|
||||||
|
pagination: {
|
||||||
|
el: '.swiper-pagination',
|
||||||
|
clickable: true,
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
nextEl: '.swiper-button-next',
|
||||||
|
prevEl: '.swiper-button-prev',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
<section class="wrapper style2">
|
<section class="wrapper style2">
|
||||||
@ -12,71 +38,103 @@
|
|||||||
<header class="major">
|
<header class="major">
|
||||||
<h2>Bookmarks</h2>
|
<h2>Bookmarks</h2>
|
||||||
<p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p>
|
<p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p>
|
||||||
|
<div class="filter-controls" style="margin-bottom: 2em; text-align: center;">
|
||||||
|
<div style="margin-bottom: 1em;">
|
||||||
|
<strong>카테고리:</strong>
|
||||||
|
<a th:href="@{/bookmarks}" th:classappend="${currentCategory == null && currentTag == null} ? 'button small' : 'button alt small'">전체</a>
|
||||||
|
<a th:each="cat : ${allCategories}"
|
||||||
|
th:href="@{/bookmarks(category=${cat})}"
|
||||||
|
th:text="${cat}"
|
||||||
|
th:classappend="${currentCategory == cat} ? 'button small' : 'button alt small'"></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>태그:</strong>
|
||||||
|
<a th:each="tg : ${allTags}"
|
||||||
|
th:href="@{/bookmarks(tag=${tg})}"
|
||||||
|
th:text="'#' + ${tg}"
|
||||||
|
th:classappend="${currentTag == tg} ? 'button small' : 'button alt small'"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="wrapper style1">
|
<section class="wrapper style1">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="swiper bookmark-swiper">
|
||||||
<div class="col-12" th:if="${bookmarksPage.empty}">
|
<div class="swiper-wrapper">
|
||||||
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
|
<div class="swiper-slide" th:each="bookmark : ${bookmarksPage.content}">
|
||||||
</div>
|
<section class="box feature" style="margin: 0; height: 100%; display: flex; flex-direction: column;">
|
||||||
<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 th:switch="${bookmark.bookmarkType}">
|
||||||
<div class="vote-controls" th:data-bookmark-id="${bookmark.id}" style="text-align: center; margin-bottom: 1em;">
|
<div th:case="'IMAGE'" class="image-flick-container scrollable-content">
|
||||||
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'like')">
|
<img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" />
|
||||||
👍 (<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>
|
</div>
|
||||||
<a href="javascript:void(0);" th:onclick="toggleCommentSection('[[${bookmark.id}]]')" class="button small fit">댓글 보기</a>
|
<div th:case="'VIDEO'" class="video-container" th:if="${!#lists.isEmpty(bookmark.contentUrls)}">
|
||||||
<div th:id="'comment-section-' + ${bookmark.id}" class="comment-section" style="display: none; margin-top: 1em;">
|
<video controls style="width: 100%;">
|
||||||
<div th:id="'comments-list-' + ${bookmark.id}" class="comments-list"></div>
|
<source th:src="${apiBaseUrl + bookmark.contentUrls[0]}" type="video/mp4">
|
||||||
<textarea th:id="'comment-input-' + ${bookmark.id}" placeholder="댓글을 입력하세요..." style="margin-top: 1em;"></textarea>
|
</video>
|
||||||
<button class="button small" th:onclick="submitBookmarkComment('[[${bookmark.id}]]')">등록</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<a th:case="'URL'"
|
||||||
|
href="javascript:void(0);"
|
||||||
|
th:data-url="${bookmark.url}"
|
||||||
|
th:data-title="${bookmark.title}"
|
||||||
|
onclick="showBookmarkOptions(this)" class="image featured scrollable-content">
|
||||||
|
<img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer style="font-size: 0.8em; color: #888; text-align: right; margin-top: 1em;">
|
<div class="inner" style="flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between;">
|
||||||
by <span th:text="${bookmark.userId}"></span>
|
<div>
|
||||||
</footer>
|
<header>
|
||||||
</div>
|
<h3 th:text="${bookmark.title}">북마크 제목</h3>
|
||||||
</section>
|
<p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}"></p>
|
||||||
|
</header>
|
||||||
|
<p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="vote-controls" style="margin-top: 1em; text-align: center;" th:data-bookmark-id="${bookmark.id}">
|
||||||
|
<button class="button small" onclick="handleBookmarkVote(this, 'like')">
|
||||||
|
👍 Like (<span class="like-count" th:text="${bookmark.voteCount}">0</span>)
|
||||||
|
</button>
|
||||||
|
<button class="button small" onclick="handleBookmarkVote(this, 'unlike')">
|
||||||
|
👎 Unlike (<span class="unlike-count" th:text="${bookmark.unlikeCount}">0</span>)
|
||||||
|
</button>
|
||||||
|
<button class="button alt small" th:onclick="toggleCommentSection([[${bookmark.id}]])">
|
||||||
|
💬 Comments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="comment-section" th:id="|comment-section-${bookmark.id}|" style="display: none; margin-top: 1em; text-align: left;">
|
||||||
|
|
||||||
|
<th:block sec:authorize="isAuthenticated()">
|
||||||
|
<div class="comment-form-container">
|
||||||
|
<textarea th:id="|comment-input-${bookmark.id}|" placeholder="댓글을 입력하세요..." style="width: 100%;"></textarea>
|
||||||
|
<button th:onclick="submitBookmarkComment([[${bookmark.id}]])" class="button small" style="margin-top: 0.5em;">등록</button>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<div sec:authorize="isAnonymous()" style="padding: 1em; text-align: center; border: 1px dashed #ccc; margin-bottom: 1em;">
|
||||||
|
<p style="margin:0;">댓글을 작성하려면 <a th:href="@{/home.bs(action='login')}">로그인</a>이 필요합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div th:id="|comments-list-${bookmark.id}|" style="margin-top: 1em;">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="swiper-pagination"></div>
|
||||||
|
<div class="swiper-button-prev"></div>
|
||||||
|
<div class="swiper-button-next"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav th:if="${bookmarksPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em;">
|
<div th:if="${bookmarksPage.empty}">
|
||||||
<ul class="pagination">
|
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
|
||||||
<li th:classappend="${bookmarksPage.first} ? 'disabled'">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|||||||
25
src/main/resources/templates/content/error_page.html
Normal file
25
src/main/resources/templates/content/error_page.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<!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="content">
|
||||||
|
<section class="wrapper style1">
|
||||||
|
<div class="container" style="text-align: center; padding: 4em 0;">
|
||||||
|
<header class="major">
|
||||||
|
<h2 th:text="|오류가 발생했습니다 (${statusCode})|">오류가 발생했습니다</h2>
|
||||||
|
<p th:text="${errorMessage}" style="font-size: 1.5em; color: #e85a4f;">오류 메시지</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="box" style="max-width: 600px; margin: 2em auto; text-align: left;">
|
||||||
|
<p th:text="${errorDescription}">
|
||||||
|
오류에 대한 상세 설명입니다. 이 페이지는 접근이 금지되었거나, 요청 처리 중 문제가 발생했을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a th:href="@{/}" class="button primary">홈으로 돌아가기</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -155,6 +155,17 @@
|
|||||||
<label for="visibility-public" class="custom-label">전체 공개</label>
|
<label for="visibility-public" class="custom-label">전체 공개</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control-wrapper" onclick="openBookmarkCategoryPopup('new-bookmark-category-display', 'new-bookmark-category')">
|
||||||
|
<strong>카테고리</strong>
|
||||||
|
<div id="new-bookmark-category-display" class="tag-display-box">카테고리 선택</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="new-bookmark-category">
|
||||||
|
|
||||||
|
<div class="form-control-wrapper" onclick="openBookmarkTagPopup('new-bookmark-tags-display', 'new-bookmark-tags')">
|
||||||
|
<strong>태그</strong>
|
||||||
|
<div id="new-bookmark-tags-display" class="tag-display-box">태그 선택</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="new-bookmark-tags">
|
||||||
<button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button>
|
<button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -162,15 +173,26 @@
|
|||||||
<div class="box" style="margin-top: 2em;">
|
<div class="box" style="margin-top: 2em;">
|
||||||
<h4>저장된 목록</h4>
|
<h4>저장된 목록</h4>
|
||||||
<div id="bookmarks-list" class="row">
|
<div id="bookmarks-list" class="row">
|
||||||
<div class="col-4 col-12-medium">
|
<div class="col-12" th:if="${#lists.isEmpty(myBookmarks)}">
|
||||||
|
<p style="text-align: center; padding: 2em 0;">저장한 페이지가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-4 col-12-medium" th:each="bookmark : ${myBookmarks}" th:id="|bookmark-row-${bookmark.id}|">
|
||||||
<section class="box feature">
|
<section class="box feature">
|
||||||
<a href="#" class="image featured"><img src="/images/pic01.jpg" alt="" /></a>
|
<a th:href="${bookmark.url}" target="_blank" class="image featured">
|
||||||
|
<img th:src="${apiBaseUrl + bookmark.displayImageUrl}" alt="Bookmark Thumbnail" />
|
||||||
|
</a>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<header>
|
<header>
|
||||||
<h2>카드 제목</h2>
|
<h2 th:text="${bookmark.title ?: '제목 없음'}">카드 제목</h2>
|
||||||
<p>사용자 코멘트가 여기에 들어갑니다.</p>
|
<p th:if="${!#strings.isEmpty(bookmark.userComment)}" th:text="${bookmark.userComment}">사용자 코멘트</p>
|
||||||
</header>
|
</header>
|
||||||
<p style="font-size: 0.8em; color: #888;">원본 페이지 설명...</p>
|
<p style="font-size: 0.8em; color: #888;" th:text="${#strings.abbreviate(bookmark.description, 100)}">원본 페이지 설명...</p>
|
||||||
|
|
||||||
|
<div class="actions" style="margin-top: 1em; text-align: right;">
|
||||||
|
<button class="button small" th:onclick="openEditBookmarkModal([[${bookmark.id}]])">수정</button>
|
||||||
|
<button class="button small alt" th:onclick="deleteBookmark([[${bookmark.id}]])">삭제</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -497,6 +519,8 @@
|
|||||||
|
|
||||||
// [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가
|
// [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가
|
||||||
const visibility = document.querySelector('input[name="visibility"]:checked').value;
|
const visibility = document.querySelector('input[name="visibility"]:checked').value;
|
||||||
|
const category = document.getElementById('new-bookmark-category').value;
|
||||||
|
const tags = document.getElementById('new-bookmark-tags').value;
|
||||||
|
|
||||||
const bookmarkData = {
|
const bookmarkData = {
|
||||||
url: urlInput.value.trim(),
|
url: urlInput.value.trim(),
|
||||||
@ -506,7 +530,9 @@
|
|||||||
thumbnailUrl: ogData.thumbnailUrl,
|
thumbnailUrl: ogData.thumbnailUrl,
|
||||||
userComment: comment,
|
userComment: comment,
|
||||||
// [수정] bookmarkData 객체에 visibility 프로퍼티 추가
|
// [수정] bookmarkData 객체에 visibility 프로퍼티 추가
|
||||||
visibility: visibility
|
visibility: visibility,
|
||||||
|
category: category,
|
||||||
|
tags: tags
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!bookmarkData.url) {
|
if (!bookmarkData.url) {
|
||||||
@ -532,6 +558,119 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [수정] 북마크 수정 팝업을 열고 데이터를 채우는 함수
|
||||||
|
*/
|
||||||
|
async function openEditBookmarkModal(bookmarkId) {
|
||||||
|
try {
|
||||||
|
// /api/** 경로는 JWT 인증 헤더(Authorization)가 필요합니다.
|
||||||
|
// 이는 전역 fetch 인터셉터 등에서 처리된다고 가정합니다.
|
||||||
|
const response = await fetch(`/api/bookmarks/${bookmarkId}`,{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`서버 응답: ${response.status}`);
|
||||||
|
}
|
||||||
|
const bookmark = await response.json();
|
||||||
|
|
||||||
|
// 이 부분은 모달 요소가 없어서 실패했었습니다.
|
||||||
|
// 이제 default_layout.html에 요소가 추가되었습니다.
|
||||||
|
document.getElementById('edit-bookmark-id').value = bookmark.id;
|
||||||
|
document.getElementById('edit-bookmark-title').value = bookmark.title || '';
|
||||||
|
document.getElementById('edit-bookmark-comment').value = bookmark.userComment || '';
|
||||||
|
document.getElementById('edit-bookmark-visibility').value = bookmark.visibility || 'PRIVATE';
|
||||||
|
|
||||||
|
document.getElementById('edit-bookmark-category-display').innerHTML = bookmark.category ? `<span class="tag-item">${bookmark.category}</span>` : '카테고리 선택';
|
||||||
|
document.getElementById('edit-bookmark-category').value = bookmark.category || '';
|
||||||
|
|
||||||
|
document.getElementById('edit-bookmark-tags-display').innerHTML = (bookmark.tags || []).map(t => `<span class="tag-item">#${t}</span>`).join(' ') || '태그 선택';
|
||||||
|
document.getElementById('edit-bookmark-tags').value = (bookmark.tags || []).join(',');
|
||||||
|
|
||||||
|
// 공통 openPopup 함수 사용
|
||||||
|
const dummyEl = document.createElement('div');
|
||||||
|
dummyEl.setAttribute('to', '#bookmark-edit-popup');
|
||||||
|
openPopup(dummyEl);
|
||||||
|
|
||||||
|
} catch(error) {
|
||||||
|
console.error("수정 모달 열기 실패:", error);
|
||||||
|
showAlert('오류', '북마크 정보를 불러오는 데 실패했습니다. 로그인 상태를 확인해주세요.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* [수정] 북마크 수정 내용을 서버에 제출하는 함수
|
||||||
|
*/
|
||||||
|
async function submitBookmarkUpdate() {
|
||||||
|
const bookmarkId = document.getElementById('edit-bookmark-id').value;
|
||||||
|
const tagsValue = document.getElementById('edit-bookmark-tags').value;
|
||||||
|
|
||||||
|
const updatedData = {
|
||||||
|
title: document.getElementById('edit-bookmark-title').value,
|
||||||
|
userComment: document.getElementById('edit-bookmark-comment').value,
|
||||||
|
visibility: document.getElementById('edit-bookmark-visibility').value,
|
||||||
|
category: document.getElementById('edit-bookmark-category').value,
|
||||||
|
tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookmarks/${bookmarkId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updatedData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showAlert('성공', '북마크 정보가 수정되었습니다.', 'success');
|
||||||
|
location.reload(); // 변경 사항을 확인하기 위해 페이지 새로고침
|
||||||
|
} else {
|
||||||
|
const errorData = await response.text();
|
||||||
|
showAlert('오류', `수정에 실패했습니다: ${errorData}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating bookmark:', error);
|
||||||
|
showAlert('오류', '네트워크 오류로 수정에 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규] 북마크를 삭제하는 함수.
|
||||||
|
* 'deleteBookmark is not defined' 오류를 해결합니다.
|
||||||
|
*/
|
||||||
|
async function deleteBookmark(bookmarkId) {
|
||||||
|
// common.js의 공통 확인 모달 사용
|
||||||
|
const confirmed = await showConfirm('삭제 확인', '이 북마크를 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.');
|
||||||
|
if (confirmed) {
|
||||||
|
try {
|
||||||
|
// /api/** 경로는 상태가 없는(stateless) JWT 인증을 사용하므로 CSRF 토큰이 필요 없습니다.
|
||||||
|
const response = await fetch(`/api/bookmarks/${bookmarkId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showAlert('성공', '북마크가 삭제되었습니다.', 'success');
|
||||||
|
// 페이지에서 삭제된 항목 제거
|
||||||
|
document.getElementById(`bookmark-row-${bookmarkId}`).remove();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.text();
|
||||||
|
showAlert('오류', `삭제에 실패했습니다: ${errorData}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting bookmark:', error);
|
||||||
|
showAlert('오류', '네트워크 오류로 삭제에 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
@ -55,7 +55,9 @@
|
|||||||
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
|
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
|
||||||
// --- Page-specific (not model data) ---
|
// --- Page-specific (not model data) ---
|
||||||
enc: /*[[${enc ?: ''}]]*/,
|
enc: /*[[${enc ?: ''}]]*/,
|
||||||
keyword: /*[[${keyword ?: ''}]]*/
|
keyword: /*[[${keyword ?: ''}]]*/,
|
||||||
|
// --- [핵심 추가] ---
|
||||||
|
token: /*[[${jwtToken}]]*/
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|||||||
@ -73,6 +73,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="bookmark-edit-popup" class="pop_layer">
|
||||||
|
<div class="pop_container">
|
||||||
|
<div class="pop_conts">
|
||||||
|
<h2>북마크 수정</h2>
|
||||||
|
<input type="hidden" id="edit-bookmark-id">
|
||||||
|
|
||||||
|
<label for="edit-bookmark-title">제목</label>
|
||||||
|
<input type="text" id="edit-bookmark-title" placeholder="페이지 제목">
|
||||||
|
|
||||||
|
<label for="edit-bookmark-comment">내 코멘트</label>
|
||||||
|
<textarea id="edit-bookmark-comment" placeholder="나의 생각 (선택)" rows="3"></textarea>
|
||||||
|
|
||||||
|
<label for="edit-bookmark-visibility">공개 범위</label>
|
||||||
|
<select id="edit-bookmark-visibility" style="width: 100%; padding: 0.5em; border-radius: 4px; border: 1px solid #ddd;">
|
||||||
|
<option value="PRIVATE">비공개</option>
|
||||||
|
<option value="MEMBERS">회원 공개</option>
|
||||||
|
<option value="PUBLIC">전체 공개</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="form-control-wrapper" onclick="openBookmarkCategoryPopup('edit-bookmark-category-display', 'edit-bookmark-category')">
|
||||||
|
<strong>카테고리</strong>
|
||||||
|
<div id="edit-bookmark-category-display" class="tag-display-box">카테고리 선택</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="edit-bookmark-category">
|
||||||
|
|
||||||
|
<div class="form-control-wrapper" onclick="openBookmarkTagPopup('edit-bookmark-tags-display', 'edit-bookmark-tags')">
|
||||||
|
<strong>태그</strong>
|
||||||
|
<div id="edit-bookmark-tags-display" class="tag-display-box">태그 선택</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="edit-bookmark-tags">
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5em; text-align: right;">
|
||||||
|
<button type="button" class="button primary" onclick="submitBookmarkUpdate()">변경사항 저장</button>
|
||||||
|
<a href="#" class="button alt btn_layerClose">취소</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bookmark-category-popup" class="pop_layer">
|
||||||
|
<div class="pop_container">
|
||||||
|
<div class="pop_conts">
|
||||||
|
<h2>카테고리 선택</h2>
|
||||||
|
<div id="selected-bookmark-category-area" class="selected-items-area"></div>
|
||||||
|
<hr>
|
||||||
|
<div id="bookmark-category-list" class="tag-list"></div>
|
||||||
|
<input type="text" id="new-bookmark-category-input" placeholder="새 카테고리 입력 후 Enter">
|
||||||
|
<div style="margin-top: 1.5em;">
|
||||||
|
<button type="button" class="button primary" onclick="applyBookmarkCategory()">적용</button>
|
||||||
|
<a href="#" class="button alt btn_layerClose">취소</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bookmark-tag-popup" class="pop_layer">
|
||||||
|
<div class="pop_container">
|
||||||
|
<div class="pop_conts">
|
||||||
|
<h2>태그 선택</h2>
|
||||||
|
<div id="selected-bookmark-tags-area" class="selected-items-area"></div>
|
||||||
|
<hr>
|
||||||
|
<div id="bookmark-tag-list" class="tag-list"></div>
|
||||||
|
<input type="text" id="new-bookmark-tag-input" placeholder="새 태그 입력 후 Enter">
|
||||||
|
<div style="margin-top: 1.5em;">
|
||||||
|
<button type="button" class="button primary" onclick="applyBookmarkTags()">적용</button>
|
||||||
|
<a href="#" class="button alt btn_layerClose">취소</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="iframe-viewer-popup" class="pop_layer" style="width: 90%; height: 90%; max-width: 1400px;">
|
<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_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;">
|
<div class="pop_header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; border-bottom: 1px solid #eee; background: #f8f8f8;">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user