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
25 changes: 25 additions & 0 deletions src/main/kotlin/com/tripsync/application/auth/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.tripsync.application.auth
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import jakarta.annotation.PostConstruct
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.env.Environment
import org.springframework.stereotype.Component
import java.security.MessageDigest
import java.util.*
Expand All @@ -15,11 +17,19 @@ class JwtTokenProvider(
private val secret: String,
@Value("\${jwt.expiration:604800000}")
private val expiration: Long,
private val environment: Environment,
) {
companion object {
private const val MIN_HS256_KEY_BYTES = 32
private val DEV_PROFILES = setOf("local", "test")
private val WEAK_DEFAULT_SECRETS = setOf(
"your-256-bit-secret-key-here-for-development-only",
"local-development-secret-key-256-bits-min",
"test-development-secret-key-256-bits-min",
)
}
private val key: SecretKey by lazy {
validateSecret()
val rawBytes = secret.toByteArray(Charsets.UTF_8)
val keyBytes = if (rawBytes.size >= MIN_HS256_KEY_BYTES) {
rawBytes
Expand All @@ -29,6 +39,11 @@ class JwtTokenProvider(
Keys.hmacShaKeyFor(keyBytes)
}

@PostConstruct
fun validateConfiguration() {
validateSecret()
}

fun generateToken(userId: Long, isGuest: Boolean): String {
val now = Date()
val expiry = Date(now.time + expiration)
Expand Down Expand Up @@ -66,4 +81,14 @@ class JwtTokenProvider(
.parseSignedClaims(token)
.payload
}

private fun validateSecret() {
val normalized = secret.trim()
require(normalized.isNotBlank()) { "JWT_SECRET must be configured" }
val activeProfiles = environment.activeProfiles.toSet()
val isDevProfile = activeProfiles.any { it in DEV_PROFILES }
require(isDevProfile || normalized !in WEAK_DEFAULT_SECRETS) {
"JWT_SECRET must not use a development default outside local/test profiles"
}
}
}
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 @@ -35,6 +35,7 @@ class OAuthSessionService(
@Value("\${kakao.authorize-url:https://kauth.kakao.com/oauth/authorize}") private val kakaoAuthorizeUrl: String,
@Value("\${kakao.token-url:https://kauth.kakao.com/oauth/token}") private val kakaoTokenUrl: String,
@Value("\${kakao.user-info-url:https://kapi.kakao.com/v2/user/me}") private val kakaoUserInfoUrl: String,
@Value("\${oauth.dev-fallback-enabled:false}") private val devFallbackEnabled: Boolean,
) {
data class OAuthRedirect(val state: String, val redirectUrl: String)
data class OAuthResult(val user: User, val redirectUrl: String)
Expand All @@ -48,6 +49,7 @@ class OAuthSessionService(
val clientId = clientId(provider)

if (clientId.isBlank()) {
ensureDevOAuthFallbackAllowed(provider)
return OAuthRedirect(
state = state,
redirectUrl = "$callbackUrl?code=local-${provider.name.lowercase()}-code&state=${enc(combinedState)}&redirectPath=${enc(normalizedRedirectPath)}",
Expand Down Expand Up @@ -91,6 +93,7 @@ class OAuthSessionService(

private fun fetchOAuthProfile(provider: AuthProvider, code: String): OAuthProfile {
if (clientId(provider).isBlank()) {
ensureDevOAuthFallbackAllowed(provider)
return OAuthProfile(
providerUserId = code,
nickname = if (provider == AuthProvider.KAKAO) "kakao-host" else "google-host",
Expand Down Expand Up @@ -189,6 +192,11 @@ class OAuthSessionService(
private fun userInfoUrl(provider: AuthProvider) = if (provider == AuthProvider.KAKAO) kakaoUserInfoUrl else googleUserInfoUrl
private fun clientId(provider: AuthProvider) = if (provider == AuthProvider.KAKAO) kakaoClientId else googleClientId
private fun clientSecret(provider: AuthProvider) = if (provider == AuthProvider.KAKAO) kakaoClientSecret else googleClientSecret
private fun ensureDevOAuthFallbackAllowed(provider: AuthProvider) {
if (!devFallbackEnabled) {
throw DomainException(HttpStatus.SERVICE_UNAVAILABLE, "OAUTH_NOT_CONFIGURED", "${provider.name.lowercase()} OAuth 설정이 필요합니다.")
}
}
private fun enc(value: String) = URLEncoder.encode(value, StandardCharsets.UTF_8)
private fun query(params: Map<String, String>) = params.entries.joinToString("&") { "${enc(it.key)}=${enc(it.value)}" }
}
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 @@ -71,6 +71,7 @@ class ScheduleResponseMapper(
val metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id })
return mapOf(
"id" to schedule.id,
"shareToken" to schedule.shareToken,
"roomId" to schedule.room.id,
"destination" to schedule.room.destination,
"tripDate" to schedule.room.tripStartDate.toString(),
Expand Down Expand Up @@ -122,6 +123,7 @@ class ScheduleResponseMapper(
fun formatPublicShareSchedule(schedule: Schedule): Map<String, Any?> {
val publicKeys = setOf(
"id",
"shareToken",
"roomId",
"destination",
"tripDate",
Expand All @@ -139,9 +141,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 +177,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 +192,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 +222,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 +243,7 @@ class ScheduleResponseMapper(
private data class PlaceImage(
val url: String?,
val source: String?,
val fallbackUrl: String? = null,
val fallbackSource: String? = null,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ class ScheduleService(
return ApiResponse.ok(
mapOf(
"scheduleId" to target.id,
"shareToken" to target.shareToken,
"roomId" to roomId,
"optionType" to target.optionType.name.lowercase(),
"status" to "confirmed",
Expand Down Expand Up @@ -244,8 +245,9 @@ class ScheduleService(
}

@Transactional(readOnly = true)
fun getPublicShareSchedule(scheduleId: Long): ApiResponse<Map<String, Any?>> {
val schedule = accessPolicy.getActiveSchedule(scheduleId)
fun getPublicShareSchedule(shareToken: String): ApiResponse<Map<String, Any?>> {
val schedule = scheduleRepository.findByShareTokenAndDelYn(shareToken, YnFlag.N)
?: throw DomainException(HttpStatus.NOT_FOUND, "SCHEDULE_NOT_FOUND", "공유 일정을 찾을 수 없습니다.")
return ApiResponse.ok(responseMapper.formatPublicShareSchedule(schedule))
}

Expand Down
Loading
Loading