Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class ConflictService(

val room = tripRoomRepository.findById(roomId)
.orElseThrow { DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.") }
if (room.delYn != YnFlag.N) {
throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.")
}

val profiles = roomMemberProfileRepository.findAllByRoomIdAndDelYn(roomId, YnFlag.N)
if (profiles.size < 2) {
Expand Down Expand Up @@ -54,30 +57,25 @@ class ConflictService(
"${highMember?.nickname ?: "A님"}과 ${lowMember?.nickname ?: "B님"}은 ${axisLabel(it.axis)}에서 ${it.gap}점 차이로 충돌합니다."
} ?: "현재 그룹은 공통 지대가 넓습니다."

val conflictMap = conflictMapRepository.save(
com.tripsync.domain.entity.ConflictMap(
room = room,
commonAxes = analysis.commonAxes.map { it.name.lowercase() },
conflictAxes = analysis.conflictAxes.map {
mapOf(
"axis" to it.axis.name.lowercase(),
"min" to it.min,
"max" to it.max,
"gap" to it.gap,
"severity" to it.severity.name.lowercase(),
"highUserId" to it.highUserId,
"lowUserId" to it.lowUserId,
)
},
summaryText = summaryText,
val commonAxes = analysis.commonAxes.map { it.name.lowercase() }
val conflictAxes = analysis.conflictAxes.map {
mapOf(
"axis" to it.axis.name.lowercase(),
"min" to it.min,
"max" to it.max,
"gap" to it.gap,
"severity" to it.severity.name.lowercase(),
"highUserId" to it.highUserId,
"lowUserId" to it.lowUserId,
)
)
}
val conflictMap = upsertLatestConflictMap(room.id, commonAxes, conflictAxes, summaryText)

return ApiResponse.ok(
mapOf(
"roomId" to roomId,
"conflictMapId" to conflictMap.id,
"commonAxes" to analysis.commonAxes.map { it.name.lowercase() },
"commonAxes" to commonAxes,
"conflictAxes" to analysis.conflictAxes.map {
mapOf(
"axis" to it.axis.name.lowercase(),
Expand All @@ -102,6 +100,35 @@ class ConflictService(
)
}

private fun upsertLatestConflictMap(
roomId: Long,
commonAxes: List<String>,
conflictAxes: List<Map<String, Any>>,
summaryText: String,
): com.tripsync.domain.entity.ConflictMap {
val lockedRoom = tripRoomRepository.findActiveByIdForUpdate(roomId, YnFlag.N)
?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.")
val activeMaps = conflictMapRepository.findAllByRoomIdAndDelYnOrderByCreatedAtDescIdDesc(roomId, YnFlag.N)
val latest = activeMaps.firstOrNull()
activeMaps.drop(1).forEach { it.delYn = YnFlag.Y }

return if (latest != null) {
latest.commonAxes = commonAxes
latest.conflictAxes = conflictAxes
latest.summaryText = summaryText
latest
} else {
conflictMapRepository.save(
com.tripsync.domain.entity.ConflictMap(
room = lockedRoom,
commonAxes = commonAxes,
conflictAxes = conflictAxes,
summaryText = summaryText,
)
)
}
}

private fun validateRoomMember(roomId: Long, userId: Long) {
if (!roomMemberRepository.existsByRoomIdAndUserIdAndDelYn(roomId, userId, YnFlag.N)) {
throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방 멤버만 접근할 수 있습니다.")
Expand Down
114 changes: 85 additions & 29 deletions src/main/kotlin/com/tripsync/application/room/RoomService.kt
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
package com.tripsync.application.room

import com.tripsync.application.schedule.ScheduleResponseMapper
import com.tripsync.application.schedule.ScheduleReadAssembler
import com.tripsync.common.dto.ApiResponse
import com.tripsync.common.exception.DomainException
import com.tripsync.domain.entity.RoomMember
import com.tripsync.domain.entity.RoomMemberProfile
import com.tripsync.domain.entity.Schedule
import com.tripsync.domain.entity.TripRoom
import com.tripsync.domain.entity.User
import com.tripsync.domain.enums.RoomMemberRole
import com.tripsync.domain.enums.TripRoomStatus
import com.tripsync.domain.enums.YnFlag
import com.tripsync.domain.repository.*
import com.tripsync.domain.repository.ConflictMapRepository
import com.tripsync.domain.repository.RoomMemberProfileRepository
import com.tripsync.domain.repository.RoomMemberRepository
import com.tripsync.domain.repository.SatisfactionScoreRepository
import com.tripsync.domain.repository.ScheduleRepository
import com.tripsync.domain.repository.ScheduleSlotRepository
import com.tripsync.domain.repository.TptiResultRepository
import com.tripsync.domain.repository.TripPhotoRepository
import com.tripsync.domain.repository.TripRoomRepository
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.time.LocalDate
import java.util.*
import java.util.UUID

@Service
class RoomService(
Expand All @@ -29,7 +38,7 @@ class RoomService(
private val satisfactionScoreRepository: SatisfactionScoreRepository,
private val conflictMapRepository: ConflictMapRepository,
private val tripPhotoRepository: TripPhotoRepository,
private val scheduleResponseMapper: ScheduleResponseMapper,
private val scheduleReadAssembler: ScheduleReadAssembler,
) {

@Transactional
Expand Down Expand Up @@ -92,9 +101,10 @@ class RoomService(
fun getRoom(roomId: Long, user: User): ApiResponse<Map<String, Any?>> {
val room = getActiveRoom(roomId)
validateRoomMember(room.id, user.id)
val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size
val memberCount = roomMemberRepository.countByRoomIdAndDelYn(room.id, YnFlag.N)
val scheduleSummary = loadScheduleSummaries(listOf(room.id))[room.id] ?: ScheduleSummary.empty()

return ApiResponse.ok(roomSummary(room, memberCount, includeScheduleState = true))
return ApiResponse.ok(roomSummary(room, memberCount, scheduleSummary, includeScheduleState = true))
}

@Transactional(readOnly = true)
Expand All @@ -103,15 +113,22 @@ class RoomService(
throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "로그인한 계정으로만 내 여행 계획을 확인할 수 있습니다.")
}

val rooms = roomMemberRepository.findAllByUserIdAndDelYn(user.id, YnFlag.N)
val activeRooms = roomMemberRepository.findAllByUserIdAndDelYn(user.id, YnFlag.N)
.map { it.room }
.filter { it.delYn == YnFlag.N }
.distinctBy { it.id }
.sortedWith(compareByDescending<TripRoom> { it.createdAt }.thenByDescending { it.id })
.map { room ->
val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size
roomSummary(room, memberCount, includeScheduleState = false)
}
val roomIds = activeRooms.map { it.id }
val memberCounts = loadMemberCounts(roomIds)
val scheduleSummaries = loadScheduleSummaries(roomIds)
val rooms = activeRooms.map { room ->
roomSummary(
room = room,
memberCount = memberCounts[room.id] ?: 0L,
scheduleSummary = scheduleSummaries[room.id] ?: ScheduleSummary.empty(),
includeScheduleState = false,
)
}

return ApiResponse.ok(mapOf("rooms" to rooms))
}
Expand All @@ -120,9 +137,10 @@ class RoomService(
fun getShareRoom(shareCode: String): ApiResponse<Map<String, Any?>> {
val room = tripRoomRepository.findByShareCodeAndDelYn(shareCode, YnFlag.N)
?: throw DomainException(HttpStatus.NOT_FOUND, "INVALID_SHARE_CODE", "유효하지 않은 공유 코드입니다.")
val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size
val memberCount = roomMemberRepository.countByRoomIdAndDelYn(room.id, YnFlag.N)
val scheduleSummary = loadScheduleSummaries(listOf(room.id))[room.id] ?: ScheduleSummary.empty()
return ApiResponse.ok(
roomSummary(room, memberCount, includeScheduleState = false) + mapOf("hostNickname" to room.hostUser.nickname)
roomSummary(room, memberCount, scheduleSummary, includeScheduleState = false) + mapOf("hostNickname" to room.hostUser.nickname)
)
}

Expand Down Expand Up @@ -247,7 +265,6 @@ class RoomService(
return ApiResponse.ok(mapOf("roomId" to room.id, "deleted" to false, "left" to true))
}


private fun upsertMemberProfile(room: TripRoom, user: User, result: com.tripsync.domain.entity.TptiResult): RoomMemberProfile {
val profile = roomMemberProfileRepository.findByRoomIdAndUserId(room.id, user.id)
if (profile == null) {
Expand Down Expand Up @@ -276,7 +293,7 @@ class RoomService(
}

private fun refreshRoomStatus(roomId: Long): TripRoomStatus {
val profileCount = roomMemberProfileRepository.findAllByRoomIdAndDelYn(roomId, YnFlag.N).size
val profileCount = roomMemberProfileRepository.countByRoomIdAndDelYn(roomId, YnFlag.N)
val room = getActiveRoom(roomId)
val next = if (profileCount >= 2) TripRoomStatus.READY else TripRoomStatus.WAITING
room.status = next
Expand All @@ -298,12 +315,12 @@ class RoomService(
}
}

private fun roomSummary(room: TripRoom, memberCount: Int, includeScheduleState: Boolean): Map<String, Any?> {
val schedules = scheduleRepository.findByRoomIdAndDelYn(room.id, YnFlag.N)
val confirmed = schedules
.filter { it.isConfirmed }
.maxWithOrNull(compareBy<com.tripsync.domain.entity.Schedule> { it.version }.thenBy { it.id })
val latestVersion = schedules.maxOfOrNull { it.version }
private fun roomSummary(
room: TripRoom,
memberCount: Long,
scheduleSummary: ScheduleSummary,
includeScheduleState: Boolean,
): Map<String, Any?> {
val base = mapOf(
"roomId" to room.id,
"roomName" to room.roomName,
Expand All @@ -316,25 +333,28 @@ class RoomService(
"hostUserId" to room.hostUser.id,
"memberCount" to memberCount,
"createdAt" to room.createdAt.toString(),
"hasGeneratedSchedule" to schedules.isNotEmpty(),
"confirmedScheduleId" to confirmed?.id,
"latestScheduleVersion" to latestVersion,
"hasGeneratedSchedule" to scheduleSummary.hasGeneratedSchedule,
"confirmedScheduleId" to scheduleSummary.confirmedScheduleId,
"latestScheduleVersion" to scheduleSummary.latestScheduleVersion,
)
if (!includeScheduleState) return base

val latestOptions = latestVersion
?.let { version -> schedules.filter { it.version == version }.sortedBy { it.optionType.ordinal } }
val confirmed = scheduleSummary.confirmedScheduleId
?.let { scheduleRepository.findByIdAndDelYn(it, YnFlag.N) }
val latestOptions = scheduleSummary.latestScheduleVersion
?.let { version -> scheduleRepository.findAllByRoomIdAndDelYnAndVersion(room.id, YnFlag.N, version) }
?.sortedBy { it.optionType.ordinal }
.orEmpty()
val scheduleState = when {
confirmed != null -> mapOf(
"status" to "confirmed",
"confirmedSchedule" to scheduleResponseMapper.formatStoredSchedule(confirmed),
"options" to latestOptions.map { scheduleResponseMapper.formatStoredSchedule(it) },
"confirmedSchedule" to formatSchedule(confirmed),
"options" to latestOptions.map { formatSchedule(it) },
)
latestOptions.isNotEmpty() -> mapOf(
"status" to "generated",
"confirmedSchedule" to null,
"options" to latestOptions.map { scheduleResponseMapper.formatStoredSchedule(it) },
"options" to latestOptions.map { formatSchedule(it) },
)
else -> mapOf(
"status" to "empty",
Expand All @@ -346,6 +366,28 @@ class RoomService(
return base + mapOf("scheduleState" to scheduleState)
}

private fun loadMemberCounts(roomIds: Collection<Long>): Map<Long, Long> {
if (roomIds.isEmpty()) return emptyMap()
return roomMemberRepository.countActiveMembersByRoomIds(roomIds, YnFlag.N)
.associate { it.getRoomId() to it.getMemberCount() }
}

private fun loadScheduleSummaries(roomIds: Collection<Long>): Map<Long, ScheduleSummary> {
if (roomIds.isEmpty()) return emptyMap()
return scheduleRepository.findSummariesByRoomIds(roomIds)
.associate {
it.getRoomId() to ScheduleSummary(
hasGeneratedSchedule = it.getScheduleCount() > 0,
confirmedScheduleId = it.getConfirmedScheduleId(),
latestScheduleVersion = it.getLatestVersion(),
)
}
}

private fun formatSchedule(schedule: Schedule): Map<String, Any?> {
return scheduleReadAssembler.formatStoredSchedule(schedule)
}

private fun normalizeRoomName(roomName: String?, destination: String): String {
val normalized = roomName?.trim()?.takeIf { it.isNotBlank() } ?: defaultRoomName(destination)
if (normalized.length > ROOM_NAME_MAX_LENGTH) {
Expand All @@ -364,6 +406,20 @@ class RoomService(
return "CNAM${LocalDate.now().year.toString().takeLast(2)}$suffix"
}

private data class ScheduleSummary(
val hasGeneratedSchedule: Boolean,
val confirmedScheduleId: Long?,
val latestScheduleVersion: Int?,
) {
companion object {
fun empty() = ScheduleSummary(
hasGeneratedSchedule = false,
confirmedScheduleId = null,
latestScheduleVersion = null,
)
}
}

private companion object {
const val ROOM_NAME_MAX_LENGTH = 100
const val ROOM_NAME_SUFFIX = " 여행 계획"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,8 @@ class ScheduleAccessPolicy(
}

fun getActiveSchedule(scheduleId: Long): Schedule {
val schedule = scheduleRepository.findById(scheduleId)
.orElseThrow { DomainException(HttpStatus.NOT_FOUND, "SCHEDULE_NOT_FOUND", "일정을 찾을 수 없습니다.") }
if (schedule.delYn != YnFlag.N) {
throw DomainException(HttpStatus.NOT_FOUND, "SCHEDULE_NOT_FOUND", "일정을 찾을 수 없습니다.")
}
return schedule
return scheduleRepository.findByIdAndDelYn(scheduleId, YnFlag.N)
?: throw DomainException(HttpStatus.NOT_FOUND, "SCHEDULE_NOT_FOUND", "일정을 찾을 수 없습니다.")
}

fun validateHost(roomId: Long, userId: Long) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class ScheduleGenerationPersistenceService(
): SavedScheduleGeneration {
val room = tripRoomRepository.findActiveByIdForUpdate(roomId, YnFlag.N)
?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.")
val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDesc(room.id, YnFlag.N)?.version ?: 0) + 1
val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDescIdDesc(room.id, YnFlag.N)?.version ?: 0) + 1
val replacementCandidates = placeQueryRepository.findScheduleCandidates(dto.destination)
val saved = options.map { rawOption ->
val option = rawOption.copy(slots = ensureUniqueSlots(rawOption.slots, replacementCandidates))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.tripsync.application.schedule

import com.tripsync.domain.entity.Schedule
import com.tripsync.domain.enums.YnFlag
import com.tripsync.domain.repository.RoomMemberProfileRepository
import com.tripsync.domain.repository.SatisfactionScoreRepository
import com.tripsync.domain.repository.ScheduleSlotRepository
import org.springframework.stereotype.Component

@Component
class ScheduleReadAssembler(
private val roomMemberProfileRepository: RoomMemberProfileRepository,
private val scheduleSlotRepository: ScheduleSlotRepository,
private val satisfactionScoreRepository: SatisfactionScoreRepository,
private val responseMapper: ScheduleResponseMapper,
) {
fun formatStoredSchedule(schedule: Schedule): Map<String, Any?> {
val details = loadDetails(schedule)
return responseMapper.formatStoredSchedule(
schedule = schedule,
memberNicknames = details.memberNicknames,
slots = details.slots,
satisfactionScores = details.satisfactionScores,
)
}

fun formatPublicShareSchedule(schedule: Schedule): Map<String, Any?> {
val details = loadDetails(schedule)
return responseMapper.formatPublicShareSchedule(
schedule = schedule,
memberNicknames = details.memberNicknames,
slots = details.slots,
satisfactionScores = details.satisfactionScores,
)
}

private fun loadDetails(schedule: Schedule): ScheduleReadDetails {
val roomId = schedule.room.id
return ScheduleReadDetails(
memberNicknames = roomMemberProfileRepository.findMemberNicknamesByRoomId(roomId, YnFlag.N)
.associate { it.getUserId() to it.getNickname() },
slots = scheduleSlotRepository.findAllByScheduleIdAndDelYnOrderByOrderIndexAsc(schedule.id, YnFlag.N),
satisfactionScores = satisfactionScoreRepository.findAllByScheduleIdAndDelYn(schedule.id, YnFlag.N),
)
}
}

private data class ScheduleReadDetails(
val memberNicknames: Map<Long, String>,
val slots: List<com.tripsync.domain.entity.ScheduleSlot>,
val satisfactionScores: List<com.tripsync.domain.entity.SatisfactionScore>,
)
Loading
Loading