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 @@ -3,7 +3,6 @@ package com.depromeet.piki.wishlist.controller
import com.depromeet.piki.common.response.ApiResponseBody
import com.depromeet.piki.common.response.PageResponse
import com.depromeet.piki.image.controller.dto.ConfirmImageUploadRequest
import com.depromeet.piki.product.source.SourcePlatformResolver
import com.depromeet.piki.image.controller.dto.PresignedImageUploadRequest
import com.depromeet.piki.image.controller.dto.PresignedImageUploadResponse
import com.depromeet.piki.wishlist.controller.dto.WishDetailResponse
Expand Down Expand Up @@ -36,10 +35,8 @@ import java.util.UUID
@RequestMapping("/api/v1/wishlists")
class WishlistController(
private val wishlistService: WishlistService,
private val sourcePlatformResolver: SourcePlatformResolver,
) : WishlistApi {
private fun toResponse(result: WishWithItem): WishItemResponse =
WishItemResponse.from(result.wish, result.item, result.snapshot, sourcePlatformResolver.resolve(result.item.link))
private fun toResponse(result: WishWithItem): WishItemResponse = WishItemResponse.from(result)

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
Expand All @@ -49,7 +46,7 @@ class WishlistController(
): ApiResponseBody<WishItemResponse> {
val result = wishlistService.registerFromUrl(rawUrl = request.url, userId = userId)
return ApiResponseBody.created(
WishItemResponse.fromRegistration(result, sourcePlatformResolver.resolve(result.item.link)),
WishItemResponse.fromRegistration(result),
)
}

Expand Down Expand Up @@ -93,7 +90,7 @@ class WishlistController(
): ApiResponseBody<WishDetailResponse> {
val result = wishlistService.getWish(userId = userId, wishId = wishId)
return ApiResponseBody.ok(
WishDetailResponse.from(result, sourcePlatformResolver.resolve(result.item.link), requesterId = userId),
WishDetailResponse.from(result, requesterId = userId),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,15 @@ data class WishDetailResponse(
val priceHistory: List<PriceHistoryEntry>,
) {
companion object {
// sourcePlatform 은 SourcePlatformResolver(빈)의 판정이라 호출부(컨트롤러)가 풀어 넘긴다.
// requesterId 는 editedByMe(수기 버전을 내가 넣었는지) 파생용 — 편집자 식별자(UUID)는 응답에 노출하지 않는다.
fun from(
detail: WishDetail,
sourcePlatform: String?,
requesterId: UUID,
): WishDetailResponse =
WishDetailResponse(
wish = WishItemResponse.WishView.from(detail.wish),
memo = detail.wish.memo,
item = WishItemResponse.ItemView.from(detail.item, detail.snapshot, sourcePlatform),
item = WishItemResponse.ItemView.from(detail.item, detail.snapshot, detail.sourcePlatform),
priceHistory = detail.history.map { PriceHistoryEntry.from(it, requesterId) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,19 @@ data class WishItemResponse(
) {
companion object {
fun from(
wish: Wish,
item: Item,
snapshot: ItemSnapshot,
sourcePlatform: String?,
result: WishWithItem,
): WishItemResponse =
WishItemResponse(
wish = WishView.from(wish),
item = ItemView.from(item, snapshot, sourcePlatform),
wish = WishView.from(result.wish),
item = ItemView.from(result),
)

// URL 등록 응답 전용 — 공유 attach 메타(#853)까지 싣는다. 등록만 이 오버로드를 쓰고,
// 목록·수정 등 다른 경로는 위 기본 from(플래그 null)을 유지한다.
fun fromRegistration(
result: WishWithItem,
sourcePlatform: String?,
): WishItemResponse =
fun fromRegistration(result: WishWithItem): WishItemResponse =
WishItemResponse(
wish = WishView.from(result.wish),
item = ItemView.from(result.item, result.snapshot, sourcePlatform),
item = ItemView.from(result),
reused = result.reused,
refreshNeeded = result.refreshNeeded,
)
Expand Down Expand Up @@ -123,7 +117,8 @@ data class WishItemResponse(
companion object {
// 표시값(status·name·price·currency·imageUrl)은 활성 snapshot 에서,
// 정체성(id·sourceUrl=상품 링크)은 item 에서 읽는다. snapshot 은 5단계 갱신에서 새 버전으로 스왑된다.
// sourcePlatform 은 SourcePlatformResolver(빈)의 판정이라 호출부(컨트롤러)가 풀어 넘긴다.
fun from(result: WishWithItem): ItemView = from(result.item, result.snapshot, result.sourcePlatform)

fun from(
item: Item,
snapshot: ItemSnapshot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.depromeet.piki.item.repository.ItemSnapshotRepository
import com.depromeet.piki.item.service.ItemDisplayService
import com.depromeet.piki.item.service.ItemRegistrar
import com.depromeet.piki.product.domain.ProductLink
import com.depromeet.piki.product.source.SourcePlatformResolver
import com.depromeet.piki.user.domain.IdentityType
import com.depromeet.piki.user.service.UserService
import com.depromeet.piki.wishlist.domain.WishCursor
Expand Down Expand Up @@ -43,8 +44,11 @@ class WishlistService(
private val itemDisplayService: ItemDisplayService,
private val itemQuotaGuard: ItemQuotaGuard,
private val userService: UserService,
private val sourcePlatformResolver: SourcePlatformResolver,
) {

private fun WishWithItem.withSourcePlatform(): WishWithItem = copy(sourcePlatform = sourcePlatformResolver.resolve(item.link))

// TODO AOP
private fun requireMember(userId: UUID) {
val user = userService.findActiveById(userId)
Expand All @@ -59,7 +63,7 @@ class WishlistService(
val link = ProductLink.parse(rawUrl)
wishPersistenceService.rejectIfAlreadyRegistered(userId, link)
itemRegistrar.accept(link, userId)
return wishPersistenceService.persist(userId, link)
return wishPersistenceService.persist(userId, link).withSourcePlatform()
}

fun presignImageUploads(
Expand All @@ -79,9 +83,10 @@ class WishlistService(
requireMember(userId)
if (imageKeys.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount()
imagePresignService.verifyUploaded(imageKeys)
return imageKeys.mapNotNull { key ->
skippingDuplicateKey(Item.SOURCE_IMAGE_KEY_UNIQUE) { wishPersistenceService.registerImage(key, userId) }
}
return imageKeys
.mapNotNull { key ->
skippingDuplicateKey(Item.SOURCE_IMAGE_KEY_UNIQUE) { wishPersistenceService.registerImage(key, userId) }
}.map { it.withSourcePlatform() }
}

@Transactional(readOnly = true)
Expand All @@ -106,7 +111,11 @@ class WishlistService(
pageWishes.map { wish ->
// item 은 wish 와 함께 영속화되며 병합 시 함께 옮겨진다(WishItemMergeListener). 없으면 영속화 경로가 깨진 코드 버그다.
val item = itemsById[wish.itemId] ?: error("wish ${wish.getId()} 의 item ${wish.itemId} 가 없다")
WishWithItem(wish = wish, item = item, snapshot = displayByCard.getValue(wish.displayCard()))
WishWithItem(
wish = wish,
item = item,
snapshot = displayByCard.getValue(wish.displayCard()),
).withSourcePlatform()
}

val nextCursor =
Expand Down Expand Up @@ -141,6 +150,7 @@ class WishlistService(
item = item,
snapshot = itemDisplayService.resolveDisplay(wish.displayCard()),
history = history,
sourcePlatform = sourcePlatformResolver.resolve(item.link),
)
}

Expand All @@ -163,7 +173,7 @@ class WishlistService(
if (listOfNotNull(name, price, currency, image).isEmpty()) {
memo?.let {
// 응답의 item 은 updateMemo 가 행 락 안에서 표시값(#857)으로 맞춰 돌려준다.
return wishPersistenceService.updateMemo(userId = userId, wishId = wishId, memo = it)
return wishPersistenceService.updateMemo(userId = userId, wishId = wishId, memo = it).withSourcePlatform()
}
}
// 이미지 형식 검증(빈 바이트·미지원 MIME) — 외부 호출 전에 동기로 거른다(400).
Expand All @@ -186,7 +196,7 @@ class WishlistService(
imageUrl = imageUrl,
currency = currency,
memo = memo,
)
).withSourcePlatform()
}

// 위시 item 의 상품 정보를 원본 링크로 재추출해 최신화한다(수동 새로고침). 추출(Gemini)은 디스패처가 비동기로
Expand All @@ -201,7 +211,7 @@ class WishlistService(
// refresh 계약 검증(링크 없음·FAILED 항목 등)은 persistence 안쪽이라 여기선 앞서 깎이는데, 그 두 사유는
// 클라가 refresh 버튼을 띄우지 않는 상태라 정상 흐름에서 반복 호출되지 않는다.
itemQuotaGuard.consume(userId, 1, ItemErrorCode.QUOTA_EXCEEDED)
return wishPersistenceService.refresh(userId = userId, wishId = wishId)
return wishPersistenceService.refresh(userId = userId, wishId = wishId).withSourcePlatform()
}

// 멱등 삭제: 없거나 이미 삭제됐으면 "이미 목표 상태(없음)"이므로 성공으로 본다(no-op).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ data class WishDetail(
val item: Item,
val snapshot: ItemSnapshot,
val history: List<ItemSnapshot>,
val sourcePlatform: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import com.depromeet.piki.wishlist.domain.Wish
// wish 기록과 그 wish 가 가리키는 상품의 정체성(item)·활성 버전(snapshot) 묶음. 등록 결과·조회 항목이 공유한다.
// 표시값(name/price/image/status)은 snapshot 에서, 정체성(id·sourceUrl=link)은 item 에서 온다.
// reused·refreshNeeded 는 공유 등록(#853)의 attach 메타 — 등록 경로만 채우고 그 외(조회·수기 수정 등)는 기본값 false.
// sourcePlatform 은 응답 경계(WishlistService)가 링크에서 판정해 채운다. 영속화 계층은 비워 둔다.
data class WishWithItem(
val wish: Wish,
val item: Item,
val snapshot: ItemSnapshot,
val reused: Boolean = false,
val refreshNeeded: Boolean = false,
val sourcePlatform: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() {
.andExpect(status().isOk)
.andExpect(jsonPath("$.data[0].item.name").value("새 기계값"))
.andExpect(jsonPath("$.data[0].item.price").value(90_000))
.andExpect(jsonPath("$.data[0].item.sourcePlatform").value("example"))
mockMvc
.perform(get("/api/v1/wishlists/$wishId").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}"))
.andExpect(status().isOk)
Expand Down
Loading