Skip to content

Commit 12cceb9

Browse files
authored
feat: 사용자별 여행 계획 자동 생성 및 관리 기능 (#45)
* feat: TripPlan 도메인 모델 및 ExceptionCode 추가 (#44) * feat: TripPlanRequest 요청 대기열 도메인 모델 추가 (#44) * feat: TripPlan UseCase 및 Persistence Port 인터페이스 정의 (#44) * feat: TripPlan 영속성 계층 구현 (Entity, Repository, Adapter) (#44) * feat: TripPlanRequest 요청 대기열 영속성 계층 구현 (#44) * feat: TripPlanService 구현 (자동 생성, 조회, 수정, 삭제) (#44) * feat: TripPlanController 및 요청/응답 DTO 추가 (#44) * feat: VideoController에 TripPlan 대기열 등록 및 lazy 보완 적용 (#44) * feat: EventListener 대기열 처리 및 알림에 요청자 memberIds 전달 (#44) * test: TripPlan 기능 추가에 따른 EventListener 테스트 수정 (#44) * refactor: @Valid@validated 스프링 애노테이션으로 변경 (#44) * refactor: 모든 DB 매핑 도메인 모델에 createdAt/updatedAt 추가 (#44) * refactor: ExceptionCode를 상태코드 프리픽스 네이밍으로 통일 및 JwtAuthenticationFilter 응답 직렬화 개선 (#44) * refactor: soft delete를 BaseTimeEntity로 통합, 전체 쿼리에 deleted 필터 적용, 영상 데이터 삭제/수정 불가 보호 (#44) * test: TripPlanService 단위 테스트 작성 (#44) * refactor: 페이징 default size를 PaginationDefaults로 통일 관리 (#44) * feat: 앱 시작 시 GCP credentials 파일 존재 검증 추가 (#44) * fix: PENDING 상태에서 placeEnrichmentCompleted 오표시 수정 및 202 응답 적용 (#44) * refactor: 전체 엔티티 인덱스 최적화 - UK 추가, 중복 제거, 복합 인덱스 강화 (#44) * fix: 코드리뷰 반영 - deleted 필터 누락, soft delete 필터 적용, 실패 격리, 검증 강화, hard delete 제거 (#44)
1 parent 373d563 commit 12cceb9

63 files changed

Lines changed: 1801 additions & 84 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.linktrip.application.domain.trip
2+
3+
/**
4+
* 추후 구현 예정
5+
*
6+
* 여행 폴더 - 여러 TripPlan을 그룹화하는 기능
7+
*/
8+
internal object TripFolder
9+
// data class TripFolder(
10+
// val id: String,
11+
// val memberId: String,
12+
// val name: String,
13+
// )
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.linktrip.application.domain.trip
2+
3+
import com.linktrip.application.domain.common.IdGenerator
4+
import java.time.LocalDateTime
5+
6+
data class TripPlan(
7+
val id: String,
8+
val memberId: String,
9+
val videoAnalysisTaskId: String,
10+
val title: String,
11+
val createdAt: LocalDateTime = LocalDateTime.now(),
12+
val updatedAt: LocalDateTime = LocalDateTime.now(),
13+
) {
14+
companion object {
15+
fun create(
16+
memberId: String,
17+
videoAnalysisTaskId: String,
18+
title: String,
19+
): TripPlan =
20+
TripPlan(
21+
id = IdGenerator.generate(),
22+
memberId = memberId,
23+
videoAnalysisTaskId = videoAnalysisTaskId,
24+
title = title,
25+
)
26+
}
27+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.linktrip.application.domain.trip
2+
3+
import com.linktrip.application.domain.common.IdGenerator
4+
import java.time.LocalDateTime
5+
6+
data class TripPlanItem(
7+
val id: String,
8+
val tripPlanId: String,
9+
val travelItineraryItemId: String,
10+
val day: Int,
11+
val itemOrder: Int,
12+
val createdAt: LocalDateTime = LocalDateTime.now(),
13+
val updatedAt: LocalDateTime = LocalDateTime.now(),
14+
) {
15+
companion object {
16+
fun create(
17+
tripPlanId: String,
18+
travelItineraryItemId: String,
19+
day: Int,
20+
itemOrder: Int,
21+
): TripPlanItem =
22+
TripPlanItem(
23+
id = IdGenerator.generate(),
24+
tripPlanId = tripPlanId,
25+
travelItineraryItemId = travelItineraryItemId,
26+
day = day,
27+
itemOrder = itemOrder,
28+
)
29+
}
30+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.linktrip.application.domain.trip
2+
3+
import com.linktrip.application.domain.common.IdGenerator
4+
import java.time.LocalDateTime
5+
6+
data class TripPlanRequest(
7+
val id: String,
8+
val memberId: String,
9+
val videoAnalysisTaskId: String,
10+
val processed: Boolean = false,
11+
val createdAt: LocalDateTime = LocalDateTime.now(),
12+
val updatedAt: LocalDateTime = LocalDateTime.now(),
13+
) {
14+
companion object {
15+
fun create(
16+
memberId: String,
17+
videoAnalysisTaskId: String,
18+
): TripPlanRequest =
19+
TripPlanRequest(
20+
id = IdGenerator.generate(),
21+
memberId = memberId,
22+
videoAnalysisTaskId = videoAnalysisTaskId,
23+
)
24+
}
25+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.linktrip.application.domain.trip
2+
3+
import com.linktrip.application.domain.common.CursorPage
4+
import com.linktrip.application.port.input.TripPlanDetail
5+
import com.linktrip.application.port.input.TripPlanItemDetail
6+
import com.linktrip.application.port.input.TripPlanSummary
7+
import com.linktrip.application.port.input.TripPlanUseCase
8+
import com.linktrip.application.port.input.UpdateTripPlanCommand
9+
import com.linktrip.application.port.output.persistence.TravelItineraryItemPersistencePort
10+
import com.linktrip.application.port.output.persistence.TripPlanItemPersistencePort
11+
import com.linktrip.application.port.output.persistence.TripPlanPersistencePort
12+
import com.linktrip.application.port.output.persistence.TripPlanRequestPersistencePort
13+
import com.linktrip.common.exception.ExceptionCode
14+
import com.linktrip.common.exception.LinktripException
15+
import org.springframework.stereotype.Service
16+
import org.springframework.transaction.annotation.Transactional
17+
import java.time.LocalDateTime
18+
19+
@Service
20+
class TripPlanService(
21+
private val planPort: TripPlanPersistencePort,
22+
private val planItemPort: TripPlanItemPersistencePort,
23+
private val itineraryItemPort: TravelItineraryItemPersistencePort,
24+
private val requestPort: TripPlanRequestPersistencePort,
25+
) : TripPlanUseCase {
26+
@Transactional
27+
override fun registerRequest(
28+
memberId: String,
29+
videoAnalysisTaskId: String,
30+
) {
31+
if (requestPort.existsByMemberIdAndVideoAnalysisTaskId(memberId, videoAnalysisTaskId)) return
32+
requestPort.save(TripPlanRequest.create(memberId, videoAnalysisTaskId))
33+
}
34+
35+
@Transactional
36+
override fun createFromAnalysisIfAbsent(
37+
memberId: String,
38+
videoAnalysisTaskId: String,
39+
title: String,
40+
) {
41+
if (memberId.isBlank()) return
42+
43+
if (planPort.existsByMemberIdAndVideoAnalysisTaskId(memberId, videoAnalysisTaskId)) {
44+
return
45+
}
46+
47+
val itineraryItems = itineraryItemPort.findByVideoAnalysisTaskId(videoAnalysisTaskId)
48+
if (itineraryItems.isEmpty()) return
49+
50+
val tripPlan =
51+
TripPlan.create(
52+
memberId = memberId,
53+
videoAnalysisTaskId = videoAnalysisTaskId,
54+
title = title,
55+
)
56+
val savedTripPlan = planPort.save(tripPlan)
57+
58+
val tripPlanItems =
59+
itineraryItems.map { item ->
60+
TripPlanItem.create(
61+
tripPlanId = savedTripPlan.id,
62+
travelItineraryItemId = item.id,
63+
day = item.day,
64+
itemOrder = item.itemOrder,
65+
)
66+
}
67+
planItemPort.saveAll(tripPlanItems)
68+
}
69+
70+
@Transactional(readOnly = true)
71+
override fun getTripPlans(
72+
memberId: String,
73+
cursor: LocalDateTime?,
74+
size: Int,
75+
): CursorPage<TripPlanSummary> {
76+
val fetchSize = size + 1
77+
val rows = planPort.findSummariesByMemberId(memberId, cursor, fetchSize)
78+
val hasNext = rows.size > size
79+
val items = rows.take(size)
80+
81+
val summaries =
82+
items.map { row ->
83+
TripPlanSummary(
84+
tripPlan = row.tripPlan,
85+
youtubeUrl = row.youtubeUrl,
86+
itemCount = row.activeItemCount,
87+
)
88+
}
89+
90+
val nextCursor = if (hasNext) items.last().tripPlan.createdAt.toString() else null
91+
92+
return CursorPage(
93+
items = summaries,
94+
nextCursor = nextCursor,
95+
hasNext = hasNext,
96+
)
97+
}
98+
99+
@Transactional(readOnly = true)
100+
override fun getTripPlanDetail(
101+
memberId: String,
102+
tripPlanId: String,
103+
): TripPlanDetail {
104+
val tripPlan = findTripPlanOrThrow(tripPlanId, memberId)
105+
106+
val itemsWithItinerary =
107+
planItemPort.findActiveWithItineraryAndPlaceByTripPlanId(tripPlanId)
108+
109+
val itemDetails =
110+
itemsWithItinerary.map { row ->
111+
TripPlanItemDetail(row.tripPlanItem, row.travelItineraryItem)
112+
}
113+
114+
return TripPlanDetail(tripPlan, itemDetails)
115+
}
116+
117+
@Transactional
118+
override fun updateTripPlan(
119+
memberId: String,
120+
tripPlanId: String,
121+
command: UpdateTripPlanCommand,
122+
): TripPlanDetail {
123+
findTripPlanOrThrow(tripPlanId, memberId)
124+
125+
if (command.title != null) {
126+
planPort.updateTitle(tripPlanId, command.title)
127+
}
128+
129+
if (command.items != null) {
130+
planItemPort.updateItems(tripPlanId, command.items)
131+
}
132+
133+
return getTripPlanDetail(memberId, tripPlanId)
134+
}
135+
136+
@Transactional
137+
override fun deleteTripPlan(
138+
memberId: String,
139+
tripPlanId: String,
140+
) {
141+
findTripPlanOrThrow(tripPlanId, memberId)
142+
planItemPort.deleteByTripPlanId(tripPlanId)
143+
planPort.deleteById(tripPlanId)
144+
}
145+
146+
private fun findTripPlanOrThrow(
147+
tripPlanId: String,
148+
memberId: String,
149+
): TripPlan {
150+
val tripPlan =
151+
planPort.findById(tripPlanId)
152+
?: throw LinktripException(ExceptionCode.NOT_FOUND_TRIP_PLAN)
153+
if (tripPlan.memberId != memberId) {
154+
throw LinktripException(ExceptionCode.FORBIDDEN_TRIP_PLAN)
155+
}
156+
return tripPlan
157+
}
158+
}

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Place.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.linktrip.application.domain.video
22

33
import com.linktrip.application.domain.common.IdGenerator
4+
import java.time.LocalDateTime
45

56
data class Place(
67
val id: String,
@@ -9,6 +10,8 @@ data class Place(
910
val address: String?,
1011
val latitude: Double?,
1112
val longitude: Double?,
13+
val createdAt: LocalDateTime = LocalDateTime.now(),
14+
val updatedAt: LocalDateTime = LocalDateTime.now(),
1215
) {
1316
companion object {
1417
fun from(result: PlaceSearchResult): Place =

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/TravelItineraryItem.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.linktrip.application.domain.video
22

33
import com.linktrip.application.domain.common.IdGenerator
4+
import java.time.LocalDateTime
45

56
data class TravelItineraryItem(
67
val id: String,
@@ -14,6 +15,8 @@ data class TravelItineraryItem(
1415
val placeId: String? = null,
1516
val placeSearchCount: Int = 0,
1617
val place: Place? = null,
18+
val createdAt: LocalDateTime = LocalDateTime.now(),
19+
val updatedAt: LocalDateTime = LocalDateTime.now(),
1720
) {
1821
fun isRetryable(): Boolean =
1922
placeId == null &&

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package com.linktrip.application.domain.video
33
import com.linktrip.application.domain.common.IdGenerator
44
import com.linktrip.common.exception.ExceptionCode
55
import com.linktrip.common.exception.LinktripException
6+
import java.time.LocalDateTime
67

78
data class VideoAnalysisTask(
89
val id: String,
910
val youtubeUrl: String,
1011
val valid: Boolean,
1112
val status: VideoAnalysisTaskStatus,
13+
val createdAt: LocalDateTime = LocalDateTime.now(),
14+
val updatedAt: LocalDateTime = LocalDateTime.now(),
1215
) {
1316
companion object {
1417
private const val YOUTUBE_VIDEO_BASE_URL = "https://www.youtube.com/watch?v="
@@ -49,11 +52,11 @@ data class VideoAnalysisTask(
4952

5053
fun normalizeUrl(url: String): String {
5154
if (!YOUTUBE_URL_REGEX.matches(url)) {
52-
throw LinktripException(ExceptionCode.INVALID_YOUTUBE_URL)
55+
throw LinktripException(ExceptionCode.BAD_REQUEST_YOUTUBE_URL)
5356
}
5457
val videoId =
5558
VIDEO_ID_REGEX.find(url)?.groupValues?.get(1)
56-
?: throw LinktripException(ExceptionCode.INVALID_YOUTUBE_URL)
59+
?: throw LinktripException(ExceptionCode.BAD_REQUEST_YOUTUBE_URL)
5760
return "$YOUTUBE_VIDEO_BASE_URL$videoId"
5861
}
5962

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.linktrip.application.domain.video
22

3+
import com.linktrip.application.domain.trip.TripPlanService
34
import com.linktrip.application.port.output.external.VideoAnalysisNotificationPort
45
import com.linktrip.application.port.output.external.VideoAnalyzePort
6+
import com.linktrip.application.port.output.persistence.TripPlanRequestPersistencePort
57
import com.linktrip.application.port.output.persistence.VideoAnalysisTaskPersistencePort
68
import mu.KotlinLogging
79
import org.springframework.scheduling.annotation.Async
@@ -18,6 +20,8 @@ class VideoAnalyzeEventListener(
1820
private val videoAnalysisResultSaver: VideoAnalysisResultSaver,
1921
private val placeEnrichService: PlaceEnrichService,
2022
private val videoAnalysisNotificationPort: VideoAnalysisNotificationPort,
23+
private val tripPlanRequestPort: TripPlanRequestPersistencePort,
24+
private val tripPlanService: TripPlanService,
2125
) {
2226
@Async("VideoAnalyzeExecutor")
2327
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@@ -55,8 +59,40 @@ class VideoAnalyzeEventListener(
5559
return
5660
}
5761

62+
processPendingRequests(event.videoAnalysisTaskId, destination)
5863
enrichPlaces(event.videoAnalysisTaskId, destination)
59-
videoAnalysisNotificationPort.notifyAnalysisComplete(event.videoAnalysisTaskId)
64+
65+
val memberIds = tripPlanRequestPort.findMemberIdsByVideoAnalysisTaskId(event.videoAnalysisTaskId)
66+
videoAnalysisNotificationPort.notifyAnalysisComplete(event.videoAnalysisTaskId, memberIds)
67+
}
68+
69+
private fun processPendingRequests(
70+
videoAnalysisTaskId: String,
71+
destination: String?,
72+
) {
73+
val requests = tripPlanRequestPort.findUnprocessedByVideoAnalysisTaskId(videoAnalysisTaskId)
74+
if (requests.isEmpty()) return
75+
76+
val title = destination ?: "여행 계획"
77+
val processedIds = mutableListOf<String>()
78+
requests.forEach { request ->
79+
runCatching {
80+
tripPlanService.createFromAnalysisIfAbsent(request.memberId, videoAnalysisTaskId, title)
81+
processedIds += request.id
82+
}.onFailure { e ->
83+
logger.error(e) {
84+
"여행 계획 자동 생성 실패: taskId=$videoAnalysisTaskId, requestId=${request.id}"
85+
}
86+
}
87+
}
88+
if (processedIds.isNotEmpty()) {
89+
tripPlanRequestPort.markAsProcessed(processedIds)
90+
}
91+
92+
logger.info {
93+
"여행 계획 자동 생성: taskId=$videoAnalysisTaskId, " +
94+
"성공=${processedIds.size}/${requests.size}"
95+
}
6096
}
6197

6298
private fun enrichPlaces(

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeService.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ class VideoAnalyzeService(
2828
}
2929

3030
val videoAnalysisTask = videoAnalysisTaskPersistencePort.save(VideoAnalysisTask.create(normalizedUrl))
31-
3231
Events.raise(VideoAnalyzeEvent(videoAnalysisTask.id, normalizedUrl))
3332

3433
return videoAnalysisTask

0 commit comments

Comments
 (0)