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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.retoday.api.extension.withAuthentication
import com.retoday.api.snippet.generateRecapQueryFields
import com.retoday.api.snippet.recapDetailResponseFields
import com.retoday.core.domain.recap.dto.response.RecapDetailResponse
import com.retoday.core.domain.recap.entity.RecapStatus
import com.retoday.core.domain.recap.service.RecapService
import io.mockk.every
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
Expand Down Expand Up @@ -104,6 +105,7 @@ class RecapControllerTest : ControllerTest() {
id = 100L,
userId = 1L,
recapDate = date,
status = RecapStatus.COMPLETED,
title = "집중적인 연구의 하루",
summary = "취업준비와 개발공부를 병행하며 열심히 앞으로 나아갔어요. 앞으로도 꾸준히 작업하다보면 원하는 결과를 얻을 수 있을거에요!",
startedAt = LocalDateTime.parse("2026-02-21T09:00:00"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ val recapDetailResponseFields =
RecapDetailResponse::id desc "리캡 식별자",
RecapDetailResponse::userId desc "사용자 식별자",
RecapDetailResponse::recapDate desc "리캡 대상 일자",
RecapDetailResponse::status desc "리캡 상태(COMPLETED, FAILED)",
RecapDetailResponse::title desc "리캡 제목",
RecapDetailResponse::summary desc "리캡 요약",
RecapDetailResponse::imageUrl desc "리캡 이미지 URL",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum class Category(
LIFE("생활/편의"),
SURFING("웹서핑"),
DESIGN("디자인"),
AI("AI"),
DEVELOPMENT("개발"),
ETC("기타");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.retoday.core.domain.recap.dto.response

import com.retoday.core.domain.recap.entity.Recap
import com.retoday.core.domain.recap.entity.RecapStatus
import com.retoday.core.domain.recap.entity.Section
import com.retoday.core.domain.recap.entity.Timeline
import com.retoday.core.domain.recap.entity.Topic
Expand All @@ -13,6 +14,7 @@ data class RecapDetailResponse(
val id: Long,
val userId: Long,
val recapDate: LocalDate,
val status: RecapStatus,
val title: String,
val summary: String,
val imageUrl: String? = null,
Expand Down Expand Up @@ -54,6 +56,7 @@ data class RecapDetailResponse(
id = recap.id!!,
userId = recap.userId,
recapDate = recap.recapDate,
status = recap.status,
title = recap.title,
summary = recap.summary,
imageUrl = recap.imageUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ enum class RecapImage(
LIFE("08.png", "생활/편의"),
SURFING("09.png", "웹서핑"),
DESIGN("10.png", "디자인"),
DEVELOPMENT("11.png", "개발"),
SCREEN_TIME_OVER_12H("12.png"),
SCREEN_TIME_UNDER_1H("13.png"),
CATEGORY_OVER_5("14.png"),
CATEGORY_ONLY_1("15.png"),
START_AFTER_9PM("16.png"),
START_BEFORE_9AM("17.png"),
RANDOM_1("18.png"),
RANDOM_2("19.png"),
RANDOM_3("20.png")
AI("11.png", "AI"),
DEVELOPMENT("12.png", "개발"),
SCREEN_TIME_OVER_12H("13.png"),
SCREEN_TIME_UNDER_1H("14.png"),
CATEGORY_OVER_5("15.png"),
CATEGORY_ONLY_1("16.png"),
START_AFTER_9PM("17.png"),
START_BEFORE_9AM("18.png"),
RANDOM_1("19.png"),
RANDOM_2("20.png"),
RANDOM_3("21.png")
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@ package com.retoday.core.domain.recap.entity

enum class RecapStatus {
COMPLETED,
IN_PROGRESS,
PENDING,
FAILED
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ class RecapScheduler(
runCatching {
recapService.createDailyRecap(profile.userId, recapDate)
}.onFailure { e ->
runCatching {
recapService.saveFailedRecap(profile.userId, recapDate)
}.onFailure { saveFailure ->
logger.error(saveFailure) {
"Failed to persist failed recap. userId=${profile.userId}, recapDate=$recapDate"
}
}
logger.error(e) {
"Failed to create daily recap. userId=${profile.userId}, recapDate=$recapDate, timeZone=${profile.timeZone}"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.retoday.core.domain.recap.dto.response.GeminiTimelineResponse
import com.retoday.core.domain.recap.dto.response.GeminiTopicResponse
import com.retoday.core.domain.recap.dto.response.RecapDetailResponse
import com.retoday.core.domain.recap.entity.Recap
import com.retoday.core.domain.recap.entity.RecapStatus
import com.retoday.core.domain.recap.entity.Section
import com.retoday.core.domain.recap.entity.Timeline
import com.retoday.core.domain.recap.entity.Topic
Expand Down Expand Up @@ -48,6 +49,8 @@ class RecapService(
) {
private companion object {
val TIMELINE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("H:mm")
const val FAILED_RECAP_TITLE: String = ""
const val FAILED_RECAP_SUMMARY: String = ""
}

fun generateDailyRecap(
Expand Down Expand Up @@ -87,18 +90,19 @@ class RecapService(
val name = profile.firstName
val zoneId = profile.timeZone.id

val timelineProjections = historyRepository.findUserTimelinesForRecap(userId, startedAt, endedAt)
val firstVisitedAt = timelineProjections.mapNotNull { it.visitedAt }.minOrNull() ?: startedAt
val lastClosedAt = timelineProjections.mapNotNull { it.closedAt }.maxOrNull() ?: endedAt

val recapResponse = generateRecap(name, activityRequests)
val topicResponse = generateTopics(name, activityRequests)

// 타임라인 전용 데이터 조회 및 저장
var timelineResponse = GeminiTimelineResponse()
val timelineProjections = historyRepository.findUserTimelinesForRecap(userId, startedAt, endedAt)
if (timelineProjections.isNotEmpty()) {
val timelineRequests = timelineProjections.map { it.toRequest() }
timelineResponse = generateTimeline(name, timelineRequests)
}
val firstVisitedAt = timelineProjections.mapNotNull { it.visitedAt }.minOrNull() ?: startedAt
val lastClosedAt = timelineProjections.mapNotNull { it.closedAt }.maxOrNull() ?: endedAt

val categoryAnalyses =
historyService
.getMyCategoryAnalyses(
Expand Down Expand Up @@ -128,7 +132,8 @@ class RecapService(
imageUrl = imageUrl,
startedAt = firstVisitedAt,
closedAt = lastClosedAt,
model = recapAIClient.modelName
model = recapAIClient.modelName,
status = RecapStatus.COMPLETED
).let { recapRepository.save(it) }

val sections =
Expand Down Expand Up @@ -176,6 +181,32 @@ class RecapService(
}
}

fun saveFailedRecap(
userId: Long,
date: LocalDate
) {
if (recapRepository.existsByUserIdAndRecapDate(userId, date)) return

val profile = profileRepository.findByUserId(userId) ?: return
val startedAt = date.atStartOfDay(profile.timeZone.id).toInstant()
val closedAt = startedAt.plus(Duration.ofDays(1))

transactionManager.transaction {
recapRepository.save(
Recap(
userId = userId,
recapDate = date,
title = FAILED_RECAP_TITLE,
summary = FAILED_RECAP_SUMMARY,
startedAt = startedAt,
closedAt = closedAt,
model = recapAIClient.modelName,
status = RecapStatus.FAILED
)
)
}
}

// api 호출용 조회 로직
fun getRecapDetail(
userId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.retoday.core.domain.recap.dto.request.RecapPayload
import com.retoday.core.domain.recap.dto.response.GeminiRecapResponse
import com.retoday.core.domain.recap.dto.response.GeminiTimelineResponse
import com.retoday.core.domain.recap.dto.response.GeminiTopicResponse
import com.retoday.core.domain.recap.entity.RecapStatus
import com.retoday.core.domain.recap.entity.Section
import com.retoday.core.domain.recap.entity.Timeline
import com.retoday.core.domain.recap.entity.Topic
Expand All @@ -21,6 +22,7 @@ import com.retoday.core.domain.recap.repository.TimelineRepository
import com.retoday.core.domain.recap.repository.TopicRepository
import com.retoday.core.domain.user.repository.ProfileRepository
import com.retoday.core.fixture.*
import io.kotest.assertions.throwables.shouldThrow
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
Expand Down Expand Up @@ -143,7 +145,11 @@ class RecapServiceTest : ServiceTest() {

Then("부모 Recap이 저장되고, 파생되는 모든 엔티티들이 recapId(100L)를 가지고 저장된다") {
// 1. 부모 리캡 저장 확인
verify(exactly = 1) { recapRepository.save(any()) }
verify(exactly = 1) {
recapRepository.save(
match { it.status == RecapStatus.COMPLETED }
)
}

// 2. 섹션 저장 확인
verify(exactly = 1) {
Expand Down Expand Up @@ -177,5 +183,41 @@ class RecapServiceTest : ServiceTest() {
}
}
}

Given("리캡 생성 중 예외가 발생할 때") {
val userId = ID
val date = LocalDate.parse("2026-02-24")
val profile =
createProfile(
userId = userId,
firstName = "민주"
)
val startedAt = date.atStartOfDay(profile.timeZone.id).toInstant()
val endedAt = startedAt.plus(Duration.ofDays(1))
val activities = createUserActivities()

every { recapRepository.existsByUserIdAndRecapDate(userId, date) } returns false
every { historyRepository.findUserActivitiesForRecap(userId, startedAt, endedAt) } returns activities
every { historyRepository.findUserTimelinesForRecap(userId, startedAt, endedAt) } returns emptyList()
every { profileRepository.findByUserId(userId) } returns profile
every { recapAIClient.modelName } returns "gemini-pro"
every {
recapAIClient.generate(
any<GenerateRecapRequest>(),
GeminiRecapResponse::class.java
)
} throws RuntimeException("AI unavailable")
every { recapRepository.save(any()) } answers { firstArg() }

When("createDailyRecap을 호출하면") {
Then("리캡 저장 없이 예외가 전파된다") {
shouldThrow<RuntimeException> {
recapService.createDailyRecap(userId, date)
}

verify(exactly = 0) { recapRepository.save(any()) }
}
}
}
}
}