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 @@ -2,6 +2,9 @@ package com.depromeet.piki.tournament.repository

import com.depromeet.piki.tournament.domain.TournamentHistory
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.util.UUID

interface TournamentHistoryJpaRepository : JpaRepository<TournamentHistory, Long> {
fun findAllByTournamentIdAndTournamentUserIdAndDeletedAtIsNullOrderByCurrentRoundAscIdAsc(
Expand All @@ -10,4 +13,37 @@ interface TournamentHistoryJpaRepository : JpaRepository<TournamentHistory, Long
): List<TournamentHistory>

fun findAllByTournamentIdInAndDeletedAtIsNull(tournamentIds: List<Long>): List<TournamentHistory>

/**
* 이 사용자가 **자기 플레이의 결승에서 고른** 상품들과, 그 우승이 난 토너먼트. 위시 카드의 우승 칩(#1236).
*
* 기준이 참여 행(TournamentUser)인 것이 핵심이다. 그룹 합산 순위가 아니라 "내가 선택한 1등" 이므로,
* 같은 소셜 토너먼트라도 사람마다 답이 다르다. 결승 한 행만 보면 되어 순위 전체를 계산할 필요가 없다
* (TournamentService.computeRanking 은 2·3위까지 필요할 때만 쓴다).
*
* 삭제된 참여 행은 빠진다 — 토너먼트를 지우면 칩도 사라져야 한다는 기획 요구가 여기서 충족된다.
* 우승을 따로 저장하지 않고 매 조회에 계산하는 이유도 이것이다. 저장하면 삭제와 동기화하는 부채가 생긴다.
*
* 출전(TournamentItem)은 상품을 버전(snapshot)으로 가리키므로 itemId 까지 두 단계를 더 탄다.
* FK·연관관계를 두지 않는 규약이라 암시적 조인으로 엮는다(TournamentItemJpaRepository 의 선례와 같은 형태).
*
* itemIds 로 먼저 좁혀, 한 페이지에 보이는 상품만 본다. 위시가 수백 건이어도 조회 비용이 페이지 크기에 묶인다.
*
* 정렬은 결승 기록 id 역순 — 곧 "우승한 순서의 역순"이다. 참여 행의 완료 시각(completedAt)을 쓰지 않는 이유는
* 그 값이 완료 판정의 출처가 아니어서(#1027) 레거시 행에 비어 있을 수 있기 때문이다. 기록 id 는 항상 있다.
*/
@Query(
"SELECT s.itemId AS itemId, h.tournamentId AS tournamentId " +
"FROM TournamentHistory h, TournamentUser tu, TournamentItem ti, ItemSnapshot s " +
"WHERE h.tournamentUserId = tu.id AND tu.userId = :userId AND tu.deletedAt IS NULL " +
"AND h.currentRound = :finalRound AND h.deletedAt IS NULL " +
"AND ti.id = h.selectedTournamentItemId AND ti.deletedAt IS NULL " +
"AND s.id = ti.snapshotId AND s.itemId IN :itemIds " +
"ORDER BY h.id DESC",
)
fun findWinnerItems(
@Param("userId") userId: UUID,
@Param("itemIds") itemIds: Collection<Long>,
@Param("finalRound") finalRound: Int,
): List<WinnerItemView>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.depromeet.piki.tournament.repository

// 우승한 상품과 그 우승이 난 토너먼트 좌표 — 위시 카드의 우승 칩(#1236)이 역추적에 쓴다.
// 한 상품이 여러 판에서 이길 수 있어 itemId 당 여러 행이 나온다. Spring Data interface projection.
interface WinnerItemView {
val itemId: Long
val tournamentId: Long
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.depromeet.piki.tournament.service

import com.depromeet.piki.tournament.domain.Tournament
import com.depromeet.piki.tournament.repository.TournamentHistoryJpaRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID

/**
* "이 사용자가 우승시킨 상품" 을 읽는다. 위시 목록의 우승 칩(#1236)이 쓴다.
*
* 토너먼트 바깥(위시)에서 필요한 질문이지만 판정 규칙은 토너먼트의 것이라 여기 둔다. 결승 라운드 번호가
* Tournament 의 internal 상수인 것도 같은 이유다 — 그 값을 호출자가 알아야 한다면 경계가 샌 것이다.
*
* TournamentService 에 얹지 않는다. 그쪽은 플레이 진행·결과 집계를 맡는 큰 서비스라, 조회 한 줄을 더하면
* 읽는 사람이 "우승 칩도 저 안 어딘가" 를 뒤져야 한다. 질문이 작으므로 자리도 작게 둔다.
*/
@Service
class TournamentWinnerReader(
private val tournamentHistoryJpaRepository: TournamentHistoryJpaRepository,
) {
/**
* [itemIds] 중 이 사용자가 **자기 플레이의 결승에서 고른** 상품 → 그 우승이 난 토너먼트들(최신 우승 순).
*
* 호출자가 관심 있는 상품만 넘기고 그 부분집합을 돌려받는다. 한 페이지에 보이는 것만 묻게 되어
* 위시가 수백 건이어도 비용이 페이지 크기에 묶인다. 결과에 없는 itemId 는 우승 이력이 없다는 뜻이다.
*
* 한 상품이 여러 판에서 이길 수 있어 값이 리스트다. 어느 하나를 서버가 고르지 않고 전부 넘긴다 —
* "이 상품이 이긴 판들" 을 보여줄지 최신 하나만 쓸지는 화면이 정할 일이다.
*
* 빈 입력에 쿼리를 날리지 않는다 — `IN ()` 은 DB 마다 문법이 갈리고, 어차피 답이 빈 맵이다.
*/
@Transactional(readOnly = true)
fun findWinnerTournamentIdsByItem(
userId: UUID,
itemIds: Collection<Long>,
): Map<Long, List<Long>> {
if (itemIds.isEmpty()) return emptyMap()
// 쿼리가 최신 우승 순으로 정렬해 돌려주므로 groupBy 가 그 순서를 그대로 유지한다.
return tournamentHistoryJpaRepository
.findWinnerItems(userId, itemIds, Tournament.FINAL_ROUND_SIZE)
.groupBy({ it.itemId }, { it.tournamentId })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ class WishlistApiExamples(
name = "조회 성공 (다음 페이지 있음)",
payload =
ApiResponseBody.ok(
data = listOf(sampleEntry),
// 우승 칩이 붙은 항목과 아닌 항목을 함께 둔다 — 항목마다 갈리는 값임을 드러낸다.
data = listOf(winnerSampleEntry, sampleEntry),
pageResponse = PageResponse(nextCursor = "1024", hasNext = true),
),
)
Expand Down Expand Up @@ -417,6 +418,9 @@ class WishlistApiExamples(

// URL 등록 응답 전용(#853) — 등록 경로는 attach 메타(reused·refreshNeeded)가 non-null 로 내려간다.
// 새 파싱을 만든 등록: 캐시에 붙지 않았으므로 둘 다 false.
// 우승 칩이 붙는 항목(#1236) — 두 판에서 이긴 상품이라 최신 우승이 앞에 온다. 그 토너먼트를 지우면 id 가 빠진다.
private val winnerSampleEntry = sampleEntry.copy(winnerTournamentIds = listOf(1402, 1397))

private val pendingRegistrationEntry = pendingSampleEntry.copy(reused = false, refreshNeeded = false)

// 파싱 없이 다른 등록의 완성 값(캐시)에 붙은 등록 — 값이 갱신 권고 임계(24h)보다 낡아 refreshNeeded=true.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ data class WishDetailResponse(
"가격 기록이 하나도 없으면 빈 배열이다(그때는 item.status 로 대기와 실패를 가른다).",
)
val priceHistory: List<PriceHistoryEntry>,
@field:Schema(
description = "이 상품이 우승한 내 토너먼트들 — 목록 응답의 같은 필드와 같은 규칙·순서로 내려간다.",
example = "[1402, 1397]",
)
val winnerTournamentIds: List<Long> = emptyList(),
) {
companion object {
// requesterId 는 editedByMe(수기 버전을 내가 넣었는지) 파생용 — 편집자 식별자(UUID)는 응답에 노출하지 않는다.
Expand All @@ -45,6 +50,7 @@ data class WishDetailResponse(
memo = detail.wish.memo,
item = WishItemResponse.ItemView.from(detail.item, detail.snapshot, detail.sourcePlatform),
priceHistory = detail.history.map { PriceHistoryEntry.from(it, requesterId) },
winnerTournamentIds = detail.winnerTournamentIds,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ data class WishItemResponse(
nullable = true,
)
val refreshNeeded: Boolean? = null,
@field:Schema(
description =
"이 상품이 우승한 내 토너먼트들 — 최신 우승 순이고, 비어 있으면 우승 이력이 없다(위시 카드의 우승 칩). " +
"기준은 그룹 합산 순위가 아니라 내가 플레이한 결과라, 같은 소셜 토너먼트라도 참여자마다 다르다. " +
"한 상품이 여러 판에서 이길 수 있어 배열이며, 그 토너먼트를 삭제하면 해당 id 가 빠진다.",
example = "[1402, 1397]",
)
val winnerTournamentIds: List<Long> = emptyList(),
) {
companion object {
fun from(
Expand All @@ -38,6 +46,7 @@ data class WishItemResponse(
WishItemResponse(
wish = WishView.from(result.wish),
item = ItemView.from(result),
winnerTournamentIds = result.winnerTournamentIds,
)

// URL 등록 응답 전용 — 공유 attach 메타(#853)까지 싣는다. 등록만 이 오버로드를 쓰고,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.depromeet.piki.item.domain.ItemErrorCode
import com.depromeet.piki.item.domain.ItemSnapshot
import com.depromeet.piki.item.repository.ItemRepository
import com.depromeet.piki.item.repository.ItemSnapshotRepository
import com.depromeet.piki.tournament.service.TournamentWinnerReader
import com.depromeet.piki.item.service.ItemDisplayService
import com.depromeet.piki.item.service.ItemRegistrar
import com.depromeet.piki.product.domain.ProductLink
Expand Down Expand Up @@ -45,6 +46,7 @@ class WishlistService(
private val itemQuotaGuard: ItemQuotaGuard,
private val userService: UserService,
private val sourcePlatformResolver: SourcePlatformResolver,
private val tournamentWinnerReader: TournamentWinnerReader,
) {

private fun WishWithItem.withSourcePlatform(): WishWithItem = copy(sourcePlatform = sourcePlatformResolver.resolve(item.link))
Expand Down Expand Up @@ -107,6 +109,8 @@ class WishlistService(
// 위시는 상품(itemId)을 직접 참조하므로 상품과 표시값을 그 키로 끌어온다.
val displayByCard = itemDisplayService.resolveDisplay(pageWishes.map { it.displayCard() })
val itemsById = itemRepository.findByIds(pageWishes.map { it.itemId }).associateBy { it.getId() }
// 우승 칩(#1236) — 이 페이지에 보이는 상품만 한 번에 물어 N+1 을 만들지 않는다(표시값·상품 조회와 같은 결).
val winnersByItem = tournamentWinnerReader.findWinnerTournamentIdsByItem(userId, pageWishes.map { it.itemId })
val entries =
pageWishes.map { wish ->
// item 은 wish 와 함께 영속화되며 병합 시 함께 옮겨진다(WishItemMergeListener). 없으면 영속화 경로가 깨진 코드 버그다.
Expand All @@ -115,6 +119,7 @@ class WishlistService(
wish = wish,
item = item,
snapshot = displayByCard.getValue(wish.displayCard()),
winnerTournamentIds = winnersByItem[wish.itemId].orEmpty(),
).withSourcePlatform()
}

Expand Down Expand Up @@ -145,12 +150,15 @@ class WishlistService(
val item = itemRepository.findById(wish.itemId) ?: error("wish ${wish.getId()} 의 item ${wish.itemId} 가 없다")
// 표시값 파생(#857·#1051) — 목록(getWishlist)과 같은 규칙.
val history = itemSnapshotRepository.findPriceHistoryByItemId(wish.itemId, PRICE_HISTORY_LIMIT)
// 우승 칩(#1236) — 목록과 같은 규칙으로 판정한다. 상세는 상품이 하나라 그 하나만 묻는다.
val winners = tournamentWinnerReader.findWinnerTournamentIdsByItem(userId, listOf(wish.itemId))
return WishDetail(
wish = wish,
item = item,
snapshot = itemDisplayService.resolveDisplay(wish.displayCard()),
history = history,
sourcePlatform = sourcePlatformResolver.resolve(item.link),
winnerTournamentIds = winners[wish.itemId].orEmpty(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ data class WishDetail(
val snapshot: ItemSnapshot,
val history: List<ItemSnapshot>,
val sourcePlatform: String?,
// 이 상품이 우승한 내 토너먼트들(#1236, 최신 우승 순). 목록과 같은 규칙으로 판정해 두 화면이 어긋나지 않게 한다.
val winnerTournamentIds: List<Long> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import com.depromeet.piki.wishlist.domain.Wish
// 표시값(name/price/image/status)은 snapshot 에서, 정체성(id·sourceUrl=link)은 item 에서 온다.
// reused·refreshNeeded 는 공유 등록(#853)의 attach 메타 — 등록 경로만 채우고 그 외(조회·수기 수정 등)는 기본값 false.
// sourcePlatform 은 응답 경계(WishlistService)가 링크에서 판정해 채운다. 영속화 계층은 비워 둔다.
// winnerTournamentIds 도 같은 자리에서 채운다 — 토너먼트에서 파생하는 값이라 위시 영속화와 무관하고,
// 등록 직후 응답에는 쓸 일이 없어 기본값이 빈 목록이다(막 담은 상품이 우승했을 리 없다).
data class WishWithItem(
val wish: Wish,
val item: Item,
val snapshot: ItemSnapshot,
val reused: Boolean = false,
val refreshNeeded: Boolean = false,
val sourcePlatform: String? = null,
val winnerTournamentIds: List<Long> = emptyList(),
)
Loading
Loading