Skip to content
Merged
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
71 changes: 30 additions & 41 deletions src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@ import com.tripsync.common.dto.ApiResponse
import com.tripsync.common.exception.DomainException
import com.tripsync.domain.entity.User
import com.tripsync.domain.enums.AuthProvider
import com.tripsync.domain.enums.YnFlag
import com.tripsync.domain.repository.UserRepository
import mu.KotlinLogging
import org.springframework.http.HttpStatus
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service

Expand All @@ -18,8 +15,6 @@ class OAuth2UserService(
private val userRepository: UserRepository,
private val jwtTokenProvider: JwtTokenProvider,
) : DefaultOAuth2UserService() {
private val logger = KotlinLogging.logger {}

fun processGoogleLogin(oAuth2User: OAuth2User): ApiResponse<Map<String, Any>> {
val email = oAuth2User.getAttribute<String>("email")
?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_EMAIL_MISSING", "이메일 정보를 가져올 수 없습니다.")
Expand All @@ -28,33 +23,14 @@ class OAuth2UserService(
val providerId = oAuth2User.getAttribute<String>("sub")
?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_ID_MISSING", "사용자 ID를 가져올 수 없습니다.")

val existingUser = userRepository.findByAuthProviderAndProviderUserId(AuthProvider.GOOGLE, providerId)

val user = if (existingUser != null) {
existingUser
} else {
userRepository.save(
User(
nickname = name,
email = email,
authProvider = AuthProvider.GOOGLE,
providerUserId = providerId,
profileImageUrl = picture,
)
)
}

val token = jwtTokenProvider.generateToken(user.id, user.isGuest)
@Suppress("UNCHECKED_CAST")
return ApiResponse.ok(
mapOf(
"token" to token,
"userId" to user.id,
"nickname" to user.nickname,
"email" to (user.email ?: ""),
"authProvider" to user.authProvider.name,
) as Map<String, Any>
val user = findOrCreateOAuthUser(
provider = AuthProvider.GOOGLE,
providerId = providerId,
nickname = name,
email = email,
profileImageUrl = picture,
)
return authResponse(user)
}

fun processKakaoLogin(oAuth2User: OAuth2User): ApiResponse<Map<String, Any>> {
Expand All @@ -68,32 +44,45 @@ class OAuth2UserService(
val providerId = attributes["id"]?.toString()
?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_ID_MISSING", "사용자 ID를 가져올 수 없습니다.")

val existingUser = userRepository.findByAuthProviderAndProviderUserId(AuthProvider.KAKAO, providerId)
val user = findOrCreateOAuthUser(
provider = AuthProvider.KAKAO,
providerId = providerId,
nickname = nickname,
email = email,
profileImageUrl = profileImage,
)
return authResponse(user)
}

val user = if (existingUser != null) {
existingUser
} else {
userRepository.save(
private fun findOrCreateOAuthUser(
provider: AuthProvider,
providerId: String,
nickname: String,
email: String?,
profileImageUrl: String?,
): User {
return userRepository.findByAuthProviderAndProviderUserId(provider, providerId)
?: userRepository.save(
User(
nickname = nickname,
email = email,
authProvider = AuthProvider.KAKAO,
authProvider = provider,
providerUserId = providerId,
profileImageUrl = profileImage,
profileImageUrl = profileImageUrl,
)
)
}
}

private fun authResponse(user: User): ApiResponse<Map<String, Any>> {
val token = jwtTokenProvider.generateToken(user.id, user.isGuest)
@Suppress("UNCHECKED_CAST")
return ApiResponse.ok(
mapOf(
"token" to token,
"userId" to user.id,
"nickname" to user.nickname,
"email" to (user.email ?: ""),
"authProvider" to user.authProvider.name,
) as Map<String, Any>
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class PersonaValidationService(
(slotScores.average() * 100).toInt().coerceIn(0, 100)
}

val positiveSignals = buildPositiveSignals(option, members)
val positiveSignals = buildPositiveSignals(option, acceptanceScore)
val objections = buildObjections(option, members)
val persuasions = buildPersuasionPoints(option)

Expand All @@ -107,10 +107,27 @@ class PersonaValidationService(
}
}

private fun buildPositiveSignals(option: ScheduleOptionForValidation, members: List<MemberSnapshot>): List<String> {
private fun buildPositiveSignals(option: ScheduleOptionForValidation, acceptanceScore: Int): List<String> {
val commonSlotCount = option.slots.count { it.slotType == SlotType.COMMON }
val leadingAxes = option.slots
.flatMap { slot -> slot.scores.leadingAxisLabels() }
.groupingBy { it }
.eachCount()
.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.map { it.key }
.take(2)

val preferenceSummary = when (leadingAxes.size) {
0 -> "무리 없는 일정 밀도와 균형 잡힌 장소 구성이 좋게 평가됩니다."
1 -> "${leadingAxes[0]} 성향의 여행자에게 일정 구성이 잘 맞습니다."
else -> "${leadingAxes.joinToString("·")} 성향의 여행자에게 일정 구성이 잘 맞습니다."
}

return listOf(
"${members.size}명의 취향이 ${option.slots.count { it.slotType == SlotType.COMMON }}개 공통 슬롯에서 조화를 이룹니다.",
"그룹 만족도 ${option.groupSatisfaction}점으로 안정적인 선택입니다.",
preferenceSummary,
"${commonSlotCount}개 공통 코스로 동행자와 함께 움직이기 쉽습니다.",
"참고군 기준 수용도가 ${acceptanceScore}점으로 안정적입니다.",
)
}

Expand All @@ -123,8 +140,34 @@ class PersonaValidationService(
}

private fun buildPersuasionPoints(option: ScheduleOptionForValidation): List<String> {
return option.slots.filter { it.slotType == SlotType.PERSONAL }.map { slot ->
slot.reasonText.trim().trimEnd('.', '!', '?', '。')
}.distinct()
return option.slots
.filter { it.slotType == SlotType.PERSONAL }
.mapNotNull { slot -> slot.reasonText.toPersonaFriendlyReason() }
.distinct()
}

private fun AxisScores.leadingAxisLabels(): List<String> {
return listOf(
mobility to "이동을 즐기는",
photo to "사진 기록을 중시하는",
budget to "예산 효율을 보는",
theme to "테마 경험을 선호하는",
)
.filter { (score, _) -> score >= 60 }
.sortedByDescending { (score, _) -> score }
.map { (_, label) -> label }
.take(2)
}

private fun String.toPersonaFriendlyReason(): String? {
val cleaned = trim().trimEnd('.', '!', '?', '。').takeIf { it.isNotBlank() } ?: return null
return when {
cleaned.contains("사진") || cleaned.contains("기록") -> "사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다."
cleaned.contains("예산") -> "부담 없는 비용 흐름이라 실속형 여행자도 수용하기 좋습니다."
cleaned.contains("테마") -> "여행 테마가 분명해 취향이 비슷한 참고군에게 설득력이 있습니다."
cleaned.contains("이동") || cleaned.contains("활동성") -> "이동 부담을 고려한 구성이라 활동형 여행자도 따라가기 쉽습니다."
cleaned.contains("취향 반영") -> null
else -> cleaned
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,8 @@ class ScheduleResponseMapper(
return formatStoredSchedule(schedule).filterKeys { it in publicKeys }
}

@Suppress("UNCHECKED_CAST")
private fun formatLlmMetadata(schedule: Schedule): Map<String, Any?> {
val metadata = schedule.generationInput["llm"] as? Map<String, Any?> ?: emptyMap()
val metadata = schedule.generationInput["llm"] as? Map<*, *> ?: emptyMap<String, Any?>()
val provider = schedule.llmProvider ?: metadata["provider"]
return mapOf(
"provider" to provider,
Expand Down Expand Up @@ -176,6 +175,8 @@ class ScheduleResponseMapper(
return mapOf(
"imageUrl" to image.url,
"imageSource" to image.source,
"fallbackImageUrl" to image.fallbackUrl,
"fallbackImageSource" to image.fallbackSource,
"isRegionalBenefit" to isRegionalBenefit(place?.metadataTags, metric),
"popularity" to formatPopularity(metric, place?.metadataTags),
)
Expand All @@ -189,10 +190,13 @@ class ScheduleResponseMapper(

private fun formatImage(place: Place?, metric: ExternalPopularityMetric?, placeId: Long): PlaceImage {
val tourApiImage = place?.imageUrl?.trim()?.takeIf { it.isNotBlank() }
if (tourApiImage != null) return PlaceImage(tourApiImage, "tourapi")
val hasGooglePhoto = metric?.googlePhotoReference?.isNotBlank() == true
return if (hasGooglePhoto) {
PlaceImage("${apiBaseUrl.trimEnd('/')}/places/$placeId/photo", "google_places")
val googleImage = if (hasGooglePhoto) "${apiBaseUrl.trimEnd('/')}/places/$placeId/photo" else null

return if (tourApiImage != null) {
PlaceImage(tourApiImage, "tourapi", googleImage, if (googleImage != null) "google_places" else null)
} else if (googleImage != null) {
PlaceImage(googleImage, "google_places")
} else {
PlaceImage(null, null)
}
Expand All @@ -216,6 +220,11 @@ class ScheduleResponseMapper(
"role" to role,
"label" to label,
"hasExternalSignal" to (score != null),
"normalizedPopularityScore" to score,
"naverSearchTrendScore" to metric?.naverSearchTrendScore,
"googleRating" to metric?.googleRating?.toDouble(),
"googleUserRatingCount" to metric?.googleUserRatingCount,
"sourceUpdatedAt" to metric?.updatedAt?.toString(),
)
}

Expand All @@ -232,5 +241,7 @@ class ScheduleResponseMapper(
private data class PlaceImage(
val url: String?,
val source: String?,
val fallbackUrl: String? = null,
val fallbackSource: String? = null,
)
}
7 changes: 4 additions & 3 deletions src/main/kotlin/com/tripsync/web/auth/AuthController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.tripsync.application.auth.JwtAuthenticationFilter
import com.tripsync.application.auth.JwtTokenProvider
import com.tripsync.application.auth.OAuthSessionService
import com.tripsync.common.dto.ApiResponse
import com.tripsync.common.exception.DomainException
import com.tripsync.common.security.CurrentUser
import com.tripsync.domain.entity.User
import com.tripsync.domain.enums.AuthProvider
Expand Down Expand Up @@ -37,7 +38,7 @@ class AuthController(
fun register(@Valid @RequestBody dto: RegisterDto, response: HttpServletResponse): ApiResponse<Map<String, Any>> {
val email = dto.email.trim().lowercase()
if (userRepository.findByEmailAndDelYn(email, YnFlag.N) != null) {
return ApiResponse.error("INVALID_REQUEST", "이미 사용 중인 이메일입니다.")
throw DomainException(HttpStatus.CONFLICT, "INVALID_REQUEST", "이미 사용 중인 이메일입니다.")
}

val user = userRepository.save(
Expand All @@ -57,10 +58,10 @@ class AuthController(
@PostMapping("/login")
fun login(@Valid @RequestBody dto: LoginDto, response: HttpServletResponse): ApiResponse<Map<String, Any>> {
val user = userRepository.findByEmailAndDelYn(dto.email.trim().lowercase(), YnFlag.N)
?: return ApiResponse.error("UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.")
?: throw DomainException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.")

if (user.authProvider != AuthProvider.LOCAL || user.passwordHash == null || !passwordEncoder.matches(dto.password, user.passwordHash)) {
return ApiResponse.error("UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.")
throw DomainException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.")
}

val token = issueSessionCookie(user, response)
Expand Down
37 changes: 37 additions & 0 deletions src/test/kotlin/com/tripsync/AuthContractTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,43 @@ class AuthContractTests(
}
}

@Test
fun `duplicate register returns conflict instead of success false payload`() {
val email = "duplicate-${System.nanoTime()}@example.com"

mockMvc.post("/auth/register") {
contentType = MediaType.APPLICATION_JSON
content = """{"email":"$email","password":"password123","nickname":"중복"}"""
}.andExpect {
status { isCreated() }
jsonPath("$.success") { value(true) }
}

mockMvc.post("/auth/register") {
contentType = MediaType.APPLICATION_JSON
content = """{"email":"$email","password":"password123","nickname":"중복"}"""
}.andExpect {
status { isConflict() }
jsonPath("$.success") { value(false) }
jsonPath("$.error.message") { value("이미 사용 중인 이메일입니다.") }
}
}

@Test
fun `invalid local login returns unauthorized instead of success false payload`() {
val email = "login-fail-${System.nanoTime()}@example.com"
registerSession(email, "로그인실패")

mockMvc.post("/auth/login") {
contentType = MediaType.APPLICATION_JSON
content = """{"email":"$email","password":"wrong-password"}"""
}.andExpect {
status { isUnauthorized() }
jsonPath("$.success") { value(false) }
jsonPath("$.error.message") { value("이메일 또는 비밀번호가 올바르지 않습니다.") }
}
}

@Test
fun `cookie session authenticates me and tpti submit then public share`() {
val guest = mockMvc.post("/auth/guest") {
Expand Down
Loading
Loading