Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,12 @@ REDIS_PASSWORD=
# DB_PASSWORD=your-db-password

# 🔧 개발 모드 설정
SPRING_PROFILES_ACTIVE=dev
SPRING_PROFILES_ACTIVE=dev

# 🔐 OAuth 2.0 Client Credentials
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
NAVER_CLIENT_ID=your-naver-client-id
NAVER_CLIENT_SECRET=your-naver-client-secret
KAKAO_CLIENT_ID=your-kakao-client-id
KAKAO_CLIENT_SECRET=your-kakao-client-secret
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class CustomOAuth2UserService(
val oAuthUserInfo =
when (provider) {
"google" -> parseGoogle(attributes)
"naver" -> parseNaver(attributes)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 네이버 안한다고하지 않았었나요?

"kakao" -> parseKakao(attributes)
else -> throw IllegalArgumentException("지원하지 않는 소셜 로그인입니다.")
}

Expand Down Expand Up @@ -57,6 +59,33 @@ class CustomOAuth2UserService(
profileImageUrl = attributes["picture"] as String?,
)
}

private fun parseNaver(attributes: Map<String, Any>): OAuthUserInfo {
val response = attributes["response"] as Map<String, Any>

return OAuthUserInfo(
oauthId = response["id"] as String,
email = response["email"] as String,
nickname = response["name"] as String,
profileImageUrl = response["profile_image"] as String?,
)
}

private fun parseKakao(attributes: Map<String, Any>): OAuthUserInfo {
val kakaoAccount = attributes["kakao_account"] as? Map<String, Any>
val profile = kakaoAccount?.get("profile") as? Map<String, Any>
val kakaoId = attributes["id"].toString()

// 카카오는 이메일 못받아서 이렇게 처리했음
val email = kakaoAccount?.get("email") as? String ?: "kakao_$kakaoId@social.login"

return OAuthUserInfo(
oauthId = kakaoId,
email = email,
nickname = profile?.get("nickname") as? String ?: "사용자",
profileImageUrl = profile?.get("profile_image_url") as? String,
)
}
}

data class OAuthUserInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class SecurityConfig(
}

if (!isDev) {
addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthenticationFilter)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
package com.back.koreaTravelGuide.domain.ai.tour.client

import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI

// 09.25 양현준
// 09.26 양현준
@Component
class TourApiClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${tour.api.key}") private val serviceKey: String,
@Value("\${tour.api.base-url}") private val apiUrl: String,
) {
// println 대신 SLF4J 로거 사용
private val logger = LoggerFactory.getLogger(TourApiClient::class.java)

// 요청 URL 구성
private fun buildUrl(params: InternalData): URI =
private fun buildUrl(params: TourSearchParams): URI =
UriComponentsBuilder.fromUri(URI.create(apiUrl))
.path("/areaBasedList2")
.queryParam("serviceKey", serviceKey)
Expand All @@ -35,49 +40,78 @@ class TourApiClient(
.toUri()

// 지역 기반 관광 정보 조회 (areaBasedList2)
fun fetchTourInfo(params: InternalData): TourResponse? {
println("URL 생성")
val url = buildUrl(params)
fun fetchTourInfo(params: TourSearchParams): TourResponse {
logger.info("지역 기반 관광 정보 조회 시작")

println("관광 정보 조회 API 호출: $url")
val url = buildUrl(params)
logger.info("Tour API URL 생성 : $url")

return try {
val jsonResponse = restTemplate.getForObject(url, String::class.java)
println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}")
/*
* runCatching: 예외를 Result로 감싸 예외를 던지지 않고 처리하는 유틸리티 함수
* getOrNull(): 성공 시 응답 문자열을, 실패 시 null 반환
* takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄
* ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환
*/
val response =
runCatching { restTemplate.getForObject(url, String::class.java) }
.onFailure { logger.error("관광 정보 조회 실패", it) }
.getOrNull()
.takeUnless { it.isNullOrBlank() }
?.let { parseItems(it) }

if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때
return response ?: TourResponse(items = emptyList())
}

val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환
val itemsNode =
root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
.path("response")
.path("body")
.path("items")
.path("item")
private fun parseItems(json: String): TourResponse {
val root = objectMapper.readTree(json)

if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우
// header.resultCode 값 추출위한 노스 탐색 과정
val resultCode =
root
.path("response")
.path("header")
.path("resultCode")
.asText()

val firstItem = itemsNode.first()
TourResponse(
contentId = firstItem.path("contentid").asText(),
contentTypeId = firstItem.path("contenttypeid").asText(),
createdTime = firstItem.path("createdtime").asText(),
modifiedTime = firstItem.path("modifiedtime").asText(),
title = firstItem.path("title").asText(),
addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(),
areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(),
firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(),
firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(),
mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(),
mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(),
mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(),
sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(),
lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
)
} catch (e: Exception) {
println("관광 정보 조회 오류: ${e.message}")
null
// resultCode가 "0000"이 아닌 경우 체크
if (resultCode != "0000") {
logger.warn("관광 정보 API resultCode={}", resultCode)
return TourResponse(items = emptyList())
}

// path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
val itemsNode =
root
.path("response")
.path("body")
.path("items")
.path("item")

// 탐색 결과가 비어 있는 경우
if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList())

// itemsNode가 배열이므로 map으로 각 노드를 TourItem으로 변환 후 컨테이너로 감싼다.
val items =
itemsNode.map { node ->
TourItem(
contentId = node.path("contentid").asText(),
contentTypeId = node.path("contenttypeid").asText(),
createdTime = node.path("createdtime").asText(),
modifiedTime = node.path("modifiedtime").asText(),
title = node.path("title").asText(),
addr1 = node.path("addr1").takeIf { it.isTextual }?.asText(),
areaCode = node.path("areacode").takeIf { it.isTextual }?.asText(),
firstimage = node.path("firstimage").takeIf { it.isTextual }?.asText(),
firstimage2 = node.path("firstimage2").takeIf { it.isTextual }?.asText(),
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
mlevel = node.path("mlevel").takeIf { it.isTextual }?.asText(),
sigunguCode = node.path("sigungucode").takeIf { it.isTextual }?.asText(),
lDongRegnCd = node.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
lDongSignguCd = node.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
)
}

return TourResponse(items = items)
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

/**
* 9.25 양현준
* 9.27 양현준
* 관광 정보 응답 DTO
* API 매뉴얼에서 필수인 값은 NonNull로 지정.
*/

data class TourResponse(
// 콘텐츠ID (고유 번호)
val items: List<TourItem>,
)

// 관광 정보 단일 아이템
data class TourItem(
// 콘텐츠ID (고유 번호, NonNull)
val contentId: String,
// 관광타입 ID (12: 관광지, 14: 문화시설 ..)
// 관광타입 ID (12: 관광지, NonNull)
val contentTypeId: String,
// 등록일
// 등록일 (NonNull)
val createdTime: String,
// 수정일
// 수정일 (NonNull)
val modifiedTime: String,
// 제목
// 제목 (NonNull)
val title: String,
// 주소
val addr1: String?,
Expand All @@ -32,7 +38,7 @@ data class TourResponse(
val mlevel: String?,
// 시군구코드
val sigunguCode: String?,
// 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로,
// 법정동 시도 코드
val lDongRegnCd: String?,
// 법정동 시군구 코드
val lDongSignguCd: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

/**
* 9.25 양현준
* 관광 정보 호출용 파라미터
* 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
* 9.27 양현준
* API 요청 파라미터
* 기능상, 생략 가능한 필드는 생략 (arrange : 제목 , cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
*/

data class InternalData(
data class TourSearchParams(
// 한 페이지 데이터 수, 10으로 지정
val numOfRows: Int = 10,
val numOfRows: Int = DEFAULT_ROWS,
// 페이지 번호, 1로 지정
val pageNo: Int = 1,
val pageNo: Int = DEFAULT_PAGE,
// 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...),
val contentTypeId: String? = "",
val contentTypeId: String? = null,
// 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...)
val areaCode: String? = "",
val areaCode: String? = null,
// 시군구코드, 미 입력시 전체 조회
val sigunguCode: String? = "",
)
val sigunguCode: String? = null,
) {
companion object {
const val DEFAULT_ROWS = 10
const val DEFAULT_PAGE = 1
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,50 @@
package com.back.koreaTravelGuide.domain.ai.tour.service

// TODO: 관광 정보 캐싱 서비스 - 캐시 관리 및 데이터 제공
class TourService
import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

// 09.26 양현준
@Service
class TourService(
private val tourApiClient: TourApiClient,
) {
private val logger = LoggerFactory.getLogger(this::class.java)

// 관광 정보 조회
fun fetchTours(
numOfRows: Int? = null,
pageNo: Int? = null,
contentTypeId: String? = null,
areaCode: String? = null,
sigunguCode: String? = null,
): TourResponse {
// null 또는 비정상 값은 기본값으로 대체
val request =
TourSearchParams(
numOfRows = numOfRows?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_ROWS,
pageNo = pageNo?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_PAGE,
contentTypeId = contentTypeId?.ifBlank { null } ?: "",
areaCode = areaCode?.ifBlank { null } ?: "",
sigunguCode = sigunguCode?.ifBlank { null } ?: "",
)

// request를 바탕으로 관광 정보 API 호출
val tours = tourApiClient.fetchTourInfo(request)

// 관광 정보 결과 로깅
if (tours.items.isEmpty()) {
logger.info(
"관광 정보 없음: params={} / {} {}",
request.areaCode,
request.sigunguCode,
request.contentTypeId,
)
} else {
logger.info("관광 정보 {}건 조회 성공", tours.items.size)
}
return tours
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.back.koreaTravelGuide.domain.userChat

import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.concurrent.ConcurrentHashMap

// Websocket,Stomp 사용 전 임시로 만들었음
// 테스트 후 제거 예정

@Component
class UserChatSseEvents {
private val emitters = ConcurrentHashMap<Long, MutableList<SseEmitter>>()

fun subscribe(roomId: Long): SseEmitter {
val emitter = SseEmitter(0L)
emitters.computeIfAbsent(roomId) { mutableListOf() }.add(emitter)
emitter.onCompletion { emitters[roomId]?.remove(emitter) }
emitter.onTimeout { emitter.complete() }
return emitter
}

fun publishNew(
roomId: Long,
lastMessageId: Long,
) {
emitters[roomId]?.toList()?.forEach {
try {
it.send(SseEmitter.event().name("NEW").data(lastMessageId))
} catch (_: Exception) {
it.complete()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ data class ChatMessage(
val roomId: Long,
@Column(name = "sender_id", nullable = false)
val senderId: Long,
@Column(columnDefinition = "text", nullable = false)
@Column(nullable = false, columnDefinition = "text")
val content: String,
@Column(name = "created_at", nullable = false)
val createdAt: Instant = Instant.now(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
roomId: Long,
afterId: Long,
): List<ChatMessage>

fun deleteByRoomId(roomId: Long)
}
Loading