fix(content): 오디오 추천 조회 fallback을 섹션별로 적용한다

This commit is contained in:
2026-07-12 22:44:14 +09:00
parent d8fbf449c4
commit 91f0d20a79
2 changed files with 122 additions and 139 deletions

View File

@@ -7,12 +7,8 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommenda
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.redisson.api.RedissonClient
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -20,30 +16,31 @@ import java.time.ZoneId
class AudioRecommendationQueryService( class AudioRecommendationQueryService(
private val queryPort: AudioRecommendationQueryPort, private val queryPort: AudioRecommendationQueryPort,
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
private val snapshotPort: RecommendationSnapshotPort, private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService
private val snapshotRefreshService: AudioRecommendationSnapshotRefreshService,
private val redissonClient: RedissonClient
) { ) {
fun getRecommendations(member: Member?): AudioRecommendations { fun getRecommendations(member: Member?): AudioRecommendations {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val fallbackNow = LocalDateTime.now(KST_ZONE)
val canViewAdultContent = canViewAdultContent(member) val canViewAdultContent = canViewAdultContent(member)
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val memberId = member?.id val memberId = member?.id
val newAndHotSectionType = newAndHotSectionType(visibility) val newAndHotSectionType = newAndHotSectionType(visibility)
val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_HOME_LIMIT) val mostCommentedSectionType = mostCommentedSectionType(visibility)
val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( val recommendedAudioSectionType = recommendedAudioSectionType(visibility)
mostCommentedSectionType(visibility), val newAndHotSnapshots = findSnapshotsWithFallback(
limit = MOST_COMMENTED_AUDIO_LIMIT
)
val recommendedSnapshots = snapshotPort.findLatestSnapshots(
recommendedAudioSectionType(visibility),
limit = RECOMMENDED_AUDIO_LIMIT
)
val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(
newAndHotSectionType, newAndHotSectionType,
newAndHotSnapshots, limit = NEW_AND_HOT_HOME_LIMIT,
offset = 0, now = fallbackNow
limit = NEW_AND_HOT_HOME_LIMIT )
val mostCommentedSnapshots = findSnapshotsWithFallback(
mostCommentedSectionType,
limit = MOST_COMMENTED_AUDIO_LIMIT,
now = fallbackNow
)
val recommendedSnapshots = findSnapshotsWithFallback(
recommendedAudioSectionType,
limit = RECOMMENDED_AUDIO_LIMIT,
now = fallbackNow
) )
return AudioRecommendations( return AudioRecommendations(
@@ -51,7 +48,7 @@ class AudioRecommendationQueryService(
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now), originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now), latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
newAndHotAudios = queryPort.findAudioCardsByIds( newAndHotAudios = queryPort.findAudioCardsByIds(
refreshedNewAndHotSnapshots.map { it.targetId }, newAndHotSnapshots.map { it.targetId },
memberId, memberId,
canViewAdultContent, canViewAdultContent,
now now
@@ -74,14 +71,14 @@ class AudioRecommendationQueryService(
fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List<AudioCard> { fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List<AudioCard> {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val fallbackNow = LocalDateTime.now(KST_ZONE)
val canViewAdultContent = canViewAdultContent(member) val canViewAdultContent = canViewAdultContent(member)
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val sectionType = newAndHotSectionType(visibility) val sectionType = newAndHotSectionType(visibility)
val snapshots = snapshotPort.findLatestSnapshots(sectionType, offset, limit) val snapshots = findSnapshotsWithFallback(sectionType, offset, limit, fallbackNow)
val refreshedSnapshots = refreshMissingNewAndHotSnapshots(sectionType, snapshots, offset, limit)
return queryPort.findAudioCardsByIds( return queryPort.findAudioCardsByIds(
refreshedSnapshots.map { it.targetId }, snapshots.map { it.targetId },
member.id, member.id,
canViewAdultContent, canViewAdultContent,
now now
@@ -113,29 +110,13 @@ class AudioRecommendationQueryService(
} }
} }
private fun refreshMissingNewAndHotSnapshots( private fun findSnapshotsWithFallback(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshots: List<RecommendationSnapshotRecord>, offset: Long = 0,
offset: Long, limit: Int,
limit: Int now: LocalDateTime
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
if (snapshots.isNotEmpty()) return snapshots return snapshotFallbackService.refreshIfMissing(sectionType, offset, limit, now)
val today = LocalDate.now(KST_ZONE)
val marker = redissonClient.getBucket<String>(newAndHotLazyRefreshMarkerKey(today))
if (!marker.setIfAbsent(LAZY_REFRESH_ATTEMPTED_VALUE, LAZY_REFRESH_MARKER_TTL)) {
return snapshots
}
runCatching {
snapshotRefreshService.refreshDailySnapshots()
}.onFailure { ex ->
marker.delete()
throw ex
}
return snapshotPort.findLatestSnapshots(sectionType, offset, limit)
}
private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String {
return "$LAZY_REFRESH_MARKER_KEY_PREFIX:$date"
} }
private fun canViewAdultContent(member: Member?): Boolean { private fun canViewAdultContent(member: Member?): Boolean {
@@ -152,9 +133,6 @@ class AudioRecommendationQueryService(
const val NEW_AND_HOT_HOME_LIMIT = 12 const val NEW_AND_HOT_HOME_LIMIT = 12
const val MOST_COMMENTED_AUDIO_LIMIT = 5 const val MOST_COMMENTED_AUDIO_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 20 const val RECOMMENDED_AUDIO_LIMIT = 20
private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted"
private const val LAZY_REFRESH_ATTEMPTED_VALUE = "1"
private val LAZY_REFRESH_MARKER_TTL: Duration = Duration.ofDays(2)
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
} }
} }

View File

@@ -7,30 +7,21 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.redisson.api.RBucket
import org.redisson.api.RedissonClient
import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
class AudioRecommendationQueryServiceTest { class AudioRecommendationQueryServiceTest {
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java) private val fallbackService = Mockito.mock(AudioRecommendationSnapshotFallbackService::class.java)
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
private val redissonClient = Mockito.mock(RedissonClient::class.java)
private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket<String>
private val service = AudioRecommendationQueryService( private val service = AudioRecommendationQueryService(
queryPort, queryPort,
preferenceService, preferenceService,
snapshotPort, fallbackService
refreshService,
redissonClient
) )
@Test @Test
@@ -40,8 +31,8 @@ class AudioRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다") @DisplayName("조회 서비스는 SAFE 스냅샷 기반 3개 섹션을 fallback 경로로 조립한다")
fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() { fun shouldBuildRecommendationsFromSafeSnapshotsWithFallback() {
val snapshot = RecommendationSnapshotRecord( val snapshot = RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
targetId = 1L, targetId = 1L,
@@ -49,39 +40,39 @@ class AudioRecommendationQueryServiceTest {
snapshotAt = LocalDateTime.now(), snapshotAt = LocalDateTime.now(),
randomTieBreaker = 1.0 randomTieBreaker = 1.0
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>(), listOf(snapshot)) stubFallback(
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT,
listOf(snapshot)
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) stubFallback(
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT,
emptyList()
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) stubFallback(
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT,
emptyList()
) )
allowLazyRefreshOnce()
val recommendations = service.getRecommendations(null) val recommendations = service.getRecommendations(null)
assertEquals(0, recommendations.mostCommentedAudios.size) assertEquals(0, recommendations.mostCommentedAudios.size)
Mockito.verify(refreshService).refreshDailySnapshots() verifyFallback(
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
)
verifyFallback(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
) )
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( verifyFallback(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
@@ -114,35 +105,34 @@ class AudioRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는") @DisplayName("Most Commented만 비어 있어도 해당 섹션 fallback을 독립적으로 호출한")
fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() { fun shouldFallbackMostCommentedIndependentlyWhenNewAndHotExists() {
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) stubFallback(
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT,
listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, 1L))
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) stubFallback(
.`when`(snapshotPort) RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
.findLatestSnapshots( 0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT,
emptyList()
)
stubFallback(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT,
listOf(snapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, 2L))
)
service.getRecommendations(null)
verifyFallback(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
allowLazyRefreshOnce()
service.getRecommendations(null)
service.getRecommendations(null)
Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots()
} }
@Test @Test
@@ -150,19 +140,18 @@ class AudioRecommendationQueryServiceTest {
fun shouldUseStoredPreferenceForMemberAdultVisibility() { fun shouldUseStoredPreferenceForMemberAdultVisibility() {
val member = member(id = 10L) val member = member(id = 10L)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) stubFallback(
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT,
listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))
) )
service.getRecommendations(member) service.getRecommendations(member)
Mockito.verify(preferenceService).canViewAdultContent(member) Mockito.verify(preferenceService).canViewAdultContent(member)
Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member) Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member)
Mockito.verify(snapshotPort).findLatestSnapshots( verifyFallback(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
@@ -174,16 +163,16 @@ class AudioRecommendationQueryServiceTest {
fun shouldKeepNewAndHotHomeLimitAtTwelve() { fun shouldKeepNewAndHotHomeLimitAtTwelve() {
val member = member(id = 10L) val member = member(id = 10L)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))).`when`(snapshotPort) stubFallback(
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT,
listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))
) )
service.getRecommendations(member) service.getRecommendations(member)
Mockito.verify(snapshotPort).findLatestSnapshots( verifyFallback(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
@@ -200,15 +189,19 @@ class AudioRecommendationQueryServiceTest {
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L) snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L)
) )
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(snapshots).`when`(snapshotPort) stubFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21, snapshots)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21)
Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort) Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort)
.findAudioCardsByIds(eqValue(listOf(3L, 4L, 5L)), eqValue(member.id), eqValue(true), anyLocalDateTime()) .findAudioCardsByIds(
eqValue(listOf(3L, 4L, 5L)),
eqValue(member.id),
eqValue(true),
anyLocalDateTime()
)
val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21) val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21)
assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId }) assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId })
Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) verifyFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21)
} }
@Test @Test
@@ -280,15 +273,27 @@ class AudioRecommendationQueryServiceTest {
) )
} }
private fun stubFallback(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int,
snapshots: List<RecommendationSnapshotRecord>
) {
Mockito.doReturn(snapshots)
.`when`(fallbackService)
.refreshIfMissing(eqValue(sectionType), eqValue(offset), eqValue(limit), anyLocalDateTime())
}
private fun verifyFallback(sectionType: RecommendedSectionType, offset: Long, limit: Int) {
Mockito.verify(fallbackService).refreshIfMissing(
eqValue(sectionType),
eqValue(offset),
eqValue(limit),
anyLocalDateTime()
)
}
private fun <T> eqValue(value: T): T { private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value return Mockito.eq(value) ?: value
} }
private fun allowLazyRefreshOnce() {
Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket<String>(Mockito.anyString())
Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent(
eqValue("1"),
eqValue(Duration.ofDays(2))
)
}
} }