Skip to content

Commit d8ca9dd

Browse files
authored
feat/#146 -> staging merge commit
* feat: 포즈 조회시 view count 증가 * ref: GetPoseUseCase 쓰기 트랜잭션 범위 축소 * fix: pose view ttl 자정으로 수정
1 parent 4902cb5 commit d8ca9dd

File tree

12 files changed

+160
-1
lines changed

12 files changed

+160
-1
lines changed

src/main/kotlin/com/neki/pose/application/port/PoseRepositoryPort.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ interface PoseRepositoryPort {
3232
fun countPoses(headCount: HeadCount, excludeIds: List<Long>): Long
3333

3434
fun findPoseByOffset(offset: Long, headCount: HeadCount, excludeIds: List<Long>): Pose?
35+
36+
fun incrementViewCount(poseId: Long)
3537
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.neki.pose.application.port
2+
3+
interface PoseViewCachePort {
4+
5+
fun addViewer(poseId: Long, userId: Long): Boolean
6+
}

src/main/kotlin/com/neki/pose/application/usecase/GetPoseUseCase.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,30 @@ package com.neki.pose.application.usecase
33
import com.neki.common.annotation.UseCase
44
import com.neki.common.api.dto.ResultCode
55
import com.neki.common.exception.BusinessException
6+
import com.neki.common.transaction.TransactionRunner
67
import com.neki.pose.application.command.GetPoseCommand
78
import com.neki.pose.application.contract.MediaStorageInfo
89
import com.neki.pose.application.port.MediaClientPort
910
import com.neki.pose.application.port.PoseRepositoryPort
11+
import com.neki.pose.application.port.PoseViewCachePort
1012
import com.neki.pose.application.result.GetPoseResult
1113

1214
@UseCase
13-
class GetPoseUseCase(private val poseRepository: PoseRepositoryPort, private val mediaClient: MediaClientPort) {
15+
class GetPoseUseCase(
16+
private val poseRepository: PoseRepositoryPort,
17+
private val mediaClient: MediaClientPort,
18+
private val poseViewCache: PoseViewCachePort,
19+
private val transactionRunner: TransactionRunner,
20+
) {
1421

1522
fun execute(command: GetPoseCommand): GetPoseResult {
1623
val (pose, isScraped) = poseRepository.getOwnedPoseWithScrap(command.userId, command.poseId)
1724
?: throw BusinessException(ResultCode.NOT_FOUND)
1825

26+
if (poseViewCache.addViewer(command.poseId, command.userId)) {
27+
transactionRunner.run { poseRepository.incrementViewCount(command.poseId) }
28+
}
29+
1930
val mediaInfo: MediaStorageInfo = mediaClient.getMediaStorageInfo(pose.mediaId)
2031

2132
return GetPoseResult(

src/main/kotlin/com/neki/pose/domain/entity/Pose.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@ class Pose(
3838

3939
@Column(name = "memo", nullable = true)
4040
var memo: String? = null,
41+
42+
@Column(name = "view_count", nullable = false)
43+
var viewCount: Long = 0,
4144
) : BaseTimeEntity()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.neki.pose.infra.cache.fake
2+
3+
import com.neki.pose.application.port.PoseViewCachePort
4+
import org.springframework.context.annotation.Profile
5+
import org.springframework.stereotype.Component
6+
import java.util.concurrent.ConcurrentHashMap
7+
8+
@Component
9+
@Profile("test")
10+
class FakePoseViewCacheAdapter : PoseViewCachePort {
11+
12+
private val viewers = ConcurrentHashMap<Long, MutableSet<Long>>()
13+
14+
override fun addViewer(poseId: Long, userId: Long): Boolean {
15+
val userSet = viewers.computeIfAbsent(poseId) { ConcurrentHashMap.newKeySet() }
16+
return userSet.add(userId)
17+
}
18+
19+
fun clearAll() {
20+
viewers.clear()
21+
}
22+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.neki.pose.infra.cache.redis
2+
3+
internal object PoseViewRedisCacheKey {
4+
private const val VIEW_PREFIX = "pose:view:"
5+
6+
fun viewKey(poseId: Long): String = "$VIEW_PREFIX$poseId"
7+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.neki.pose.infra.cache.redis
2+
3+
import com.neki.pose.application.port.PoseViewCachePort
4+
import org.slf4j.LoggerFactory
5+
import org.springframework.context.annotation.Primary
6+
import org.springframework.context.annotation.Profile
7+
import org.springframework.data.redis.core.RedisTemplate
8+
import org.springframework.stereotype.Component
9+
import java.time.Instant
10+
import java.time.LocalDate
11+
import java.time.ZoneId
12+
13+
@Component
14+
@Primary
15+
@Profile("!test")
16+
class RedisPoseViewCacheAdapter(private val redisTemplate: RedisTemplate<String, Any>) : PoseViewCachePort {
17+
18+
private val log = LoggerFactory.getLogger(javaClass)
19+
20+
override fun addViewer(poseId: Long, userId: Long): Boolean {
21+
val key = PoseViewRedisCacheKey.viewKey(poseId)
22+
return try {
23+
val added = redisTemplate.opsForSet().add(key, userId) ?: 0
24+
if (added > 0) {
25+
redisTemplate.expireAt(key, getMidnight())
26+
true
27+
} else {
28+
false
29+
}
30+
} catch (e: Exception) {
31+
log.warn("[PoseViewCache] Failed to check viewer for pose: $poseId, user: $userId", e)
32+
true // fail-open: Redis 장애 시 신규 조회로 처리
33+
}
34+
}
35+
36+
private fun getMidnight(): Instant {
37+
val tomorrow = LocalDate.now(KOREA_ZONE).plusDays(1)
38+
return tomorrow.atStartOfDay(KOREA_ZONE).toInstant()
39+
}
40+
41+
companion object {
42+
private val KOREA_ZONE = ZoneId.of("Asia/Seoul")
43+
}
44+
}

src/main/kotlin/com/neki/pose/infra/persist/PoseRepositoryAdapter.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,8 @@ class PoseRepositoryAdapter(
4444

4545
override fun findPoseByOffset(offset: Long, headCount: HeadCount, excludeIds: List<Long>): Pose? =
4646
queryRepository.findPoseByOffset(offset, headCount, excludeIds)
47+
48+
override fun incrementViewCount(poseId: Long) {
49+
queryRepository.incrementViewCount(poseId)
50+
}
4751
}

src/main/kotlin/com/neki/pose/infra/persist/jpa/PosesQueryRepository.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,10 @@ class PosesQueryRepository(private val queryFactory: JPAQueryFactory) {
110110
.offset(offset)
111111
.limit(1)
112112
.fetchOne()
113+
114+
fun incrementViewCount(poseId: Long): Long = queryFactory
115+
.update(pose)
116+
.set(pose.viewCount, pose.viewCount.add(1))
117+
.where(pose.id.eq(poseId))
118+
.execute()
113119
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE TB_POSE ADD COLUMN view_count BIGINT NOT NULL DEFAULT 0;

0 commit comments

Comments
 (0)