diff --git a/.claude/rules/testing-convention.md b/.claude/rules/testing-convention.md index 5805c2829..8ea9351d8 100644 --- a/.claude/rules/testing-convention.md +++ b/.claude/rules/testing-convention.md @@ -40,7 +40,7 @@ fun `같은 guest 가 같은 URL 을 두 번 등록하면 409 CONFLICT 가 반 "기본 동작을 throw 로 둔다" 는 원칙의 Kotlin 구현: ```kotlin -class StubProductLinkExtractor : ProductLinkExtractor { +class StubLinkSnapshotExtractor : LinkSnapshotExtractor { // 동작 가능한 기본값을 두면 명시 세팅을 빠뜨려도 통과해버린다. 그래서 기본을 throw 로 둔다. var build: (ProductLink) -> ProductSnapshot = { error("stub.build 를 테스트 본문에서 명시 세팅해야 한다.") @@ -49,7 +49,7 @@ class StubProductLinkExtractor : ProductLinkExtractor { } // 테스트 본문 -stubProductLinkExtractor.build = { link -> ProductSnapshot(link = link, name = "나이키", price = 99_000) } +stubLinkSnapshotExtractor.build = { ProductSnapshot(name = "나이키", price = 99_000) } ``` ## DB 바인딩 (이 repo 는 MySQL 을 쓴다) diff --git a/CLAUDE.md b/CLAUDE.md index 48ca620dc..f5a6ab8ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ FK 제약과 JPA 연관관계 어노테이션(`@ManyToOne` 등)을 쓰지 않는 외부 호출 (LLM · HTTP fetch · 결제 등 우리 바깥 의존성) 을 트랜잭션 안에 넣지 않는다. read-timeout 이 길어 (예: extractor 원격 추출 호출) 그 동안 DB 커넥션을 잡으면 커넥션 풀이 고갈되어 다른 API 까지 latency 가 번진다. - 외부 호출은 트랜잭션 바깥에서 끝내고, **영속화만 별도 빈에 위임**해 짧은 트랜잭션으로 묶는다. -- 예: `AsyncItemParsingWorker` 는 트랜잭션 없이 추출을 끝낸 뒤 상태 전이 영속화만 `ItemParsingService`(`@Transactional`)에 위임한다. 같은 빈 안 호출은 proxy 를 거치지 않아 `@Transactional` 이 걸리지 않으므로 별도 빈이어야 한다. +- 예: `ItemParser` 는 트랜잭션 없이 추출을 끝낸 뒤 결과 영속화만 `ItemParsingService`(`@Transactional`)에 위임한다. 같은 빈 안 호출은 proxy 를 거치지 않아 `@Transactional` 이 걸리지 않으므로 별도 빈이어야 한다. ## 로깅 @@ -126,9 +126,10 @@ FK 제약과 JPA 연관관계 어노테이션(`@ManyToOne` 등)을 쓰지 않는 `private val log = LoggerFactory.getLogger(javaClass)` 로 통일한다. 메시지는 `{}` placeholder 로 바인딩한다. ### 민감 정보는 마스킹해서 찍는다 -URL · 토큰 · 사용자 입력 원본 등 민감 정보를 로그에 그대로 남기지 않는다. URL 은 `ProductLink.safeLogString()` (host + path 만, 쿼리스트링 제외) 처럼 마스킹 헬퍼를 거친다. +토큰 · 비밀번호 같은 인증 값은 로그에 그대로 남기지 않는다. -- 이유: URL 쿼리스트링에 인증 토큰이 실릴 수 있어 raw 로깅 시 누출된다. (`.claude/rules/domain-exception.md` 의 "메시지 톤: 응답 detail 은 전부 사용자 대면, 개발자 구분은 로그로" 와 같은 결) +- 상품 URL 은 민감 정보로 보지 않는다. 쿼리까지 그대로 찍는다. DB 에도 평문으로 저장하는 값이고, 쿼리를 가리면 상품 번호가 쿼리에 있는 몰은 로그로 추적할 수 없다. +- logfmt 로 읽는 로그 줄에 URL 을 실을 때는 따옴표로 감싼다(`url="..."`). 값 안의 `=` 가 파싱을 깨면 그 줄이 대시보드·알림 집계에서 빠진다. ### 레벨 기준 - **info** — 정상 흐름·지표 (latency 등), 클라이언트 계약 위반 (검증 실패·도메인 예외). 클라이언트 잘못은 서버 입장에선 정상 동작이라 info. @@ -137,11 +138,11 @@ URL · 토큰 · 사용자 입력 원본 등 민감 정보를 로그에 그대 ## 도메인 용어 -- **product** — 외부 상품(쇼핑몰 페이지)과 그 추출 파이프라인. `ProductLink`(외부 URL) · `ProductLinkExtractor` · `ProductSnapshot`(추출 시점 결과). -- **item** — 상품의 정체성(`link`). 추출값·상태·이력은 버전(`ItemSnapshot`)이 들고, item 은 wish · tournament 가 참조하는 안정적 식별 단위다. -- **item_snapshot** (`ItemSnapshot`) — item 의 한 추출 버전. item 갱신 때마다 새 행이 쌓여 가격·이름 이력을 보존한다. 화면값은 버전들에서 계산한다(`ItemVersions`, 내 맥락의 값 vs 공유 READY). wish 는 item 을 참조하고 "기다리는 행"(`waitingSnapshotId`)만 따로 들며, tournament_item 은 출전 시점 고정 버전(pin)을 가리킨다. -- **wish** — user 가 item 을 위시리스트에 담은 기록 (`user_id` + `item_id`). -- **tournament** — item 들로 겨루는 토너먼트. `tournament_item`(출전 아이템) · `tournament_user`(참여자). +- **product** — 외부 상품(쇼핑몰 페이지)과 그 추출 파이프라인. `ProductLink`(외부 URL) · `LinkSnapshotExtractor` · `ProductSnapshot`(추출 시점 결과). +- **item** — 상품의 정체성(`canonicalUrl`: 링크는 정체 URL, 이미지는 이미지 키). 파싱 성공이나 사용자 입력으로 상품이 정해질 때 찾거나 만든다. 추출값·이력은 버전(`ItemSnapshot`)이 들고, item 은 wish · tournament 가 참조하는 안정적 식별 단위다. +- **item_snapshot** (`ItemSnapshot`) — item 의 한 버전. 파싱 성공·사용자 입력 때마다 새 행이 쌓여 가격·이름 이력을 보존한다. 상태 칸은 없고, 카드의 화면값·상태는 버전들과 최근 파싱 요청(`item_parse_outbox`)에서 계산한다(`ItemVersions`, `ItemCard`). 시작한 토너먼트의 값은 시작 시각(`started_at`)까지의 버전으로 계산한다. +- **wish** — user 가 상품을 위시리스트에 담은 기록 (`user_id` + `item_id`). `item_id` 는 상품이 정해지기 전 null. +- **tournament** — 상품들로 겨루는 토너먼트. `tournament_item`(출전 후보, 상품이 정해지기 전 `item_id` null) · `tournament_user`(참여자). 추출 결과(`ProductSnapshot`)를 영속화하면 그 상품의 `item`(정체성)과 `ItemSnapshot`(버전)이 된다. 외부 경계를 가리키는 이름에 `item` 을, 우리 엔티티에 `product` 를 쓰지 않는다. @@ -166,8 +167,8 @@ docker info > /dev/null 2>&1 || (open -a Docker && until docker info > /dev/null - **도메인 → 응답 DTO**: 응답 DTO 의 `companion object` 에 `from(도메인)` 정적 팩토리. 예: `UserResponse.from(user)`, `WishItemResponse.WishView.from(wish)`. - **요청 DTO → 도메인/커맨드**: 요청 DTO 의 `toXxx()` 인스턴스 메서드. 예: `CreateTournamentRequest.toCreateTournament()`. -- **외부 응답 → 도메인**: 외부 결과 객체의 `toXxx()`. 예: 생성 응답 클래스(`ExtractionResult`)의 확장 `toProductSnapshot(link)` (`RemoteExtractionContract.kt`). -- **스냅샷·도메인 → 엔티티**: 받는 엔티티의 `companion object` 정적 팩토리. 예: `ItemSnapshot.pending(...)`, `ItemSnapshot.manual(...)`. +- **외부 응답 → 도메인**: 외부 결과 객체의 `toXxx()`. 예: 원격 추출 응답 DTO 의 `toProductSnapshot()` (`RemoteExtractionContract.kt`). +- **스냅샷·도메인 → 엔티티**: 받는 엔티티의 `companion object` 정적 팩토리. 예: `ItemSnapshot.extracted(...)`, `ItemSnapshot.userInput(...)`. 매핑 분기·정규화는 단위 테스트로 검증한다 (`.claude/rules/testing-principles.md` 의 "분기 위치 결정 트리" (3) 매퍼 함수 분기). diff --git a/README.md b/README.md index 2dc1396bb..41d00e585 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ ┗ 📂 common ────────────── 응답 래퍼 · 예외 · 이벤트 · 레이트리밋 · 스토리지 ``` -- **도메인 용어 통일:** `item`은 상품의 고유 정체성을 의미하며, 추출값·상태·이력은 `ItemSnapshot`(버전)으로 관리합니다. `wish`는 사용자가 `item`을 담은 기록이며, `tournament_item`은 출전 시점의 버전을 고정하여 가리킵니다. 외부 경계를 가리키는 이름에는 `item`, 내부 엔티티에는 `product`를 사용합니다. +- **도메인 용어 통일:** `item`은 상품의 고유 정체성(링크는 정체 URL, 이미지는 업로드 키)을 의미하며 파싱 성공이나 사용자 입력으로 확정됩니다. 추출값·이력은 `ItemSnapshot`(버전)으로 관리하고, 카드의 상태는 버전과 최근 파싱 요청에서 계산합니다. `wish`와 `tournament_item`은 사용자가 담은 카드이며 상품이 확정되기 전에는 `item`을 가리키지 않습니다. 시작한 토너먼트의 값은 시작 시각까지의 버전으로 계산합니다. 외부 경계를 가리키는 이름에는 `product`, 우리 엔티티에는 `item`을 사용합니다. - **JPA 연관관계 제약 최소화:** 테이블 간 FK 제약과 JPA 연관관계 어노테이션을 배제했습니다. 관계는 Raw ID로만 유지하며, 참조 무결성은 서비스 계층에서 보장합니다. ### extractor diff --git a/notification-sse-spec.md b/notification-sse-spec.md index 1917d7c16..99699514b 100644 --- a/notification-sse-spec.md +++ b/notification-sse-spec.md @@ -94,7 +94,7 @@ data: 3f1c2b0e-7d4a-4c8b-9e2f-1a2b3c4d5e6f | `type` | enum | 알림 종류. 화면 분기 키 (6절) | | `kind` | enum | `WISH` \| `TOURNAMENT` \| `SYSTEM`. 전 알림 공통. 카드 라벨·아이콘용 | | `title` | string | 표시용 제목 (변수 치환 완료본) | -| `body` | string | 표시용 본문. 현재 대부분 `""` | +| `body` | string | 표시용 본문. `type` 에 따라 빈 문자열 `""` 일 수 있다 | | `refId` | long | 딥링크 대상 식별자. `type` 마다 대상이 다르다 (6절) | | `isRead` | boolean | 읽음 여부 | | `createdAt` | string | ISO-8601 LocalDateTime, 오프셋 없음. 예 `2026-06-06T14:32:10` | @@ -109,10 +109,11 @@ data: 3f1c2b0e-7d4a-4c8b-9e2f-1a2b3c4d5e6f | `type` | 필드 | 클라 동작 | |---|---|---| -| `TOURNAMENT_ITEM_PARSED` | `tournamentId`, `tournamentItemId`, `status` (`READY` \| `FAILED`) | 그 출전 카드를 찾아 `status` 반영. 수신자는 그 토너먼트 참여자 전원이고, 한 아이템이 여러 토너먼트에 있으면 각 토너먼트 좌표로 각자 받는다 | +| `TOURNAMENT_ITEM_PARSED` | `tournamentId`, `tournamentItemId`, `status` (`READY` \| `INCOMPLETE` \| `FAILED`) | 그 출전 카드를 찾아 `status` 반영. 수신자는 파싱을 요청한 출전 카드가 속한 토너먼트의 참여자 전원 | +| `TOURNAMENT_ITEM_MERGED` | `tournamentId`, `tournamentItemId`, `mergedIntoTournamentItemId` | 같은 상품의 출전 카드가 이미 있어 합쳐짐. `tournamentItemId` 카드를 지우고 `mergedIntoTournamentItemId` 카드를 다시 읽는다. 수신자는 그 토너먼트 참여자 전원 | | `UNREAD_COUNT_CHANGED` | `unreadCount` | 읽음 처리 후 다른 기기의 인앱 배지를 그 값으로 미러링(+1/-1 산수 없이). 읽은 기기도 받지만 응답과 같은 값이라 멱등 | -위시로만 담긴 아이템의 파싱 결과는 `silent-sync` 가 아니라 `notification`(`ITEM_PARSING_*`)으로 온다. +위시 카드의 파싱 결과와 합침은 `silent-sync` 없이 `notification`(`ITEM_PARSING_*`)으로만 온다. ## 6. `type` 별 분기표 @@ -123,14 +124,18 @@ data: 3f1c2b0e-7d4a-4c8b-9e2f-1a2b3c4d5e6f | `TOURNAMENT_ITEM_DELETED` | `TOURNAMENT` | 아이템 삭제 | tournamentId | **있음** | 참여자 (행위자 제외) | | `TOURNAMENT_STARTED` | `TOURNAMENT` | 토너먼트 시작 | tournamentId | 없음 | 참여자 (주최자 제외) | | `TOURNAMENT_PLAYED_FROM_LINK` | `TOURNAMENT` | 플레이링크로 플레이 시작 | ROOT tournamentId | 없음 | ROOT 주최자 | -| `TOURNAMENT_COMPLETED` | `TOURNAMENT` | 클론 완료 | ROOT tournamentId | 없음 | ROOT 주최자 | -| `TOURNAMENT_RESULT_READY` | `TOURNAMENT` | ROOT 결과 나옴 | ROOT tournamentId | 없음 | 참여자 (주최자 제외) | -| `ITEM_PARSING_COMPLETED` / `FAILED` / `INCOMPLETE` | `WISH` \| `TOURNAMENT` (출처) | 내 상품 추출 결과 | itemId | `TOURNAMENT` 출처면 있음, `WISH` 출처면 `wishId` | 본인 | -| `ITEM_REFRESH_COMPLETED` / `FAILED` | `WISH` | 위시 새로고침 결과 | itemId | 없음 (`wishId` 있음) | 본인 | +| `TOURNAMENT_COMPLETED` | `TOURNAMENT` | 참여자가 플레이 완료 | ROOT tournamentId | 없음 | ROOT 주최자 | +| `TOURNAMENT_RESULT_READY` | `TOURNAMENT` | 주최자가 플레이 완료 (결과 나옴) | ROOT tournamentId | 없음 | 참여자 (주최자 제외) | +| `ITEM_PARSING_COMPLETED` / `INCOMPLETE` | `WISH` \| `TOURNAMENT` (출처) | 내 상품 추출 결과 | itemId | `TOURNAMENT` 출처면 있음, `WISH` 출처면 `wishId` | 본인 | +| `ITEM_PARSING_FAILED` | `WISH` \| `TOURNAMENT` (출처) | 내 상품 추출 실패 | 요청한 카드 id (wishId 또는 tournamentItemId) | `TOURNAMENT` 출처면 있음, `WISH` 출처면 `wishId` | 본인 | +| `ITEM_PARSING_RECOVERED` | `WISH` \| `TOURNAMENT` (받는 사람 카드) | 미완이던 내 카드가 같은 상품의 다른 추출 성공으로 채워짐 | itemId | 받는 사람의 카드 | 같은 상품의 미완 카드 주인 (요청한 카드 제외) | +| `ITEM_PARSING_MERGED` | `WISH` \| `TOURNAMENT` (출처) | 요청한 카드가 같은 상품의 기존 카드에 합쳐짐 | itemId | 남은 카드 | 본인 | +| `ITEM_REFRESH_COMPLETED` | `WISH` | 위시 새로고침 성공 | itemId | 없음 (`wishId` 있음) | 본인 | +| `ITEM_REFRESH_FAILED` | `WISH` | 위시 새로고침 실패 | 요청한 위시 id (wishId) | 없음 (`wishId` 있음) | 본인 | | `ANNOUNCEMENT` | `SYSTEM` | 전체 공지 | 공지 id | 없음 | 전 유저 | - `title` 문구는 서버 템플릿이라 바뀔 수 있다. **문구가 아니라 `type` 으로 분기**한다. -- **파싱 알림(`ITEM_PARSING_*`)만 `kind` 로 출처를 가른다.** `WISH` 면 `wishId` 로 위시 상세(값이 `null` 이면 `/archive/wish`), `TOURNAMENT` 면 `tournamentId` 로 입장해 `tournamentItemId` 를 지목한다. 이 분기는 그 `type` 안에서만 쓴다. +- **파싱 알림(`ITEM_PARSING_*`)만 `kind` 로 출처를 가른다.** `RECOVERED` 는 받는 사람 자신의 카드 출처다. `WISH` 면 `wishId` 로 위시 상세(값이 `null` 이면 `/archive/wish`), `TOURNAMENT` 면 `tournamentId` 로 입장해 `tournamentItemId` 를 지목한다. 이 분기는 그 `type` 안에서만 쓴다. - 새로고침(`ITEM_REFRESH_*`)은 항상 `WISH` 이고 `wishId` 로 위시 상세에 간다. 실패해도 카드의 옛 값은 유지한다. ## 7. 연결 수명 @@ -184,6 +189,8 @@ es.addEventListener("notification", (e) => { case "ITEM_PARSING_COMPLETED": case "ITEM_PARSING_FAILED": case "ITEM_PARSING_INCOMPLETE": + case "ITEM_PARSING_RECOVERED": + case "ITEM_PARSING_MERGED": // 좌표는 남은 카드 if (n.kind === "TOURNAMENT") goToTournamentItem(n.tournamentId, n.tournamentItemId); else goToWishDetail(n.wishId); // null 이면 /archive/wish break; @@ -200,6 +207,10 @@ es.addEventListener("notification", (e) => { es.addEventListener("silent-sync", (e) => { const s = JSON.parse(e.data); if (s.type === "TOURNAMENT_ITEM_PARSED") updateTournamentItemCard(s.tournamentId, s.tournamentItemId, s.status); + if (s.type === "TOURNAMENT_ITEM_MERGED") { + removeTournamentItemCard(s.tournamentId, s.tournamentItemId); + reloadTournamentItemCard(s.tournamentId, s.mergedIntoTournamentItemId); + } if (s.type === "UNREAD_COUNT_CHANGED") setBadge(s.unreadCount); }); ``` @@ -234,7 +245,7 @@ fun openSse() { ## 9. 요약 - `type` 으로 분기한다. 문구로 분기하지 않는다 -- `refId` 의 의미는 `type` 마다 다르다 (tournamentId / itemId) +- `refId` 의 의미는 `type` 마다 다르다 (tournamentId / itemId / 실패 알림은 요청한 카드 id) - 좌표 유무는 `type` 이 가른다. `kind` 만 보고 좌표를 읽지 않는다 - 파싱 알림 안에서만 `kind` 로 출처를 가른다 - `silent-sync` 는 토스트·알림센터 없이 화면만 갱신한다 diff --git a/parsing-claim-ownership.md b/parsing-claim-ownership.md index 525ec25c5..a2197d006 100644 --- a/parsing-claim-ownership.md +++ b/parsing-claim-ownership.md @@ -2,6 +2,7 @@ > 2026-07-23. "claim 을 extractor 로 이관해야 하는가"를 놓고 벌인 설계 논쟁의 결론 기록. claim 은 큐에 쌓인 작업을 자기 몫으로 집어가 선점하는 행위를 말한다. > 배경: 링크·이미지 파싱 실행은 이미 원격 extractor 로 이관 완료된 상태에서, 남은 claim 머신 - dispatch(대기 작업을 집어 워커에 넘기는 루프)와 recover(오래 멈춘 작업을 되살리는 루프) - 의 소유권을 검토했다. +> 현 구조와 다름(2026-10, #1176): 작업 큐는 `item_parse_outbox` 로 분리됐고 버전(`item_snapshots`)에는 상태가 없다. 카드 상태는 PARSING·FAILED·READY·INCOMPLETE 로 계산한다. 아래 본문은 결정 당시의 기록이다. ## 1. 현 구조 (실측 기준) diff --git a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt index 55af7e6b2..eba752e2b 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt @@ -3,7 +3,7 @@ package com.depromeet.piki.admin.extraction import com.depromeet.piki.admin.access.AdminSession import com.depromeet.piki.admin.config.ClientIp import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled -import com.depromeet.piki.product.service.remote.ExtractionTarget +import com.depromeet.piki.product.domain.ParseInputType import io.swagger.v3.oas.annotations.Hidden import jakarta.servlet.http.HttpServletRequest import org.springframework.stereotype.Controller @@ -35,7 +35,7 @@ class AdminExtractionModelController( @PostMapping("/{target}") fun save( - @PathVariable target: ExtractionTarget, + @PathVariable target: ParseInputType, @RequestParam("model") rawModel: String, request: HttpServletRequest, view: Model, @@ -58,7 +58,7 @@ class AdminExtractionModelController( @PostMapping("/{target}/check") fun check( - @PathVariable target: ExtractionTarget, + @PathVariable target: ParseInputType, view: Model, ): String = try { @@ -70,7 +70,7 @@ class AdminExtractionModelController( @PostMapping("/{target}/clear") fun clear( - @PathVariable target: ExtractionTarget, + @PathVariable target: ParseInputType, request: HttpServletRequest, view: Model, ): String = diff --git a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelService.kt b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelService.kt index 068216b72..d6c08404d 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelService.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelService.kt @@ -3,11 +3,11 @@ package com.depromeet.piki.admin.extraction import com.depromeet.piki.admin.audit.AdminAuditAction import com.depromeet.piki.admin.audit.AdminAuditService import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.service.remote.DbExtractionModelSettings import com.depromeet.piki.product.service.remote.ExtractionModelEntity import com.depromeet.piki.product.service.remote.ExtractionModelJpaRepository import com.depromeet.piki.product.service.remote.ExtractionModelProbe -import com.depromeet.piki.product.service.remote.ExtractionTarget import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization @@ -31,7 +31,7 @@ class AdminExtractionModelService( @Transactional(readOnly = true) fun board(): List { val rows = settings.findAll() - return ExtractionTarget.entries.map { target -> + return ParseInputType.entries.map { target -> val entity = rows[target] ExtractionModelView(target = target, model = entity?.model, updatedAt = entity?.updatedAt) } @@ -40,7 +40,7 @@ class AdminExtractionModelService( // 저장 게이트 — 프로브가 성공한 모델만 등록된다. 아는 모델 목록을 코드에 박지 않으므로(그러면 새 모델마다 // 배포가 필요해진다) 유효성은 런타임 실측이 판정한다. 프로브가 던지는 예외 메시지가 그대로 화면 사유가 된다. fun save( - target: ExtractionTarget, + target: ParseInputType, rawModel: String, actor: String, clientIp: String?, @@ -53,13 +53,13 @@ class AdminExtractionModelService( // 온디맨드 확인 — 등록 당시엔 유효했어도 모델은 사라진다(preview 는 2주 공지 후 deprecate). 그 경우 extractor 가 // 기본 모델로 fallback 해 파싱은 계속 되지만, 화면에 적힌 모델과 실제로 도는 모델이 갈린 채 아무도 모르게 된다. // 이 버튼이 그 유령 상태를 운영자가 직접 확인하는 자리다. - fun check(target: ExtractionTarget) { - val model = settings.modelOf(target) ?: throw IllegalArgumentException("지정된 모델이 없습니다 (extractor 기본 모델로 동작 중).") + fun check(target: ParseInputType) { + val model = settings.modelOverrideOf(target) ?: throw IllegalArgumentException("지정된 모델이 없습니다 (extractor 기본 모델로 동작 중).") probe.verify(target, model) } fun clear( - target: ExtractionTarget, + target: ParseInputType, actor: String, clientIp: String?, ) = writer.clear(target, actor, clientIp) @@ -92,7 +92,7 @@ class ExtractionModelWriter( // 공백 창이 생긴다 (AdminExtractionPolicyService.save 와 같은 이유). @Transactional fun write( - target: ExtractionTarget, + target: ParseInputType, model: String, actor: String, clientIp: String?, @@ -110,7 +110,7 @@ class ExtractionModelWriter( @Transactional fun clear( - target: ExtractionTarget, + target: ParseInputType, actor: String, clientIp: String?, ) { @@ -137,7 +137,7 @@ class ExtractionModelWriter( // model 이 null 이면 미지정 — 그 경로는 요청에 모델을 싣지 않아 extractor 기본 모델로 동작한다. data class ExtractionModelView( - val target: ExtractionTarget, + val target: ParseInputType, val model: String?, val updatedAt: LocalDateTime?, ) diff --git a/src/main/kotlin/com/depromeet/piki/common/exception/DuplicateKey.kt b/src/main/kotlin/com/depromeet/piki/common/exception/DuplicateKey.kt deleted file mode 100644 index 9010a0cd0..000000000 --- a/src/main/kotlin/com/depromeet/piki/common/exception/DuplicateKey.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.depromeet.piki.common.exception - -import org.springframework.dao.DataIntegrityViolationException -import java.sql.SQLIntegrityConstraintViolationException - -private const val MYSQL_DUPLICATE_ENTRY = 1062 - -// 벤더 코드만 보면 어느 unique 인지 모른다. 같은 INSERT 에 제약이 여럿 걸리면 무관한 충돌까지 삼키므로 제약명까지 맞춘다. -fun DataIntegrityViolationException.isDuplicateKey(constraint: String): Boolean { - val cause = mostSpecificCause as? SQLIntegrityConstraintViolationException ?: return false - return cause.errorCode == MYSQL_DUPLICATE_ENTRY && cause.message?.contains(constraint) == true -} - -// 트랜잭션 밖에서만 부른다. 안에서 잡으면 rollback-only 가 찍혀 커밋 때 터진다. -inline fun skippingDuplicateKey( - constraint: String, - block: () -> T, -): T? = - try { - block() - } catch (e: DataIntegrityViolationException) { - if (e.isDuplicateKey(constraint)) null else throw e - } diff --git a/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorage.kt b/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorage.kt index 6bb7328b1..b9b161655 100644 --- a/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorage.kt +++ b/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorage.kt @@ -10,6 +10,8 @@ interface ImageStorage { contentType: String, ): String + fun urlOf(key: String): String + // 클라이언트가 서버를 거치지 않고 S3 에 직접 PUT 업로드할 수 있는 presigned URL 을 발급한다(이미지 등록 v2). fun presignUpload( key: String, @@ -22,7 +24,7 @@ interface ImageStorage { // 객체 없음은 정상 결과(false)이고, 스토리지 장애만 ImageStorageException(502)로 던진다. fun exists(key: String): Boolean - // key 에 해당하는 객체의 바이트를 읽는다 — 클라가 presigned 로 직접 올린 raw 를 서버가 검증·가공해야 할 때 쓴다. + // key 에 해당하는 객체의 바이트를 읽는다 — 클라가 presigned 로 직접 올린 객체를 서버가 검증해야 할 때 쓴다. // (상품 이미지 등록은 extractor 가 bucket·key 를 받아 자기가 내려받으므로 이 경로를 타지 않는다.) // 객체 없음도 장애로 본다 — 호출부가 exists 로 존재를 확인한 뒤 부르는 계약이라, 그 사이 사라졌으면 예외가 맞다. fun download(key: String): ByteArray diff --git a/src/main/kotlin/com/depromeet/piki/common/storage/S3ImageStorage.kt b/src/main/kotlin/com/depromeet/piki/common/storage/S3ImageStorage.kt index c50e64bb2..5f6b65fad 100644 --- a/src/main/kotlin/com/depromeet/piki/common/storage/S3ImageStorage.kt +++ b/src/main/kotlin/com/depromeet/piki/common/storage/S3ImageStorage.kt @@ -41,9 +41,11 @@ class S3ImageStorage( .build(), RequestBody.fromBytes(bytes), ) - "${s3Properties.publicBaseUrl.trimEnd('/')}/${encodePath(key)}" + urlOf(key) }.getOrElse { e -> throw ImageStorageException.uploadFailed(e) } + override fun urlOf(key: String): String = "${s3Properties.publicBaseUrl.trimEnd('/')}/${encodePath(key)}" + override fun presignUpload( key: String, contentType: String, diff --git a/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt b/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt index fa5415b70..54bed2db8 100644 --- a/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.image.controller.dto +import com.depromeet.piki.image.domain.ProductImageKey import io.swagger.v3.oas.annotations.media.Schema // 이미지 등록 v2 확정 요청 — presigned 로 업로드를 마친 이미지 key 목록(발급 응답의 imageKey, 1~5개). @@ -12,4 +13,6 @@ data class ConfirmImageUploadRequest( requiredMode = Schema.RequiredMode.REQUIRED, ) val imageKeys: List, -) +) { + fun toImageKeys(): List = imageKeys.map(ProductImageKey::parse) +} diff --git a/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUpload.kt b/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUpload.kt index aa84dd2a9..422c44593 100644 --- a/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUpload.kt +++ b/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUpload.kt @@ -27,7 +27,7 @@ data class PresignedImageUpload( companion object { fun from(upload: PresignedRawUpload): PresignedImageUpload = PresignedImageUpload( - imageKey = upload.imageKey, + imageKey = upload.imageKey.value, uploadUrl = upload.uploadUrl, contentType = upload.contentType, ) diff --git a/src/main/kotlin/com/depromeet/piki/image/domain/ProductImageKey.kt b/src/main/kotlin/com/depromeet/piki/image/domain/ProductImageKey.kt new file mode 100644 index 000000000..e03b841d4 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/image/domain/ProductImageKey.kt @@ -0,0 +1,24 @@ +package com.depromeet.piki.image.domain + +import java.util.UUID + +// 상품 사진 업로드의 raw 키. 이미지 아이템의 정체 값이라 지우지 않는다 +@JvmInline +value class ProductImageKey private constructor( + override val value: String, +) : UploadedImageKey { + override fun toString(): String = value + + companion object { + private const val PREFIX = "items/raw/" + private val FORMAT = UploadedImageKey.formatOf(PREFIX) + + fun newRaw(extension: String): ProductImageKey = ProductImageKey("$PREFIX${UUID.randomUUID()}.$extension") + + // 클라이언트가 돌려준 키. 발급 형식이 아니면 우리 객체가 아니다 + fun parse(raw: String): ProductImageKey { + if (!FORMAT.matches(raw)) throw ImageUploadException.invalidKey() + return ProductImageKey(raw) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/image/domain/ProfileImageKey.kt b/src/main/kotlin/com/depromeet/piki/image/domain/ProfileImageKey.kt new file mode 100644 index 000000000..5c12b2c69 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/image/domain/ProfileImageKey.kt @@ -0,0 +1,31 @@ +package com.depromeet.piki.image.domain + +import java.util.UUID + +// 프로필 사진 업로드 키. 처음부터 사용자 접두 아래라 탈퇴 때 접두째 지워진다 +@JvmInline +value class ProfileImageKey private constructor( + override val value: String, +) : UploadedImageKey { + override fun toString(): String = value + + companion object { + private const val PREFIX = "profiles/" + + fun prefixOf(owner: UUID): String = "$PREFIX$owner/" + + fun newFor( + owner: UUID, + extension: String, + ): ProfileImageKey = ProfileImageKey("${prefixOf(owner)}${UUID.randomUUID()}.$extension") + + // 주인까지 맞춘다. 남이 올린 사진 키로 자기 프로필을 바꾸지 못하게 + fun parse( + raw: String, + owner: UUID, + ): ProfileImageKey { + if (!UploadedImageKey.formatOf(prefixOf(owner)).matches(raw)) throw ImageUploadException.invalidKey() + return ProfileImageKey(raw) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/image/domain/UploadedImageKey.kt b/src/main/kotlin/com/depromeet/piki/image/domain/UploadedImageKey.kt new file mode 100644 index 000000000..f272ef0cc --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/image/domain/UploadedImageKey.kt @@ -0,0 +1,16 @@ +package com.depromeet.piki.image.domain + +// presign 으로 클라이언트가 직접 올린 객체의 키. 용도마다 접두가 달라 서로 섞이지 않는다 +sealed interface UploadedImageKey { + val value: String + + val extension: String get() = value.substringAfterLast('.') + + companion object { + fun formatOf(prefix: String): Regex = + Regex( + "^$prefix[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + + "\\.(${ProductImage.EXTENSIONS.joinToString("|")})$", + ) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt index fa65c094f..05922a724 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt @@ -2,8 +2,10 @@ package com.depromeet.piki.image.service import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.common.storage.S3Properties +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.image.domain.ProfileImageKey +import com.depromeet.piki.image.domain.UploadedImageKey import com.depromeet.piki.image.domain.ImageUploadException -import com.depromeet.piki.image.domain.ProductImage import com.depromeet.piki.image.domain.UploadFormat import com.depromeet.piki.image.domain.UploadSize import com.depromeet.piki.image.service.dto.PresignedRawUpload @@ -19,35 +21,28 @@ class ImagePresignService( ) { private val log = LoggerFactory.getLogger(javaClass) - fun presignRawUploads(formats: List): List = - formats.map { presignRawUpload(it.extension, it.contentType, it.size) } + fun presignProductUploads(formats: List): List = + formats.map { presign(ProductImageKey.newRaw(it.extension), it.contentType, it.size) } - fun presignRawUpload( + fun presignProfileUpload( + owner: UUID, extension: String, contentType: String, size: UploadSize, + ): PresignedRawUpload = presign(ProfileImageKey.newFor(owner, extension), contentType, size) + + private fun presign( + key: UploadedImageKey, + contentType: String, + size: UploadSize, ): PresignedRawUpload { - val key = "$RAW_PREFIX${UUID.randomUUID()}.$extension" - val url = imageStorage.presignUpload(key, contentType, size.bytes, s3Properties.presignedUploadExpiry) + val url = imageStorage.presignUpload(key.value, contentType, size.bytes, s3Properties.presignedUploadExpiry) return PresignedRawUpload(imageKey = key, uploadUrl = url, contentType = contentType) } - fun extensionOf(imageKey: String): String = imageKey.substringAfterLast('.') - - fun verifyUploaded(imageKeys: List) { - imageKeys.forEach { key -> - if (!RAW_KEY_REGEX.matches(key)) throw ImageUploadException.invalidKey() - if (!imageStorage.exists(key)) throw ImageUploadException.notUploaded() + fun verifyUploaded(keys: List) { + keys.forEach { key -> + if (!imageStorage.exists(key.value)) throw ImageUploadException.notUploaded() } } - - companion object { - const val RAW_PREFIX = "items/raw/" - - private val RAW_KEY_REGEX = - Regex( - "^${RAW_PREFIX}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + - "\\.(${ProductImage.EXTENSIONS.joinToString("|")})$", - ) - } } diff --git a/src/main/kotlin/com/depromeet/piki/image/service/ImageSnapshotExtractor.kt b/src/main/kotlin/com/depromeet/piki/image/service/ImageSnapshotExtractor.kt index 603392050..3b6f906e8 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/ImageSnapshotExtractor.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/ImageSnapshotExtractor.kt @@ -1,8 +1,9 @@ package com.depromeet.piki.image.service +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.product.service.ProductSnapshot // 원본 이미지 키를 받아 상품 스냅샷을 돌려줌. 대표 이미지(imageUrl)까지 채워 옴 interface ImageSnapshotExtractor { - fun extract(imageKey: String): ProductSnapshot + fun extract(key: ProductImageKey): ProductSnapshot } diff --git a/src/main/kotlin/com/depromeet/piki/image/service/dto/PresignedRawUpload.kt b/src/main/kotlin/com/depromeet/piki/image/service/dto/PresignedRawUpload.kt index 900de9e3e..14c51a676 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/dto/PresignedRawUpload.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/dto/PresignedRawUpload.kt @@ -1,9 +1,11 @@ package com.depromeet.piki.image.service.dto +import com.depromeet.piki.image.domain.UploadedImageKey + // presigned 발급 결과 한 건 — 클라가 S3 에 직접 PUT 할 URL 과, confirm 때 되돌려줄 raw object key. // contentType 은 발급 시 서명에 박은 값이라, 클라는 이 content-type 헤더로 PUT 해야 한다(S3 가 강제). data class PresignedRawUpload( - val imageKey: String, + val imageKey: UploadedImageKey, val uploadUrl: String, val contentType: String, ) diff --git a/src/main/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractor.kt b/src/main/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractor.kt index 545c4707b..624ef9e4b 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractor.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractor.kt @@ -2,45 +2,35 @@ package com.depromeet.piki.image.service.remote import com.depromeet.piki.common.storage.S3Properties import com.depromeet.piki.contracts.extraction.v1.ImageExtractionRequest +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.image.service.ImageSnapshotExtractor +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.product.service.remote.ExtractionEndpoints import com.depromeet.piki.product.service.remote.ExtractionModelSettings -import com.depromeet.piki.product.service.remote.ExtractionTarget import com.depromeet.piki.product.service.remote.RemoteExtractionContract -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component import org.springframework.web.client.RestClient -// 링크와 같은 계약 번역(RemoteExtractionContract)을 쓰고 요청만 다름. URL 대신 원본의 S3 위치(bucket·key)를 넘김 -// bucket 을 요청에 싣는 이유: 버킷은 환경(dev/prod)마다 다른데 그 구분은 본 서버 설정(S3Properties)이 -// 이미 쥐고 있다 — extractor 는 버킷 무관(무상태)으로 두고 호출자가 자기 버킷을 알려준다. @Component class HttpImageSnapshotExtractor( @Qualifier("remoteExtractionRestClient") private val restClient: RestClient, private val s3Properties: S3Properties, private val modelSettings: ExtractionModelSettings, ) : ImageSnapshotExtractor { - private val log = LoggerFactory.getLogger(javaClass) - - override fun extract(imageKey: String): ProductSnapshot { - log.info("image extract route=remote key={}", imageKey) + override fun extract(key: ProductImageKey): ProductSnapshot { val request = ImageExtractionRequest .newBuilder() .setBucket(s3Properties.bucket) - .setKey(imageKey) - // 링크와 축이 갈려 있어 IMAGE 지정만 따른다 — 이미지는 vision 이라 링크에 맞는 모델이 여기서 맞지 않을 수 있다. - // 지정이 없으면 필드를 안 실어 extractor 의 기본 모델로 간다. - modelSettings.modelOf(ExtractionTarget.IMAGE)?.let(request::setModel) - // 이미지 추출엔 원본 URL 이 없어 link=null (extractor 계약 §2 image 와 동일). + .setKey(key.value) + modelSettings.modelOverrideOf(ParseInputType.IMAGE)?.let(request::setModel) return RemoteExtractionContract.postForSnapshot( restClient = restClient, path = ExtractionEndpoints.IMAGE_EXTRACTION, request = request.build(), - link = null, - target = "key=$imageKey", + target = "key=$key", ) } } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/CanonicalInput.kt b/src/main/kotlin/com/depromeet/piki/item/domain/CanonicalInput.kt new file mode 100644 index 000000000..1f6b5267c --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/CanonicalInput.kt @@ -0,0 +1,18 @@ +package com.depromeet.piki.item.domain + +import com.depromeet.piki.product.domain.ParseInputType +import com.depromeet.piki.product.domain.ProductLink + +// 아이템의 정체 값. 귀결점(없으면 등록 입력)을 정규화한 값이라 등록 입력의 정규화 값과 다를 수 있다 +@JvmInline +value class CanonicalInput( + val value: String, +) { + override fun toString(): String = value + + // 링크 아이템의 정체 값은 그 자체가 상품 주소다. 이미지 아이템에서는 부르지 않는다 + fun toLink(): ProductLink = ProductLink.parse(value) + + // 정체 값을 그대로 다시 파싱 입력으로 쓴다 + fun toParseInput(type: ParseInputType): ParseInput = ParseInput.of(type, value) +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/CardQuery.kt b/src/main/kotlin/com/depromeet/piki/item/domain/CardQuery.kt new file mode 100644 index 000000000..c8356d946 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/CardQuery.kt @@ -0,0 +1,13 @@ +package com.depromeet.piki.item.domain + +import java.time.LocalDateTime +import java.util.UUID + +data class CardQuery( + val slot: ItemSlot, + val itemId: Long?, + val owner: UUID, + + // asOf 가 있으면 그 시각까지의 버전으로 계산한다(시작한 토너먼트) + val asOf: LocalDateTime? = null, +) diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/Item.kt b/src/main/kotlin/com/depromeet/piki/item/domain/Item.kt index 99cb4bfb2..88c781417 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/Item.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/Item.kt @@ -1,72 +1,42 @@ package com.depromeet.piki.item.domain import com.depromeet.piki.common.domain.LongBaseEntity -import com.depromeet.piki.product.domain.CanonicalLink +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.domain.ProductLinkConverter import jakarta.persistence.Column -import jakarta.persistence.Convert import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.Table -// 상품의 정체성. 외부 입력(link=URL 또는 sourceImageKey=S3 raw 이미지 key)으로 식별되며, 추출값(name·price·image·currency)·상태· -// 이력은 전적으로 ItemSnapshot(버전)이 보유한다 — item 자신은 추출 데이터를 들지 않는 안정적 식별 단위다(4a 에서 추출 필드를 분리). -// -// 정체성의 확정 표식은 canonical(정규화된 귀결점, #825)이다. URL 등록 시점엔 입력 URL 이 임시 정체성 노릇을 하고 -// (같은 문자열 재등록은 item_links 별칭으로 이 행에 붙는다), 파싱이 finalUrl 을 돌려주면 claimCanonical 로 확정된다. -// canonical_hash unique 덕에 같은 귀결점의 item 은 하나만 존재한다 — 확정 시도가 충돌하면 이 행이 임시였다는 뜻이라 -// 기존 item 으로 병합(snapshot 재부모화)된다. -// -// 입력은 link XOR sourceImageKey 다 — URL 추출 경로는 link 를, 이미지 추출 경로는 sourceImageKey 를 채운다(두 경로가 같은 item 을 만든다). -// 입력은 바뀌면 사실상 다른 상품이라 재등록 영역으로 보고 불변(val). @Entity @Table(name = "items") class Item( - @Convert(converter = ProductLinkConverter::class) - @Column(name = "source_url", nullable = true, length = 2048) - val link: ProductLink? = null, - // 바이트 대신 S3 key 를 들어 파싱을 다시 돌려도 원본을 읽을 수 있음 - @Column(nullable = true, length = 255, unique = true) - val sourceImageKey: String? = null, -) : LongBaseEntity() { - // 정규화된 귀결점(#825 정체성 키). 파싱 성공 시점에 claimCanonical 로 확정되므로 그 전(PENDING·FAILED)엔 null 이다. - // 이미지 등록 item(link 없음)과 기존 행(forward-only 소급 제외)도 null 로 남는다. - @Column(length = 2048) - var canonicalUrl: String? = null - protected set - // canonical_url 의 SHA-256 hex — unique 인덱스용 고정 길이 대리키 (utf8mb4 인덱스 상한이 2048자 직접 unique 를 막는다). - @Column(length = 64) - var canonicalHash: String? = null - protected set + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + val sourceType: ParseInputType, - // 둘 다 채워지면 링크가 우선돼 이미지 키가 조용히 무시됨. 둘 다 비는 것은 테스트 픽스처라 허용 - init { - require(listOfNotNull(link, sourceImageKey).size <= 1) { - "Item 은 link 와 sourceImageKey 중 하나만 가질 수 있다" - } - } + // 귀결점을 정규화한 값. 링크는 정체 URL, 이미지는 이미지 키 + @Column(nullable = false, unique = true, length = ProductLink.MAX_LENGTH) + val canonicalUrl: CanonicalInput, +) : LongBaseEntity() { + // 출처 표시용. 이미지 아이템은 쇼핑몰 주소가 없다 + fun sourceLink(): ProductLink? { + if (sourceType != ParseInputType.LINK) return null + return canonicalUrl.toLink() + } - // 파싱이 알아낸 귀결점으로 정체성을 확정한다. 정체성은 불변이 원칙이라 재확정은 코드 버그다(check 500) — - // 재추출(갱신)은 이미 canonical 이 확정된 item 의 새 버전을 만들 뿐 이 메서드를 다시 타지 않는다. - // 같은 값 재확정은 멱등 허용: 병합 경합에서 진 쪽이 재시도할 때 같은 귀결점이면 무해하다. - // - // 동시성 주의: 이 검사는 로드된 인스턴스의 인메모리 값만 본다 — 같은 item 을 두 트랜잭션이 동시에 확정하는 - // 경합(둘 다 null 로 읽고 각자 다른 값 커밋)은 여기서 못 막고, 영속 계층의 조건부 claim - // (UPDATE ... WHERE canonical_hash IS NULL, 기록 단계 PR에서 도입)이 직렬화를 진다. - // 여기는 단일 트랜잭션 안의 오사용을 잡는 최후 보루다. - fun claimCanonical(canonical: CanonicalLink) { - val current = canonicalHash - current?.let { - check(it == canonical.hash) { "이미 canonical 이 확정된 item 의 정체성은 바꿀 수 없다" } - return - } - canonicalUrl = canonical.url - canonicalHash = canonical.hash + fun refreshInput(): ParseInput { + if (sourceType != ParseInputType.LINK) throw ItemException.notRefreshable() + return canonicalUrl.toParseInput(sourceType) } companion object { - const val SOURCE_IMAGE_KEY_UNIQUE = "uk_items_source_image_key" + fun of( + sourceType: ParseInputType, + canonical: CanonicalInput, + ): Item = Item(sourceType, canonical) } } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt index bfdc9b7a3..0236ffe12 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemErrorCode.kt @@ -3,31 +3,16 @@ package com.depromeet.piki.item.domain import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.common.exception.ErrorCode -// ItemException 의 code 배정표(에픽 #728). 번호는 append-only — 재배치·결번 침범 금지. -// code·category·message 를 한 엔트리에 모아 single source 로 둔다: status 는 category.httpStatus 로, -// 응답 detail·로그·OpenAPI 카탈로그는 message 로 파생된다. -// -// 공개 JSON API 도달이라 ErrorCodeRegistry 에 등록한다(어드민 SSR 전용이라 미등록인 -// AnnouncementImageErrorCode 와 갈리는 지점). 던지는 곳은 ItemSnapshot.manual(수기 수정 병합 검증)이며, -// 위시 보정(PATCH /wishlists/{id})·토너먼트 아이템 보정(PATCH /tournaments/{id}/items/{itemId}) 두 -// 엔드포인트에서 GlobalExceptionHandler 를 거쳐 wire code 로 나간다. -// -// **결번**: ITEM-001(ALREADY_READY)·ITEM-002(STILL_PROCESSING)는 수기 수정 상시 허용(#825 결정 4, -// 2026-07-31)으로 폐기됐다. 번호는 재사용하지 않는다. enum class ItemErrorCode( override val code: String, override val category: ErrorCategory, override val message: String, ) : ErrorCode { - // 필수값 누락 셋은 합치지 않는다 — manual 의 순차 guard 가 각각 다른 필드를 가리키므로, - // code 를 나눠 둬야 클라가 "어느 필드가 비었는지" 를 안내할 수 있다. NAME_REQUIRED_FOR_READY("ITEM-003", ErrorCategory.INVALID_INPUT, "상품 이름을 입력해 주세요."), PRICE_REQUIRED_FOR_READY("ITEM-004", ErrorCategory.INVALID_INPUT, "상품 가격을 입력해 주세요."), IMAGE_REQUIRED_FOR_READY("ITEM-005", ErrorCategory.INVALID_INPUT, "상품 이미지를 등록해 주세요."), - - // 006 은 한도 code 통합(WISH-010·TOURNAMENT-037 대체)에서 추가됐다. 한도는 아이템 등록의 사실이라 - // 담는 자리(위시·토너먼트)마다 code 를 나눌 이유가 없다 — 카운터도 하나다. - // 문구는 몫의 주인을 드러내지 않는 쪽으로 고정한다: 토너먼트는 오너 몫에서 깎지만 이 응답은 참여 게스트도 - // 받으므로, 남의 사용량이 문구로 새면 안 된다. 남은 시간은 문구가 아니라 Retry-After 헤더가 전한다. QUOTA_EXCEEDED("ITEM-006", ErrorCategory.TOO_MANY_REQUESTS, "지금은 추가할 수 없어요. 잠시 후 다시 시도해 주세요."), + NOT_REFRESHABLE("ITEM-007", ErrorCategory.INVALID_INPUT, "링크로 등록한 상품만 새로고침할 수 있어요."), + PARSING_IN_PROGRESS("ITEM-008", ErrorCategory.CONFLICT, "아직 정보를 가져오는 중인 상품이에요. 끝난 뒤 다시 입력해 주세요."), + EMPTY_USER_INPUT("ITEM-009", ErrorCategory.INVALID_INPUT, "바꿀 정보를 입력해 주세요."), } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemException.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemException.kt index 9c5adff17..9aa340983 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemException.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemException.kt @@ -18,13 +18,16 @@ class ItemException private constructor( override val httpStatus: HttpStatus get() = errorCode.category.httpStatus companion object { - // 수기 수정(MANUAL 새 버전)의 입력 경계 계약 — base 값과 병합해도 필수 필드가 없으면 "쓸 수 있는 상품" - // (READY)이 될 수 없다. 엔티티 불변식이 최후의 보루라면, 이건 그 전에 클라이언트에게 400 으로 돌려주는 경계다. - // (ALREADY_READY·STILL_PROCESSING 409 는 수기 수정 상시 허용(#825 결정 4)으로 폐기 — ItemErrorCode 결번 참고.) fun nameRequiredForReady(): ItemException = ItemException(ItemErrorCode.NAME_REQUIRED_FOR_READY) fun priceRequiredForReady(): ItemException = ItemException(ItemErrorCode.PRICE_REQUIRED_FOR_READY) fun imageRequiredForReady(): ItemException = ItemException(ItemErrorCode.IMAGE_REQUIRED_FOR_READY) + + fun notRefreshable(): ItemException = ItemException(ItemErrorCode.NOT_REFRESHABLE) + + fun parsingInProgress(): ItemException = ItemException(ItemErrorCode.PARSING_IN_PROGRESS) + + fun emptyUserInput(): ItemException = ItemException(ItemErrorCode.EMPTY_USER_INPUT) } } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemLink.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemLink.kt deleted file mode 100644 index a1d48e6b7..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemLink.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.depromeet.piki.item.domain - -import com.depromeet.piki.common.domain.LongBaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Table - -// 상품 별칭(#825): "이 링크 모양은 이 item 이더라"의 기록 한 줄. 등록 즉시 원본(정규화 후)이 기록되어 -// 파싱 완료 전(pending 창)의 같은 문자열 재등록이 기존 item 에 붙고, 파싱 완료 시 귀결점도 한 줄 추가된다. -// url_hash unique 가 같은 링크 동시 등록을 "한쪽만 성공"으로 직렬화한다(#826 의 바닥). -// -// 행은 불변이다 — 별칭은 "본 적 있다"는 사실의 축적이라 수정 개념이 없고, 병합 시 item_id 이관만 -// 리포지토리 벌크 연산으로 일어난다(엔티티를 통한 개별 수정 경로를 두지 않는다). -@Entity -@Table(name = "item_links") -class ItemLink( - @Column(name = "url", nullable = false, length = URL_MAX_LENGTH) - val url: String, - // url 정규형의 SHA-256 hex — unique 색인용 고정 길이 대리키. 값 생성은 CanonicalLink 가 책임진다. - @Column(name = "url_hash", nullable = false, length = HASH_LENGTH) - val urlHash: String, - @Column(name = "item_id", nullable = false) - val itemId: Long, -) : LongBaseEntity() { - // 불변식 — url·hash 는 CanonicalLink.of 산출물이라 정상 경로에선 항상 유효하다. 어긋나면 호출부 버그(500). - init { - require(url.isNotBlank()) { "별칭 url 은 비어 있을 수 없다" } - require(url.length <= URL_MAX_LENGTH) { "별칭 url 길이가 ${URL_MAX_LENGTH}자를 초과했다" } - require(urlHash.length == HASH_LENGTH) { "url_hash 는 SHA-256 hex ${HASH_LENGTH}자여야 한다" } - } - - companion object { - const val URL_MAX_LENGTH = 2048 - const val HASH_LENGTH = 64 - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemParseOutbox.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemParseOutbox.kt index 75bce491b..f0b29fdd5 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemParseOutbox.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemParseOutbox.kt @@ -1,6 +1,8 @@ package com.depromeet.piki.item.domain import com.depromeet.piki.common.domain.LongBaseEntity +import com.depromeet.piki.product.domain.ParseInputType +import com.depromeet.piki.product.domain.ProductLink import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -11,8 +13,27 @@ import jakarta.persistence.Table @Table(name = "item_parse_outbox") class ItemParseOutbox( + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + val slotType: ItemSlotType, + @Column(nullable = false) - val itemSnapshotId: Long, + val slotId: Long, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + val purpose: ParsePurpose, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + val inputType: ParseInputType, + + @Column(nullable = false, length = ProductLink.MAX_LENGTH) + val inputValue: String, + + // 같은 슬롯의 중복 요청 판정 키. 귀결점을 따라가기 전의 값이라 아이템의 정체 값과 다를 수 있다 + @Column(length = ProductLink.MAX_LENGTH) + val normalizedInput: NormalizedInput?, ) : LongBaseEntity() { @Enumerated(EnumType.STRING) @@ -20,8 +41,18 @@ class ItemParseOutbox( var status: ItemParseOutboxStatus = ItemParseOutboxStatus.PENDING protected set + val slot: ItemSlot get() = ItemSlot(slotType, slotId) + + val input: ParseInput get() = ParseInput.of(inputType, inputValue) + + fun isRefresh(): Boolean = purpose == ParsePurpose.REFRESH + fun isProcessing(): Boolean = status == ItemParseOutboxStatus.PROCESSING + fun isInProgress(): Boolean = status == ItemParseOutboxStatus.PENDING || status == ItemParseOutboxStatus.PROCESSING + + fun isFailed(): Boolean = status == ItemParseOutboxStatus.FAILED + fun claim() = transit(from = ItemParseOutboxStatus.PENDING, to = ItemParseOutboxStatus.PROCESSING) fun requeue() = transit(from = ItemParseOutboxStatus.PROCESSING, to = ItemParseOutboxStatus.PENDING) @@ -37,4 +68,20 @@ class ItemParseOutbox( check(status == from) { "item parse outbox ${getIdOrNull()} 은 $status 라 $to 로 갈 수 없다" } status = to } + + companion object { + fun of( + slot: ItemSlot, + purpose: ParsePurpose, + input: ParseInput, + ): ItemParseOutbox = + ItemParseOutbox( + slotType = slot.type, + slotId = slot.id, + purpose = purpose, + inputType = input.type, + inputValue = input.value, + normalizedInput = NormalizedInput.of(input), + ) + } } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSlot.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSlot.kt new file mode 100644 index 000000000..ade348968 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSlot.kt @@ -0,0 +1,16 @@ +package com.depromeet.piki.item.domain + +data class ItemSlot( + val type: ItemSlotType, + val id: Long, +) { + init { + require(id > 0) { "슬롯 id 는 양수여야 한다: $id" } + } + + companion object { + fun wish(wishId: Long): ItemSlot = ItemSlot(ItemSlotType.WISH, wishId) + + fun tournamentItem(tournamentItemId: Long): ItemSlot = ItemSlot(ItemSlotType.TOURNAMENT_ITEM, tournamentItemId) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSlotType.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSlotType.kt new file mode 100644 index 000000000..40e64e7e6 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSlotType.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.item.domain + +enum class ItemSlotType( + val description: String, +) { + WISH("위시 등록·새로고침"), + TOURNAMENT_ITEM("토너먼트 아이템 등록"), +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshot.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshot.kt index 6f9b76d6a..3f3c0398d 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshot.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshot.kt @@ -1,129 +1,59 @@ package com.depromeet.piki.item.domain import com.depromeet.piki.common.domain.LongBaseEntity +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.product.service.ProductSnapshot import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Table -import java.time.LocalDateTime import java.util.UUID +// 파싱 성공·사용자 입력 때만 생기고 바뀌지 않는다 @Entity @Table(name = "item_snapshots") class ItemSnapshot( + @Column(nullable = false, updatable = false) val itemId: Long, - name: String? = null, - imageUrl: String? = null, - price: Int? = null, - currency: String? = null, - status: ItemStatus = ItemStatus.PENDING, - extractedAt: LocalDateTime? = null, - source: ItemSnapshotSource? = null, - createdBy: UUID? = null, -) : LongBaseEntity() { - @Column(columnDefinition = "BINARY(16)") - var createdBy: UUID? = createdBy - protected set - - @Enumerated(EnumType.STRING) - @Column(length = 16) - var source: ItemSnapshotSource? = source - protected set - @Column(length = 512) - var name: String? = name - protected set + @Column(length = NAME_MAX_LENGTH) + val name: String?, - @Column(length = 2048) - var imageUrl: String? = imageUrl - protected set + @Column(length = IMAGE_URL_MAX_LENGTH) + val imageUrl: String?, - var price: Int? = price - protected set + @Column + val price: Int?, - @Column(length = 8) - var currency: String? = currency - protected set + @Column(length = CURRENCY_MAX_LENGTH) + val currency: String?, - // TODO: #1176 2단계에서 삭제 예정. 진행 상태는 ItemParseOutboxStatus 가 정본이고 버전은 성공 시에만 생김 @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 16) - var status: ItemStatus = status - protected set + @Column(length = 16) + val method: ExtractionMethod?, - var extractedAt: LocalDateTime? = extractedAt - protected set + @Column(nullable = false) + val userInput: Boolean, - // JPA 가 빈 인스턴스로 이 블록을 먼저 실행하므로 필수값·상태 의존 검사는 둘 수 없다. - init { - validate(name, price, imageUrl, currency) - } + @Column(nullable = false, columnDefinition = "BINARY(16)") + val createdBy: UUID, - fun markExtracted(extracted: ProductSnapshot): ItemStatus { - check(isInProgress()) { "진행 중이 아닌 snapshot(status=$status)은 추출 결과로 전이할 수 없다" } - fillValues(extracted) - source = ItemSnapshotSource.fromWireMethod(extracted.extractionMethod) - status = extractedStatus() - if (hasValue()) extractedAt = LocalDateTime.now() - return status - } - - fun markFailed() { - check(isInProgress()) { "진행 중이 아닌 snapshot(status=$status)은 FAILED 로 전이할 수 없다" } - status = ItemStatus.FAILED +) : LongBaseEntity() { + init { + require(filledFieldCount(name, price, imageUrl) > 0) { "값이 하나도 없는 버전은 만들 수 없다" } + require(fitsColumns(name, imageUrl, currency)) { "칸 길이를 넘는 값이다" } + require((price ?: 0) >= 0) { "price 는 음수일 수 없다: $price" } } - fun isReady(): Boolean = status == ItemStatus.READY - - fun isIncomplete(): Boolean = status == ItemStatus.INCOMPLETE - - fun isFailed(): Boolean = status == ItemStatus.FAILED - - fun isInProgress(): Boolean = status == ItemStatus.PENDING || status == ItemStatus.PROCESSING - - fun hasValue(): Boolean = isReady() || isIncomplete() + fun isReady(): Boolean = filledFieldCount() == READY_FIELD_COUNT - fun isUnresolved(): Boolean = isFailed() || isIncomplete() - - fun isManual(): Boolean = source == ItemSnapshotSource.MANUAL + fun isIncomplete(): Boolean = !isReady() fun isOwnedBy(user: UUID): Boolean = createdBy == user - // 출처를 모르는 옛 행도 공유값이다. 만든 사람으로 가르면 추정 백필된 옛 값이 카드에서 사라진다. - fun isSharedValue(): Boolean = isReady() && !isManual() - - private fun extractedStatus(): ItemStatus = - when (extractedFields().size) { - 0 -> ItemStatus.FAILED - READY_FIELD_COUNT -> ItemStatus.READY - else -> ItemStatus.INCOMPLETE - } - - // 통화는 단독으로 카드 값이 되지 못해 세지 않는다. - private fun extractedFields(): List = listOfNotNull(name?.takeIf { it.isNotBlank() }, price, imageUrl) - - private fun fillValues(extracted: ProductSnapshot) { - validate(extracted.name, extracted.price, extracted.imageUrl, extracted.currency) - name = extracted.name - price = extracted.price - imageUrl = extracted.imageUrl - currency = extracted.currency - } - - private fun validate( - name: String?, - price: Int?, - imageUrl: String?, - currency: String?, - ) { - require((price ?: 0) >= 0) { "price 는 음수일 수 없다: $price" } - require((name?.length ?: 0) <= NAME_MAX_LENGTH) { "name 길이가 ${NAME_MAX_LENGTH}자를 초과했다" } - require((imageUrl?.length ?: 0) <= IMAGE_URL_MAX_LENGTH) { "imageUrl 길이가 ${IMAGE_URL_MAX_LENGTH}자를 초과했다" } - require((currency?.length ?: 0) <= CURRENCY_MAX_LENGTH) { "currency 길이가 ${CURRENCY_MAX_LENGTH}자를 초과했다" } - } + private fun filledFieldCount(): Int = filledFieldCount(name, price, imageUrl) companion object { const val NAME_MAX_LENGTH = 512 @@ -131,36 +61,57 @@ class ItemSnapshot( const val CURRENCY_MAX_LENGTH = 8 private const val READY_FIELD_COUNT = 3 - fun pending( - itemId: Long, - requestedBy: UUID, - ): ItemSnapshot = ItemSnapshot(itemId = itemId, createdBy = requestedBy) + // 추출 결과가 버전이 될 수 있는지. 외부 계약 케이스라 예외가 아니라 판정으로 걸러 호출부가 파싱 실패로 닫는다 + fun accepts(extracted: ProductSnapshot): Boolean = + filledFieldCount(extracted.name, extracted.price, extracted.imageUrl) > 0 && + fitsColumns(extracted.name, extracted.imageUrl, extracted.currency) - fun manual( - base: ItemSnapshot, + // 통화는 단독으로 카드 값이 되지 못해 세지 않는다 + private fun filledFieldCount( name: String?, price: Int?, imageUrl: String?, + ): Int = listOfNotNull(name?.takeIf { it.isNotBlank() }, price, imageUrl).size + + private fun fitsColumns( + name: String?, + imageUrl: String?, currency: String?, + ): Boolean = + (name?.length ?: 0) <= NAME_MAX_LENGTH && + (imageUrl?.length ?: 0) <= IMAGE_URL_MAX_LENGTH && + (currency?.length ?: 0) <= CURRENCY_MAX_LENGTH + + fun extracted( + itemId: Long, + extracted: ProductSnapshot, + createdBy: UUID, + ): ItemSnapshot = + ItemSnapshot( + itemId = itemId, + name = extracted.name, + imageUrl = extracted.imageUrl, + price = extracted.price, + currency = extracted.currency, + method = extracted.method, + userInput = false, + createdBy = createdBy, + ) + + fun userInput( + itemId: Long, + values: UserInputValues, createdBy: UUID, - ): ItemSnapshot { - val mergedName = name ?: base.name - val mergedPrice = price ?: base.price - val mergedImage = imageUrl ?: base.imageUrl - if (mergedName.isNullOrBlank()) throw ItemException.nameRequiredForReady() - mergedPrice ?: throw ItemException.priceRequiredForReady() - mergedImage ?: throw ItemException.imageRequiredForReady() - return ItemSnapshot( - itemId = base.itemId, - name = mergedName, - imageUrl = mergedImage, - price = mergedPrice, - currency = currency ?: base.currency, - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - source = ItemSnapshotSource.MANUAL, + ): ItemSnapshot = + ItemSnapshot( + itemId = itemId, + name = values.name, + imageUrl = values.imageUrl, + price = values.price, + currency = values.currency, + method = null, + userInput = true, createdBy = createdBy, ) - } } } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshotSource.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshotSource.kt deleted file mode 100644 index 9acbe3991..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshotSource.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.depromeet.piki.item.domain - -// 추출 버전의 출처(#825 결정 4). 카드·가격 추적은 항상 마지막 SERVER* READY 를 향하고, -// MANUAL 은 이력에 남되 기본 뷰에서 접힌다 — 그 구분의 근거. -// SERVER 와 SERVER_LLM 을 가르는 이유: LLM 경로는 같은 페이지를 같은 날 재추출해도 값이 달라질 수 있어 -// (비결정성 실측), 이후 "LLM 추출분은 가격 변동 알림 제외" 같은 정책이 이 구분 위에서 열린다. -// 기존 행(도입 전)은 출처 소급이 불가능해 컬럼이 null 이다 — enum 에 UNKNOWN 을 두지 않는 이유: -// "행 없음/null = 모름" 규약이면 충분하고, 쓰기 경로가 UNKNOWN 을 실수로 저장할 여지를 없앤다. -enum class ItemSnapshotSource { - // 구조화 파서(JSON-LD·OpenGraph 등) 추출 — 결정론적. - SERVER, - - // LLM(Gemini) 추출 — 비결정성이 있어 신뢰 정책이 다를 수 있다. - SERVER_LLM, - - // 사용자 수기 입력. created_by 에 편집자 userId 가 기록된다. - MANUAL, - ; - - companion object { - // extractor 응답의 추출 경로(wire 문자열)를 출처로 번역한다. 모르는 값·null 은 null(출처 미기록) — - // tolerant reader 라 extractor 가 새 경로 값을 추가해도 여기가 깨지지 않고, 기록만 비운다(계약 §2). - fun fromWireMethod(method: String?): ItemSnapshotSource? = - when (method) { - "STRUCTURED" -> SERVER - "LLM" -> SERVER_LLM - else -> null - } - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemStatus.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemStatus.kt index 2ca745c0a..e98993238 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemStatus.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemStatus.kt @@ -1,12 +1,11 @@ package com.depromeet.piki.item.domain +// 저장하지 않는다. 카드의 아이템·버전·최근 아웃박스에서 계산한다. enum class ItemStatus( val description: String, ) { - // TODO: PENDING·PROCESSING 은 #1176 2단계에서 삭제 예정. 진행 상태는 ItemParseOutboxStatus 가 정본 - PENDING("파싱 대기 또는 진행 중"), - PROCESSING("파싱 진행 중. 버전에는 더 이상 기록하지 않음"), - INCOMPLETE("이름·가격·이미지 중 일부만 추출됨. 사용자가 나머지를 채워야 출전 가능"), + PARSING("아이템이 아직 확정되지 않았고 파싱 중"), + FAILED("아이템이 확정되지 못한 채 파싱이 실패함"), + INCOMPLETE("이름·가격·이미지 중 일부만 있음. 사용자가 나머지를 채워야 출전 가능"), READY("이름·가격·이미지가 모두 있음"), - FAILED("값을 하나도 얻지 못했거나 추출이 실패함"), } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ItemVersions.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ItemVersions.kt index 1d3cc2639..c6a7279c9 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemVersions.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemVersions.kt @@ -2,64 +2,37 @@ package com.depromeet.piki.item.domain import java.util.UUID -// 한 상품(item)의 버전들로 "이 카드에 보일 버전" 을 고르는 규칙의 단일 지점(#1051). -// -// 입력은 카드 주인(viewer)과 카드가 기다리는 행(waitingOn)이다. 카드의 참조(위시 snapshotId·출전 pin)는 "보는 값" 이 -// 아니라 "내가 기다리는 작업" 의 표식이다 — 본인의 등록·새로고침·합류로만 움직이고 남이 건드리지 못한다. 그래서 -// 남이 새로고침해도 아무 카드의 참조도 안 바뀌고 모두의 화면이 바뀌며, 반대로 남의 진행 중·남의 수기·남의 미완은 -// 내 카드에 새어 들어오지 않는다(불변식). 값은 두 축뿐이다: 공유 값(서버 READY, 누가 시켰든)과 내 맥락 값(내 수기·내 미완). -// -// 규칙 (위에서부터 먼저 맞는 것): -// 1. 카드가 기다리는 행이 진행 중이면 그것 — 내가 시작했거나 합류한 등록·갱신의 UX 신호. 같은 사람의 다른 카드 -// (위시 새로고침 중인 사람의 토너먼트 카드)는 그 행을 기다리지 않으므로 흔들리지 않는다. -// 2. 값 후보 중 최신: 내 맥락의 값(READY·INCOMPLETE) vs 공유 값(서버 READY). 더 새로운 쪽. -// 내 수기값은 그보다 새로운 서버 READY 에만 진다. 내 INCOMPLETE 는 옛 READY 를 이겨 "일부만 빈 상태" 로 보인다. -// 새로고침이 FAILED 로 끝나면 후보가 늘지 않아 카드는 새로고침 전과 같다. 남의 수기는 후보가 아니다(정책 A). -// 3. 값이 없으면 카드가 기다리던 행의 결과(등록 합류의 INCOMPLETE·FAILED 포함) — 남이 뒤에 남긴 실패가 내 카드를 -// 지우지 않는다. 그마저 없으면 내 최신 사실, 마지막으로 상품의 최신 비수기 행. +// 한 상품의 버전들 중 카드에 보일 버전을 고른다(#1051) +// 보이는 값은 기계 READY(남이 요청한 것이어도 됨)와 내 값(내가 요청하거나 직접 넣은 버전) 둘뿐이다 class ItemVersions private constructor( private val versions: List, ) { - // 이 묶음의 상품. of() 가 한 상품의 버전만 받으므로 첫 행에서 읽는다. val itemId: Long get() = versions.first().itemId - fun displayFor( - viewer: UUID, - waitingOn: Long, - ): ItemSnapshot { - val waiting = versions.firstOrNull { it.getId() == waitingOn } - if (waiting?.isInProgress() == true) return waiting - val mine = versions.lastOrNull { it.isOwnedBy(viewer) && it.hasValue() } - val shared = versions.lastOrNull { it.isSharedValue() } - listOfNotNull(mine, shared).maxByOrNull { it.getId() }?.let { return it } - waiting?.let { return it } - return versions.lastOrNull { it.isOwnedBy(viewer) } - ?: versions.lastOrNull { !it.isManual() } - ?: versions.last() + // 내 값과 기계 READY 중 더 새로운 것. 내 입력은 그보다 새 기계 READY 에만 진다 + fun displayFor(viewer: UUID): ItemSnapshot { + val mine = versions.lastOrNull { it.isOwnedBy(viewer) } + val machineReady = versions.lastOrNull { !it.userInput && it.isReady() } + listOfNotNull(mine, machineReady).maxByOrNull { it.getId() }?.let { return it } + return versions.lastOrNull { !it.userInput } ?: versions.last() } - // 해소 통지(#1028) 판정 — 이 카드가 snapshotId 버전으로 "채워지는가". 그 버전을 뺀 버전들로 계산한 표시값이 미완성이었고, - // 넣으면 표시값이 그 버전이 되는 카드다. 카드 표시값과 같은 규칙(displayFor)을 쓰므로 알림과 화면이 어긋날 수 없다. - // 그 버전을 기다리는 카드는 완료·새로고침 완료 알림을 받으므로 여기서 false 다(두 알림은 배타적). + // 직전까지 미완이던 카드가 이 버전으로 채워지는가(#1028). 이 버전을 요청한 카드는 호출부가 뺀다 + // 뒤에 쌓인 버전을 빼고 판정한다. 성공이 연달아 와도 앞 버전의 알림이 뒤 버전에 가려지지 않게 한다 fun recovers( viewer: UUID, - waitingOn: Long, snapshotId: Long, ): Boolean { require(versions.any { it.getId() == snapshotId }) { "이 상품에 버전 $snapshotId 이 없다" } - // 그 버전 이후에 기다리기 시작한 카드는 그 버전에 멈춰 있던 적이 없다. 판정은 "그 버전이 생기던 순간" 의 상태로 한다 — - // 더 새 버전까지 넣고 빼는 집합 비교는 성공 버전이 둘 이상 쌓이면(비동기 디스패치·연속 새로고침) 앞 버전을 뒤 버전이 - // 가려 아무 알림도 안 나가게 만든다. 그래서 before/after 는 그 버전까지의 접두 상태다. - if (waitingOn >= snapshotId) return false val before = versions.filter { it.getId() < snapshotId } if (before.isEmpty()) return false - if (!ItemVersions(before).displayFor(viewer, waitingOn).isUnresolved()) return false + if (!ItemVersions(before).displayFor(viewer).isIncomplete()) return false val after = versions.filter { it.getId() <= snapshotId } - return ItemVersions(after).displayFor(viewer, waitingOn).getId() == snapshotId + return ItemVersions(after).displayFor(viewer).getId() == snapshotId } companion object { - // 버전은 id 오름차순으로 정렬해 둔다 — "최신" 판정은 전부 id 다(단조증가 PK, 별도 version 컬럼 없음). + // "최신" 판정은 전부 id 순서다 fun of(versions: Collection): ItemVersions { require(versions.isNotEmpty()) { "버전이 하나도 없는 상품은 카드가 될 수 없다" } require(versions.map { it.itemId }.distinct().size == 1) { "한 상품의 버전만 묶을 수 있다" } diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/NormalizedInput.kt b/src/main/kotlin/com/depromeet/piki/item/domain/NormalizedInput.kt new file mode 100644 index 000000000..24555e22b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/NormalizedInput.kt @@ -0,0 +1,87 @@ +package com.depromeet.piki.item.domain + +import com.depromeet.piki.product.domain.ProductLink + +// 입력을 규칙대로 다듬은 값. 링크는 추적 파라미터를 뗀 URL, 이미지는 키 그대로. 귀결점에 적용한 것이 아이템의 정체 값이 된다 +@JvmInline +value class NormalizedInput private constructor( + val value: String, +) { + override fun toString(): String = value + + // 귀결점의 정규화 값을 아이템의 정체로 삼을 때 + fun asCanonical(): CanonicalInput = CanonicalInput(value) + + companion object { + // 저장 상한을 넘으면 정규화 값이 없다. 절단은 다른 상품과 충돌할 수 있는 거짓 정체성이라 쓰지 않는다(#843) + fun of(input: ParseInput): NormalizedInput? { + val value = + when (input) { + is ParseInput.Link -> normalize(input.link) + is ParseInput.Image -> input.value + } + return value.takeIf { it.length <= ProductLink.MAX_LENGTH }?.let(::NormalizedInput) + } + + // 지워도 같은 상품이 열리는 것을 실제 요청으로 확인한 이름만 지운다. 모르는 이름은 남긴다 + private val TRACKING_PARAM_PREFIXES = listOf("utm_", APPSFLYER_PREFIX, "airbridge") + private val TRACKING_PARAM_NAMES = + setOf( + "fbclid", "gclid", "igshid", "yclid", + "source_caller", "is_retargeting", "onelink_id", "shortlink", + "event_uuid", "referrer_timestamp", "deeplink_url", "channel", + "reward_key", "categorylargecode", "categorymediumcode", "categorysmallcode", + ) + + // AppsFlyer 의 유입 경로 파라미터. 상품 번호를 pid 로 주는 몰이 있어, AppsFlyer 가 붙인 링크일 때만 지운다 + private const val APPSFLYER_PREFIX = "af_" + private const val APPSFLYER_MEDIA_SOURCE = "pid" + + // 지그재그는 같은 상품이 /p/{id} 와 /catalog/products/{id} 두 경로로 열린다 + private val ZIGZAG_SHORT_PATH = Regex("^/p/(\\d+)$") + + // fragment 는 서버로 가지 않아 상품을 바꾸지 못한다. 경로와 쿼리만 다시 조립하므로 저절로 빠진다 + private fun normalize(link: ProductLink): String { + val host = link.normalizedHost() + val path = normalizePath(host, link.value.rawPath.orEmpty()) + val kept = keptParams(link.value.rawQuery) + return buildString { + append("https://").append(host).append(path) + if (kept.isNotEmpty()) append('?').append(kept.joinToString("&")) + } + } + + private fun normalizePath( + host: String, + rawPath: String, + ): String { + // cafe24 계열은 같은 상품을 /153 과 /153/ 둘 다로 준다 + val stripped = rawPath.trimEnd('/').ifEmpty { "/" } + if (host == "zigzag.kr" || host.endsWith(".zigzag.kr")) { + val folded = + ZIGZAG_SHORT_PATH.matchEntire(stripped)?.let { "/catalog/products/${it.groupValues[1]}" } + return folded ?: stripped + } + return stripped + } + + // 파라미터 순서만 다른 URL 이 다른 정체성이 되지 않게 정렬한다 + private fun keptParams(rawQuery: String?): List { + rawQuery ?: return emptyList() + val params = rawQuery.split('&').filter { it.isNotBlank() } + val fromAppsFlyer = params.any { nameOf(it).startsWith(APPSFLYER_PREFIX) } + return params.filterNot { isTrackingParam(nameOf(it), fromAppsFlyer) }.sorted() + } + + private fun isTrackingParam( + name: String, + fromAppsFlyer: Boolean, + ): Boolean { + if (name in TRACKING_PARAM_NAMES) return true + if (TRACKING_PARAM_PREFIXES.any { name.startsWith(it) }) return true + return fromAppsFlyer && name == APPSFLYER_MEDIA_SOURCE + } + + private fun nameOf(segment: String): String = segment.substringBefore('=').lowercase() + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ParseInput.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ParseInput.kt new file mode 100644 index 000000000..0e78cd7bb --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ParseInput.kt @@ -0,0 +1,37 @@ +package com.depromeet.piki.item.domain + +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.product.domain.ParseInputType +import com.depromeet.piki.product.domain.ProductLink + +// 파싱 요청의 입력. 저장은 type 과 value 두 칸이고 코드 안에서는 링크·이미지 키 타입으로 다룬다 +sealed interface ParseInput { + val type: ParseInputType + val value: String + + data class Link( + val link: ProductLink, + ) : ParseInput { + override val type: ParseInputType get() = ParseInputType.LINK + override val value: String get() = link.toString() + } + + data class Image( + val key: ProductImageKey, + ) : ParseInput { + override val type: ParseInputType get() = ParseInputType.IMAGE + override val value: String get() = key.value + } + + companion object { + // 저장된 두 칸에서 되살린다 + fun of( + type: ParseInputType, + value: String, + ): ParseInput = + when (type) { + ParseInputType.LINK -> Link(ProductLink.parse(value)) + ParseInputType.IMAGE -> Image(ProductImageKey.parse(value)) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/ParsePurpose.kt b/src/main/kotlin/com/depromeet/piki/item/domain/ParsePurpose.kt new file mode 100644 index 000000000..894f32bd8 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ParsePurpose.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.item.domain + +enum class ParsePurpose( + val description: String, +) { + REGISTER("등록"), + REFRESH("새로고침"), +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/UserInput.kt b/src/main/kotlin/com/depromeet/piki/item/domain/UserInput.kt new file mode 100644 index 000000000..5fbf88017 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/UserInput.kt @@ -0,0 +1,31 @@ +package com.depromeet.piki.item.domain + +data class UserInput( + val name: String?, + val price: Int?, + val imageUrl: String?, + val currency: String?, +) { + fun isEmpty(): Boolean = + listOfNotNull(name?.takeIf { it.isNotBlank() }, price, imageUrl?.takeIf { it.isNotBlank() }, currency?.takeIf { it.isNotBlank() }) + .isEmpty() + + fun filledFrom(base: ItemSnapshot): UserInput = + UserInput( + name = name ?: base.name, + price = price ?: base.price, + imageUrl = imageUrl ?: base.imageUrl, + currency = currency ?: base.currency, + ) + + companion object { + // 올리기 전이라 이미지 URL 이 없어 자리만 채운다. 검증에만 쓰고 저장하지 않는다 + private const val PENDING_UPLOAD_IMAGE_URL = "https://validation.invalid/pre-upload.png" + + fun beforeImageUpload( + name: String?, + price: Int?, + currency: String?, + ): UserInput = UserInput(name = name, price = price, imageUrl = PENDING_UPLOAD_IMAGE_URL, currency = currency) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/domain/UserInputValues.kt b/src/main/kotlin/com/depromeet/piki/item/domain/UserInputValues.kt new file mode 100644 index 000000000..ff15f56fa --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/domain/UserInputValues.kt @@ -0,0 +1,22 @@ +package com.depromeet.piki.item.domain + +// 사용자 입력 버전은 READY 여야 해서 이름·가격·이미지가 다 있어야 한다 +data class UserInputValues( + val name: String, + val price: Int, + val imageUrl: String, + val currency: String?, +) { + companion object { + fun of(input: UserInput): UserInputValues { + val name = input.name + if (name.isNullOrBlank()) throw ItemException.nameRequiredForReady() + return UserInputValues( + name = name, + price = input.price ?: throw ItemException.priceRequiredForReady(), + imageUrl = input.imageUrl ?: throw ItemException.imageRequiredForReady(), + currency = input.currency, + ) + } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/event/ItemMerged.kt b/src/main/kotlin/com/depromeet/piki/item/event/ItemMerged.kt deleted file mode 100644 index a031a8a32..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/event/ItemMerged.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.depromeet.piki.item.event - -// 정체성 병합(#825) — 진(임시) item 이 이긴 item 으로 합쳐졌다는 도메인 사실. 병합 트랜잭션 안에서 발행되며, -// item 을 직접 참조하는 상위 도메인(위시의 item_id, #1051)이 BEFORE_COMMIT 리스너로 같은 트랜잭션에서 참조를 따라간다. -// item 도메인은 위시를 모르므로(단방향 의존) 사실만 발행하고 추종은 소비자가 진다. -data class ItemMerged( - val loserItemId: Long, - val winnerItemId: Long, -) diff --git a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingCompleted.kt b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingCompleted.kt index b7722b536..f9dac790a 100644 --- a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingCompleted.kt +++ b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingCompleted.kt @@ -1,12 +1,13 @@ package com.depromeet.piki.item.event import com.depromeet.piki.common.event.NotificationEvent +import com.depromeet.piki.item.domain.ItemSlot +import java.util.UUID -// 아이템 파싱 완료 — 도메인 사실(fact). 이 이벤트를 알림·통계·audit 등 어떤 소비자가 구독하든 -// item 도메인은 그 존재를 모른다 (EDD 단방향 결합 — 소비자가 도메인을 import 한다). data class ItemParsingCompleted( + val slot: ItemSlot, + val owner: UUID, val itemId: Long, - // 파싱 사실의 주체는 버전(snapshot)이다 — 한 item 에 여러 버전이 공존(갱신)하고 공유(#825)로 한 버전이 - // 여러 곳에 pin 될 수 있어, 소비자의 라우팅은 itemId 가 아니라 이 값으로 해야 카드·수신자가 정확히 짚인다(#576). val snapshotId: Long, + val refresh: Boolean, ) : NotificationEvent diff --git a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingFailed.kt b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingFailed.kt index 932b87f75..b7aa44622 100644 --- a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingFailed.kt +++ b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingFailed.kt @@ -1,10 +1,11 @@ package com.depromeet.piki.item.event import com.depromeet.piki.common.event.NotificationEvent +import com.depromeet.piki.item.domain.ItemSlot +import java.util.UUID -// 아이템 파싱 실패 — 도메인 사실. data class ItemParsingFailed( - val itemId: Long, - // ItemParsingCompleted 와 같은 이유(#576) — 라우팅은 버전 단위가 정확하다. - val snapshotId: Long, + val slot: ItemSlot, + val owner: UUID, + val refresh: Boolean, ) : NotificationEvent diff --git a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt index 7a8ba4b8f..42435e402 100644 --- a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt +++ b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt @@ -1,11 +1,14 @@ package com.depromeet.piki.item.event import com.depromeet.piki.common.event.NotificationEvent +import com.depromeet.piki.item.domain.ItemSlot +import java.util.UUID -// 아이템 파싱이 일부 필드만 채우고 끝났다 — 도메인 사실. 실패가 아니라 "사용자가 나머지를 채우면 완성되는 상태"라 -// 완료·실패와 별개 사실로 둔다(#944). 소비자(알림)가 "나머지는 직접 채워주세요" 로 유도하는 근거다. +// 실패가 아니라 사용자가 나머지를 채우면 완성되는 상태라 완료·실패와 별개 사실로 둔다(#944). data class ItemParsingIncomplete( + val slot: ItemSlot, + val owner: UUID, val itemId: Long, - // ItemParsingCompleted 와 같은 이유(#576) — 라우팅은 버전 단위가 정확하다. val snapshotId: Long, + val refresh: Boolean, ) : NotificationEvent diff --git a/src/main/kotlin/com/depromeet/piki/item/event/ItemSlotMerged.kt b/src/main/kotlin/com/depromeet/piki/item/event/ItemSlotMerged.kt new file mode 100644 index 000000000..65e9e9c1b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/event/ItemSlotMerged.kt @@ -0,0 +1,13 @@ +package com.depromeet.piki.item.event + +import com.depromeet.piki.common.event.NotificationEvent +import com.depromeet.piki.item.domain.ItemSlot +import java.util.UUID + +data class ItemSlotMerged( + val slot: ItemSlot, + val holder: ItemSlot, + val owner: UUID, + val holderOwner: UUID, + val itemId: Long, +) : NotificationEvent diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemJpaRepository.kt index d3a1a8b6e..779f4fc7d 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemJpaRepository.kt @@ -1,53 +1,32 @@ package com.depromeet.piki.item.repository +import com.depromeet.piki.item.domain.CanonicalInput import com.depromeet.piki.item.domain.Item import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock -import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param interface ItemJpaRepository : JpaRepository { - // 조건부 canonical claim — "canonical 이 아직 없을 때만" 확정한다. 같은 item 을 두 트랜잭션이 동시에 - // 확정하는 경합을 인메모리 검사(Item.claimCanonical) 대신 DB 문장이 직렬화한다: 이긴 쪽만 1행, - // 진 쪽은 0행을 받아 재조회로 판정한다. 다른 item 이 같은 hash 를 이미 소유한 경우는 unique - // (uq_items_canonical_hash) 위반 예외로 갈라진다 — 호출부(ItemIdentityRecorder)가 병합 후보로 관측한다. - // native 인 이유: @Modifying bulk update 는 JPA auditing 을 우회하므로 updated_at 을 직접 채운다. - @Modifying - @Query( - value = """ - UPDATE items - SET canonical_url = :url, canonical_hash = :hash, updated_at = NOW(6) - WHERE id = :id AND canonical_hash IS NULL AND deleted_at IS NULL - """, - nativeQuery = true, - ) - fun claimCanonicalIfAbsent( - @Param("id") id: Long, - @Param("url") url: String, - @Param("hash") hash: String, - ): Int - - fun findByCanonicalHashAndDeletedAtIsNull(canonicalHash: String): Item? - - fun findBySourceImageKeyInAndDeletedAtIsNull(keys: Collection): List - - // 공유 등록(#825)의 attach 판정(합류/재사용/새 작업)을 item 행 락으로 직렬화한다 — 동시 등록 두 건이 각자 - // 새 PENDING 을 만들어 같은 item 을 중복 파싱하는 낭비를 막는다(#826). + // 버전 생성과 토너먼트 시작을 직렬화한다 @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select i from Item i where i.id = :id and i.deletedAt is null") fun findByIdForUpdate( @Param("id") id: Long, ): Item? - // 병합(#825) — 빈 껍데기가 된 진(임시) item 을 soft delete 한다. native 라 updated_at 을 직접 갱신. - @Modifying - @Query( - value = "UPDATE items SET deleted_at = NOW(6), updated_at = NOW(6) WHERE id = :id AND deleted_at IS NULL", - nativeQuery = true, - ) - fun softDeleteById( - @Param("id") id: Long, - ): Int + fun findByCanonicalUrlAndDeletedAtIsNull(canonicalUrl: CanonicalInput): Item? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select i from Item i where i.id in :ids and i.deletedAt is null order by i.id") + fun findByIdsForUpdate( + @Param("ids") ids: Collection, + ): List + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select i from Item i where i.id in :ids order by i.id") + fun findByIdsForShare( + @Param("ids") ids: Collection, + ): List } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkJpaRepository.kt deleted file mode 100644 index 07dca66de..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkJpaRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.depromeet.piki.item.repository - -import com.depromeet.piki.item.domain.ItemLink -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param - -interface ItemLinkJpaRepository : JpaRepository { - // INSERT IGNORE(MySQL) — url_hash unique 충돌 시 예외 대신 0행으로 끝난다. 별칭 기록은 등록·파싱 완료 - // 트랜잭션 안에서 도는데, 중복이 예외로 터지면 그 트랜잭션(등록 자체)이 죽는다. 중복은 오류가 아니라 - // "이미 아는 링크 모양"이라는 정상 사실이므로 문장 수준에서 조용히 넘긴다. - // native 인 이유: JPA save 는 충돌 시 예외 경로뿐이고, auditing 컬럼(created_at·updated_at)은 직접 채운다. - @Modifying - @Query( - value = """ - INSERT IGNORE INTO item_links (url, url_hash, item_id, created_at, updated_at) - VALUES (:url, :urlHash, :itemId, NOW(6), NOW(6)) - """, - nativeQuery = true, - ) - fun insertIgnore( - @Param("url") url: String, - @Param("urlHash") urlHash: String, - @Param("itemId") itemId: Long, - ): Int - - fun findByUrlHashAndDeletedAtIsNull(urlHash: String): ItemLink? - - // 병합(#825) — 진 item 의 별칭을 이긴 item 으로 이관한다. UPDATE IGNORE: 같은 url_hash 가 이미 이긴 쪽에 - // 있으면(귀결점 별칭 등) 그 행은 이동하지 않고 남는다 — 잔여는 deleteAllByItemId 로 정리한다. - @Modifying - @Query( - value = "UPDATE IGNORE item_links SET item_id = :toItemId, updated_at = NOW(6) WHERE item_id = :fromItemId", - nativeQuery = true, - ) - fun reparentAll( - @Param("fromItemId") fromItemId: Long, - @Param("toItemId") toItemId: Long, - ): Int - - // 병합 잔여(UPDATE IGNORE 로 이동 못한 중복 별칭) 정리 — 진 item 소속 별칭은 병합 후 남으면 안 된다. - @Modifying - @Query(value = "DELETE FROM item_links WHERE item_id = :itemId", nativeQuery = true) - fun deleteAllByItemId( - @Param("itemId") itemId: Long, - ): Int - - fun findByItemIdAndDeletedAtIsNull(itemId: Long): List -} diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkRepository.kt deleted file mode 100644 index fae780f12..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkRepository.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.depromeet.piki.item.repository - -import com.depromeet.piki.item.domain.ItemLink - -interface ItemLinkRepository { - // 별칭 한 줄을 기록한다. 이미 같은 url_hash 가 있으면 아무것도 하지 않고 false — 동시 등록·재등록이 - // 예외 없이 "한쪽만 성공"으로 수렴하는 것이 별칭 테이블의 계약이라, 예외가 아니라 반환값으로 알린다. - fun recordIfAbsent( - url: String, - urlHash: String, - itemId: Long, - ): Boolean - - fun findByUrlHash(urlHash: String): ItemLink? - - fun findByItemId(itemId: Long): List - - // 병합(#825): 별칭 이관 + 잔여 정리. - fun reparentAll( - fromItemId: Long, - toItemId: Long, - ) -} diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkRepositoryImpl.kt deleted file mode 100644 index f24af1962..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemLinkRepositoryImpl.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.depromeet.piki.item.repository - -import com.depromeet.piki.item.domain.ItemLink -import org.springframework.stereotype.Repository - -@Repository -class ItemLinkRepositoryImpl( - private val itemLinkJpaRepository: ItemLinkJpaRepository, -) : ItemLinkRepository { - override fun recordIfAbsent( - url: String, - urlHash: String, - itemId: Long, - ): Boolean = itemLinkJpaRepository.insertIgnore(url, urlHash, itemId) == 1 - - override fun findByUrlHash(urlHash: String): ItemLink? = itemLinkJpaRepository.findByUrlHashAndDeletedAtIsNull(urlHash) - - override fun findByItemId(itemId: Long): List = itemLinkJpaRepository.findByItemIdAndDeletedAtIsNull(itemId) - - override fun reparentAll( - fromItemId: Long, - toItemId: Long, - ) { - itemLinkJpaRepository.reparentAll(fromItemId, toItemId) - itemLinkJpaRepository.deleteAllByItemId(fromItemId) - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxJpaRepository.kt index c585ca91d..f0c0f9225 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxJpaRepository.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.item.repository import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSlotType import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint import org.springframework.data.domain.Limit @@ -29,4 +30,14 @@ interface ItemParseOutboxJpaRepository : JpaRepository { fun findByIdForUpdate( @Param("id") id: Long, ): ItemParseOutbox? + + @Query( + "select o from ItemParseOutbox o where o.slotType = :type and o.slotId in :ids and o.deletedAt is null " + + "and o.id = (select max(o2.id) from ItemParseOutbox o2 where o2.slotType = o.slotType " + + "and o2.slotId = o.slotId and o2.deletedAt is null)", + ) + fun findLatestBySlots( + @Param("type") type: ItemSlotType, + @Param("ids") ids: Collection, + ): List } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepository.kt index dc67e987c..b1f94b006 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepository.kt @@ -1,6 +1,8 @@ package com.depromeet.piki.item.repository import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSlotType interface ItemParseOutboxRepository { fun save(outbox: ItemParseOutbox): ItemParseOutbox @@ -10,4 +12,11 @@ interface ItemParseOutboxRepository { fun findById(id: Long): ItemParseOutbox? fun findByIdForUpdate(id: Long): ItemParseOutbox? + + fun findLatestBySlots( + type: ItemSlotType, + ids: Collection, + ): List + + fun findLatestBySlot(slot: ItemSlot): ItemParseOutbox? } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepositoryImpl.kt index c55bf0edf..cc4cf2eec 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemParseOutboxRepositoryImpl.kt @@ -1,6 +1,8 @@ package com.depromeet.piki.item.repository import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSlotType import org.springframework.data.domain.Limit import org.springframework.stereotype.Repository @@ -16,4 +18,12 @@ class ItemParseOutboxRepositoryImpl( override fun findById(id: Long): ItemParseOutbox? = itemParseOutboxJpaRepository.findByIdAndDeletedAtIsNull(id) override fun findByIdForUpdate(id: Long): ItemParseOutbox? = itemParseOutboxJpaRepository.findByIdForUpdate(id) + + override fun findLatestBySlots( + type: ItemSlotType, + ids: Collection, + ): List = if (ids.isEmpty()) emptyList() else itemParseOutboxJpaRepository.findLatestBySlots(type, ids) + + override fun findLatestBySlot(slot: ItemSlot): ItemParseOutbox? = + findLatestBySlots(slot.type, listOf(slot.id)).firstOrNull() } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepository.kt index cb7802a67..27cfc8fad 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepository.kt @@ -1,31 +1,21 @@ package com.depromeet.piki.item.repository +import com.depromeet.piki.item.domain.CanonicalInput import com.depromeet.piki.item.domain.Item interface ItemRepository { fun save(item: Item): Item - fun saveAll(items: List): List - fun findByIds(ids: List): List - fun findById(id: Long): Item? - - // canonical 이 아직 없을 때만 확정한다(조건부 claim). true=이번 호출이 확정 / false=이미 확정돼 있음. - // 다른 item 이 같은 hash 를 소유한 경우는 unique 위반 예외로 드러난다 — 호출부가 병합 후보로 관측한다. - fun claimCanonicalIfAbsent( - id: Long, - canonicalUrl: String, - canonicalHash: String, - ): Boolean + fun findByIdsForShare(ids: Collection): List - fun findByCanonicalHash(canonicalHash: String): Item? + fun findById(id: Long): Item? - fun findBySourceImageKeys(keys: Collection): List + fun findByCanonical(canonical: CanonicalInput): Item? - // 공유 등록 attach 판정 직렬화용 행 락 조회(#826). fun findByIdForUpdate(id: Long): Item? - // 병합: 빈 임시 item soft delete. - fun softDeleteById(id: Long) + // id 순서로 잠가 여러 아이템을 잡는 경로끼리 교착하지 않게 한다 + fun findByIdsForUpdate(ids: Collection): List } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepositoryImpl.kt index 08a2c1411..5b411318a 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.item.repository +import com.depromeet.piki.item.domain.CanonicalInput import com.depromeet.piki.item.domain.Item import org.springframework.stereotype.Repository @@ -9,27 +10,17 @@ class ItemRepositoryImpl( ) : ItemRepository { override fun save(item: Item): Item = itemJpaRepository.save(item) - override fun saveAll(items: List): List = itemJpaRepository.saveAll(items) - override fun findByIds(ids: List): List = itemJpaRepository.findAllById(ids) - override fun findById(id: Long): Item? = itemJpaRepository.findById(id).orElse(null) - - override fun findBySourceImageKeys(keys: Collection): List = - if (keys.isEmpty()) emptyList() else itemJpaRepository.findBySourceImageKeyInAndDeletedAtIsNull(keys) + override fun findByIdsForShare(ids: Collection): List = + if (ids.isEmpty()) emptyList() else itemJpaRepository.findByIdsForShare(ids) - override fun claimCanonicalIfAbsent( - id: Long, - canonicalUrl: String, - canonicalHash: String, - ): Boolean = itemJpaRepository.claimCanonicalIfAbsent(id, canonicalUrl, canonicalHash) == 1 + override fun findById(id: Long): Item? = itemJpaRepository.findById(id).orElse(null) - override fun findByCanonicalHash(canonicalHash: String): Item? = - itemJpaRepository.findByCanonicalHashAndDeletedAtIsNull(canonicalHash) + override fun findByCanonical(canonical: CanonicalInput): Item? = itemJpaRepository.findByCanonicalUrlAndDeletedAtIsNull(canonical) override fun findByIdForUpdate(id: Long): Item? = itemJpaRepository.findByIdForUpdate(id) - override fun softDeleteById(id: Long) { - itemJpaRepository.softDeleteById(id) - } + override fun findByIdsForUpdate(ids: Collection): List = + if (ids.isEmpty()) emptyList() else itemJpaRepository.findByIdsForUpdate(ids) } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt index 7cd20f465..9e9575311 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotJpaRepository.kt @@ -1,20 +1,20 @@ package com.depromeet.piki.item.repository import com.depromeet.piki.item.domain.ItemSnapshot +import jakarta.persistence.LockModeType import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param +import java.time.LocalDateTime interface ItemSnapshotJpaRepository : JpaRepository { - fun findFirstByItemIdAndDeletedAtIsNullOrderByIdDesc(itemId: Long): ItemSnapshot? - @Query( "select s from ItemSnapshot s where s.itemId = :itemId " + - "and s.status = com.depromeet.piki.item.domain.ItemStatus.READY " + + "and s.name is not null and s.price is not null and s.imageUrl is not null " + "and s.deletedAt is null " + - "and s.source is not null " + + "and (s.method is not null or s.userInput = true) " + "order by s.id desc", ) fun findPriceHistoryByItemId( @@ -28,27 +28,14 @@ interface ItemSnapshotJpaRepository : JpaRepository { fun findByItemIdInAndDeletedAtIsNullOrderByIdAsc(itemIds: Collection): List - @Query( - "select s from ItemSnapshot s where s.itemId = :itemId and s.status in (com.depromeet.piki.item.domain.ItemStatus.PENDING, com.depromeet.piki.item.domain.ItemStatus.PROCESSING) and s.deletedAt is null order by s.id desc limit 1", - ) - fun findLatestInProgressByItemId( - @Param("itemId") itemId: Long, - ): ItemSnapshot? - - @Query( - "select s from ItemSnapshot s where s.itemId = :itemId and s.status = com.depromeet.piki.item.domain.ItemStatus.READY and s.source in (com.depromeet.piki.item.domain.ItemSnapshotSource.SERVER, com.depromeet.piki.item.domain.ItemSnapshotSource.SERVER_LLM) and s.deletedAt is null order by s.id desc limit 1", - ) - fun findLatestMachineReadyByItemId( - @Param("itemId") itemId: Long, - ): ItemSnapshot? + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select s from ItemSnapshot s where s.itemId in :itemIds and s.deletedAt is null order by s.id") + fun findByItemIdsForShare( + @Param("itemIds") itemIds: Collection, + ): List - @Modifying - @Query( - value = "UPDATE item_snapshots SET item_id = :toItemId, updated_at = NOW(6) WHERE item_id = :fromItemId", - nativeQuery = true, - ) - fun reparentAll( - @Param("fromItemId") fromItemId: Long, - @Param("toItemId") toItemId: Long, - ): Int + fun findByItemIdInAndCreatedAtLessThanEqualAndDeletedAtIsNullOrderByIdAsc( + itemIds: Collection, + at: LocalDateTime, + ): List } diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepository.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepository.kt index 440098c70..2287e414a 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepository.kt @@ -1,24 +1,22 @@ package com.depromeet.piki.item.repository import com.depromeet.piki.item.domain.ItemSnapshot +import java.time.LocalDateTime interface ItemSnapshotRepository { fun save(snapshot: ItemSnapshot): ItemSnapshot - fun findLatestByItemId(itemId: Long): ItemSnapshot? - - fun findLatestInProgressByItemId(itemId: Long): ItemSnapshot? - - fun findLatestMachineReadyByItemId(itemId: Long): ItemSnapshot? - fun findAllByItemIds(itemIds: Collection): List - fun reparentAll( - fromItemId: Long, - toItemId: Long, - ): Int + fun findAllByItemIdsForShare(itemIds: Collection): List + + // 시작한 토너먼트의 출전 값. 시작과 버전 생성이 같은 아이템 행 잠금을 거쳐 created_at 순서가 커밋 순서와 같다 + fun findAllByItemIdsAsOf( + itemIds: Collection, + at: LocalDateTime, + ): List - // 출처 미상 행은 서버 추출인지 수기인지 알 수 없어 가격 이력에서 뺀다. + // 출처 미상 행은 서버 추출인지 사용자 입력인지 알 수 없어 가격 이력에서 뺀다. fun findPriceHistoryByItemId( itemId: Long, limit: Int, diff --git a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt index a303ced25..c6b279f85 100644 --- a/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/item/repository/ItemSnapshotRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.depromeet.piki.item.repository import com.depromeet.piki.item.domain.ItemSnapshot import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Repository +import java.time.LocalDateTime @Repository class ItemSnapshotRepositoryImpl( @@ -10,24 +11,26 @@ class ItemSnapshotRepositoryImpl( ) : ItemSnapshotRepository { override fun save(snapshot: ItemSnapshot): ItemSnapshot = itemSnapshotJpaRepository.save(snapshot) - - override fun findLatestInProgressByItemId(itemId: Long): ItemSnapshot? = itemSnapshotJpaRepository.findLatestInProgressByItemId(itemId) - - override fun findLatestMachineReadyByItemId(itemId: Long): ItemSnapshot? = itemSnapshotJpaRepository.findLatestMachineReadyByItemId(itemId) - override fun findAllByItemIds(itemIds: Collection): List = itemIds .takeIf { it.isNotEmpty() } ?.let { itemSnapshotJpaRepository.findByItemIdInAndDeletedAtIsNullOrderByIdAsc(it) } .orEmpty() - override fun reparentAll( - fromItemId: Long, - toItemId: Long, - ): Int = itemSnapshotJpaRepository.reparentAll(fromItemId, toItemId) + override fun findAllByItemIdsForShare(itemIds: Collection): List = + itemIds + .takeIf { it.isNotEmpty() } + ?.let { itemSnapshotJpaRepository.findByItemIdsForShare(it) } + .orEmpty() - override fun findLatestByItemId(itemId: Long): ItemSnapshot? = - itemSnapshotJpaRepository.findFirstByItemIdAndDeletedAtIsNullOrderByIdDesc(itemId) + override fun findAllByItemIdsAsOf( + itemIds: Collection, + at: LocalDateTime, + ): List = + itemIds + .takeIf { it.isNotEmpty() } + ?.let { itemSnapshotJpaRepository.findByItemIdInAndCreatedAtLessThanEqualAndDeletedAtIsNullOrderByIdAsc(it, at) } + .orEmpty() override fun findPriceHistoryByItemId( itemId: Long, diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ExtractionSettlement.kt b/src/main/kotlin/com/depromeet/piki/item/service/ExtractionSettlement.kt new file mode 100644 index 000000000..3a212835a --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/ExtractionSettlement.kt @@ -0,0 +1,16 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.ItemStatus + +// 추출 결과를 아웃박스에 기록한 결과. 기록되지 않은 두 경우는 정상 흐름이라 예외가 아니다 +sealed interface ExtractionSettlement { + data class Applied( + val status: ItemStatus, + ) : ExtractionSettlement + + // 다른 실행(회수)이 이미 이 행을 끝냄 + data object Superseded : ExtractionSettlement + + // 탈퇴로 슬롯 행이 사라져 결과를 받을 곳이 없음 + data object Discarded : ExtractionSettlement +} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemCard.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemCard.kt new file mode 100644 index 000000000..21026093b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemCard.kt @@ -0,0 +1,54 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemException +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.UserInput +import com.depromeet.piki.item.domain.UserInputValues +import com.depromeet.piki.product.domain.ProductLink + +sealed interface ItemCard { + val status: ItemStatus + val display: ItemSnapshot? + val refreshing: Boolean + val sourceLink: ProductLink? + + val itemId: Long? get() = display?.itemId + + fun isReady(): Boolean = status == ItemStatus.READY + + fun isIncomplete(): Boolean = status == ItemStatus.INCOMPLETE + + fun isParsing(): Boolean = status == ItemStatus.PARSING || refreshing + + fun userInputValues(input: UserInput): UserInputValues { + // 빈 입력을 받으면 지금 보이는 값을 복제한 사용자 입력 버전이 쌓여 가격 이력과 출처 표시가 바뀐다 + if (input.isEmpty()) throw ItemException.emptyUserInput() + if (isParsing()) throw ItemException.parsingInProgress() + return when (this) { + is Unresolved -> UserInputValues.of(input) + is Resolved -> UserInputValues.of(input.filledFrom(display)) + } + } + + data class Unresolved( + private val latestRequest: ItemParseOutbox, + ) : ItemCard { + override val status: ItemStatus get() = if (latestRequest.isFailed()) ItemStatus.FAILED else ItemStatus.PARSING + override val display: ItemSnapshot? get() = null + override val refreshing: Boolean get() = false + override val sourceLink: ProductLink? get() = (latestRequest.input as? ParseInput.Link)?.link + } + + data class Resolved( + private val item: Item, + override val display: ItemSnapshot, + override val refreshing: Boolean, + ) : ItemCard { + override val status: ItemStatus get() = if (display.isReady()) ItemStatus.READY else ItemStatus.INCOMPLETE + override val sourceLink: ProductLink? get() = item.sourceLink() + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemCardService.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemCardService.kt new file mode 100644 index 000000000..d7766697f --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemCardService.kt @@ -0,0 +1,111 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.CardQuery +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemVersions +import com.depromeet.piki.item.repository.ItemParseOutboxRepository +import com.depromeet.piki.item.repository.ItemRepository +import com.depromeet.piki.item.repository.ItemSnapshotRepository +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class ItemCardService( + private val itemRepository: ItemRepository, + private val itemSnapshotRepository: ItemSnapshotRepository, + private val itemParseOutboxRepository: ItemParseOutboxRepository, +) { + fun cardOf(query: CardQuery): ItemCard = cardsOf(listOf(query)).getValue(query) + + fun cardsOf(queries: Collection): Map = + cardsOf(queries, itemRepository::findByIds, itemSnapshotRepository::findAllByItemIds) + + // 일반 읽기는 트랜잭션 첫 읽기 때 고정된 스냅샷이라 아이템 잠금을 기다리는 동안 커밋된 버전을 못 본다 + fun lockedCardsOf(queries: Collection): Map = + cardsOf(queries, itemRepository::findByIdsForShare, itemSnapshotRepository::findAllByItemIdsForShare) + + private fun cardsOf( + queries: Collection, + itemsOf: (List) -> List, + currentVersionsOf: (Collection) -> List, + ): Map { + if (queries.isEmpty()) return emptyMap() + val latestRequests = latestRequestsOf(queries.map { it.slot }) + val itemsById = itemsOf(queries.mapNotNull { it.itemId }.distinct()).associateBy { it.getId() } + val versionsByScope = versionsByScope(queries, currentVersionsOf) + return queries.associateWith { query -> cardFor(query, latestRequests, itemsById, versionsByScope) } + } + + fun versionsOf(itemId: Long): ItemVersions = ItemVersions.of(itemSnapshotRepository.findAllByItemIds(listOf(itemId))) + + private fun cardFor( + query: CardQuery, + latestRequests: Map, + itemsById: Map, + versionsByScope: Map, + ): ItemCard { + val itemId = query.itemId ?: return unresolvedCardOf(query.slot, latestRequests) + val item = itemsById[itemId] ?: error("item $itemId 가 없다") + val versions = versionsByScope[VersionScope(itemId, query.asOf)] ?: error("item $itemId 의 버전이 없다") + val display = versions.displayFor(query.owner) + // 시작한 토너먼트는 시작 뒤의 새로고침이 보이지 않는다 + query.asOf ?: return ItemCard.Resolved(item, display, isRefreshing(query.slot, latestRequests)) + return ItemCard.Resolved(item, display, refreshing = false) + } + + // 아이템이 없는 카드는 요청과 함께 생기므로 요청이 없을 수 없다 + private fun unresolvedCardOf( + slot: ItemSlot, + latestRequests: Map, + ): ItemCard { + val request = latestRequests[slot] ?: error("$slot 는 아이템도 요청도 없다") + return ItemCard.Unresolved(request) + } + + // 위시를 토너먼트에 담은 카드와 아웃박스 이전에 만든 카드는 자기 요청이 없다. 요청이 없으면 새로고침 중일 수도 없다 + private fun isRefreshing( + slot: ItemSlot, + latestRequests: Map, + ): Boolean = latestRequests[slot]?.isInProgress() ?: false + + private fun latestRequestsOf(slots: Collection): Map { + val idsByType = slots.groupBy({ it.type }, { it.id }) + val latestRequests = + idsByType.flatMap { (type, ids) -> + itemParseOutboxRepository.findLatestBySlots(type, ids.distinct()) + } + return latestRequests.associateBy { it.slot } + } + + // 시작한 토너먼트는 시작 시각까지의 버전만 읽는다. 그 뒤 버전은 메모리에도 오지 않는다 + private fun versionsByScope( + queries: Collection, + currentVersionsOf: (Collection) -> List, + ): Map = + queries + .mapNotNull { query -> query.itemId?.let { VersionScope(it, query.asOf) } } + .distinct() + .groupBy({ it.asOf }, { it.itemId }) + .flatMap { (asOf, itemIds) -> + snapshotsOf(itemIds, asOf, currentVersionsOf) + .groupBy { it.itemId } + .map { (itemId, versions) -> VersionScope(itemId, asOf) to ItemVersions.of(versions) } + }.toMap() + + private fun snapshotsOf( + itemIds: List, + asOf: LocalDateTime?, + currentVersionsOf: (Collection) -> List, + ): List { + asOf ?: return currentVersionsOf(itemIds) + return itemSnapshotRepository.findAllByItemIdsAsOf(itemIds, asOf) + } + + private data class VersionScope( + val itemId: Long, + val asOf: LocalDateTime?, + ) +} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemDisplayService.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemDisplayService.kt deleted file mode 100644 index b52dbf7bb..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemDisplayService.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.depromeet.piki.item.service - -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemVersions -import com.depromeet.piki.item.repository.ItemSnapshotRepository -import org.springframework.stereotype.Component -import java.util.UUID - -// 카드 표시값 파생(#857·#1051)의 조회 지점. 규칙은 도메인(ItemVersions.displayFor)이 갖고, 여기는 상품의 버전들을 -// 끌어와 카드별로 위임만 한다. 카드는 상품(itemId)·주인·기다리는 행으로 표현되며, 값의 우열은 행들의 출처와 -// 만든 사람으로 가른다. -// -// 파생을 타지 않는 곳: 시작된 토너먼트(플레이·히스토리) — start 순간 pin 이 표시 버전으로 박제(repin)되어 -// "겨룬 값 = 히스토리 값" 이 고정된다(TournamentService.start). -@Component -class ItemDisplayService( - private val itemSnapshotRepository: ItemSnapshotRepository, -) { - // 카드 묶음 → 카드별 표시 버전. 키가 카드 자신이라 호출부가 순서에 기대지 않고 자기 카드로 찾는다(없으면 getValue 가 - // 던진다). 목록 화면용 배치 — 상품별 버전 전체를 한 번에 끌어와 카드 수와 무관하게 추가 쿼리 1회이고, 같은 카드가 - // 겹쳐 와도(같은 pin 을 두 출전이 공유) 결과는 같아 무해하다. - fun resolveDisplay(cards: Collection): Map { - if (cards.isEmpty()) return emptyMap() - val versionsByItemId = - itemSnapshotRepository - .findAllByItemIds(cards.map { it.itemId }.distinct()) - .groupBy { it.itemId } - .mapValues { (_, versions) -> ItemVersions.of(versions) } - return cards.associateWith { card -> - // 카드가 있는 상품은 버전이 최소 하나(기다리는 행) 있다. 없으면 영속화 경로가 깨진 코드 버그다. - val versions = versionsByItemId[card.itemId] ?: error("item ${card.itemId} 의 버전이 없다") - versions.displayFor(viewer = card.owner, waitingOn = card.waitingOn) - } - } - - fun resolveDisplay(card: DisplayCard): ItemSnapshot = resolveDisplay(listOf(card)).getValue(card) - - // 한 버전이 속한 상품의 버전 묶음 — 표시값 외의 판정(해소 통지 recovers 등)도 같은 적재를 타게 한다. 상품은 이벤트가 - // 실어 온 itemId 가 아니라 그 버전 행에서 되짚는다: 정체성 병합이 파싱 완료와 겹치면 버전은 이미 이긴 상품으로 - // 옮겨져 있어, 이벤트의 itemId(진 상품)로 찾으면 그 버전이 없다. 버전 자체가 없으면 코드 버그다. - fun versionsContaining(snapshotId: Long): ItemVersions { - val snapshot = itemSnapshotRepository.findById(snapshotId) ?: error("snapshot $snapshotId 이 없다") - return ItemVersions.of(itemSnapshotRepository.findAllByItemIds(listOf(snapshot.itemId))) - } -} - -// 표시값을 물을 카드 하나 — 상품(itemId), 카드 주인(위시 주인 / 출전시킨 사람), 카드가 기다리는 행(위시 waitingSnapshotId / 출전 pin). -data class DisplayCard( - val itemId: Long, - val owner: UUID, - val waitingOn: Long, -) { - companion object { - // 출전처럼 기다리는 행(pin)만 들고 있는 카드 — 행에서 상품을 읽는다. - fun waitingOn( - pointer: ItemSnapshot, - owner: UUID, - ): DisplayCard = DisplayCard(itemId = pointer.itemId, owner = owner, waitingOn = pointer.getId()) - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityMetrics.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityMetrics.kt deleted file mode 100644 index 5b41d98ce..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityMetrics.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.depromeet.piki.item.service - -import io.micrometer.core.instrument.MeterRegistry - -// 상품 정체성 기록(#825)의 관측 카운터. 공유 활성화 전 관측 구간에서 "병합이 얼마나·어떤 모양으로 발생할지"를 -// 실데이터로 보는 것이 목적이다 — 특히 canonical_conflict(병합 후보)와 alias_duplicate(재등록 비율)가 -// 활성화 단계의 판단 재료다. 라벨 키는 result 하나로 고정한다(키 집합이 갈리면 Prometheus 가 뒤 시계열을 -// 조용히 드롭한다 — #465 사건, ItemParsingMetrics 와 같은 규칙). URL·host 는 카디널리티 무한이라 라벨 금지, 로그로 본다. -object ItemIdentityMetrics { - const val METRIC = "item.identity" - const val TAG_RESULT = "result" - - // 등록·파싱 완료 시 별칭이 새로 기록됨. - const val ALIAS_RECORDED = "alias_recorded" - - // 이미 아는 링크 모양(재등록) — 공유 활성화 시 즉시 매칭될 물량의 실측. - const val ALIAS_DUPLICATE = "alias_duplicate" - - // 이번 호출이 canonical 을 확정함. - const val CANONICAL_CLAIMED = "canonical_claimed" - - // 이미 같은 값으로 확정돼 있음(재파싱·갱신의 정상 경로). - const val CANONICAL_ALREADY_SAME = "canonical_already_same" - - // 이미 확정된 canonical 과 이번 귀결점이 다름 — 몰의 URL 구조 변경·단축링크 만료 등. 정체성은 불변이라 - // 첫 확정을 유지하고 관측만 한다. 늘어나면 정규화 규칙 재검토 신호. - const val CANONICAL_DRIFT = "canonical_drift" - - // 다른 item 이 같은 canonical 을 이미 소유했는데 병합까지 못 간 극단 경합(승자 조회 실패) — 다음 재파싱이 복구. - const val CANONICAL_CONFLICT = "canonical_conflict" - - // canonical 충돌을 병합(재부모화 + 별칭 이관 + 임시 item 폐기)으로 해소함 — 공유 활성화(#825 3단계)의 핵심 지표. - const val CANONICAL_MERGED = "canonical_merged" - - // finalUrl 없음(구버전 extractor·이미지 경로) — 확정 건너뜀. - const val CANONICAL_NO_FINAL_URL = "canonical_no_final_url" - - // 귀결점이 저장 상한(2048자) 초과 — 절단은 거짓 정체성이라 확정·별칭을 건너뜀. - const val CANONICAL_OVERSIZE = "canonical_oversize" - - // 귀결점이 URL 로 파싱 불가(비정상 리다이렉트 등) — 확정 건너뜀. - const val CANONICAL_UNPARSABLE = "canonical_unparsable" - - fun record( - registry: MeterRegistry, - result: String, - ) { - registry.counter(METRIC, TAG_RESULT, result).increment() - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt deleted file mode 100644 index 814a20da8..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.depromeet.piki.item.service - -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.event.ItemMerged -import com.depromeet.piki.item.repository.ItemLinkRepository -import com.depromeet.piki.item.repository.ItemRepository -import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.product.domain.CanonicalLink -import com.depromeet.piki.product.domain.ProductLink -import io.micrometer.core.instrument.MeterRegistry -import org.slf4j.LoggerFactory -import org.springframework.context.ApplicationEventPublisher -import org.springframework.dao.DataIntegrityViolationException -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate - -// 상품 정체성(#825)의 기록 계층 — 별칭(item_links)·canonical 확정·병합(재부모화)을 담당한다. -// 등록 쪽의 별칭 히트 재사용은 ItemSharingService 가, 파싱 완료 쪽의 확정·병합은 여기가 진다. -// -// 정규화 실패·중복·충돌 어느 것도 호출부(등록·파싱 완료)를 실패시키지 않는다 — 정체성 기록은 값 전달의 -// 부가 기능이라, 여기서의 문제로 등록·READY 전이가 죽으면 주객이 전도된다. -@Component -class ItemIdentityRecorder( - private val itemRepository: ItemRepository, - private val itemLinkRepository: ItemLinkRepository, - private val itemSnapshotRepository: ItemSnapshotRepository, - private val meterRegistry: MeterRegistry, - private val transactionTemplate: TransactionTemplate, - private val eventPublisher: ApplicationEventPublisher, -) { - private val log = LoggerFactory.getLogger(javaClass) - - // 등록 트랜잭션 안에서 원본 입력을 별칭으로 기록한다. REQUIRED 라 호출부(영속화 빈)의 트랜잭션에 합류한다 — - // 별칭은 등록과 원자적으로 남아야 pending 창의 재등록 매칭(활성화 단계)이 성립한다. - // INSERT IGNORE 라 중복이 예외 없이 0행으로 끝나 등록을 죽일 위험이 없다. - @Transactional - fun recordRegistrationAlias(item: Item) { - val link = item.link ?: return - val canonical = normalizeOrNull(link, item.getId()) ?: return - record(canonical, item.getId()) - } - - // 파싱 완료 후 귀결점(finalUrl)으로 canonical 을 확정하고 귀결점 별칭을 남긴다(링크 파서가 결과 반영 커밋 후 호출). - // - // claim 블록만 TransactionTemplate 로 격리한다. 이유: canonical unique 충돌(병합 후보)은 SQL 오류라 - // 그 트랜잭션 세션을 rollback-only 로 오염시킨다 — 메서드 전체가 한 트랜잭션이면 예외를 잡아도 커밋에서 - // UnexpectedRollbackException 이 터져 "관측했는데 실패 로그가 남는" 어긋남이 생긴다. 충돌 트랜잭션은 - // 버리고(잃을 write 없음), 충돌 판정·관측은 바깥에서 새로 읽어 남긴다. - // READY 전이와 분리된 별도 트랜잭션인 이유·병합 시 원자화 계획은 위와 같다: 전이를 오염시키지 않기 위해서고, - // 공유 활성화 단계에서 병합이 전이와 원자적이어야 할 때 이 흐름을 전이 트랜잭션 안으로 재설계한다(#825 3단계). - // 그 사이 "전이는 커밋됐는데 기록이 빠지는" 창은 다음 재파싱(갱신)이 자연 복구한다. - fun recordParsingIdentity( - itemId: Long, - finalUrl: String?, - ) { - finalUrl ?: run { - // 구버전 extractor(필드 없음)·이미지 경로. 배포 순서 무관 계약의 정상 경로라 조용히 센다. - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_NO_FINAL_URL) - return - } - val parsed = - runCatching { ProductLink.parseHop(finalUrl) }.getOrElse { - // 귀결점이 URL 계약(http·https·host)을 벗어남 — 대상 서버의 비정상 리다이렉트다. 정체성 미확정으로 둔다. - log.warn("canonical 확정 건너뜀 - 귀결점 파싱 불가 item={}", itemId) - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_UNPARSABLE) - return - } - val canonical = normalizeOrNull(parsed, itemId) ?: return - - try { - transactionTemplate.executeWithoutResult { claim(canonical, itemId) } - } catch (e: DataIntegrityViolationException) { - // uq_items_canonical_hash 위반 — 다른 item 이 같은 귀결점을 이미 소유했다: 이 item 은 같은 상품의 - // 임시 정체성이었던 것. 승자에게 병합한다(#825 활성화 — 재부모화 + 별칭 이관 + 임시 item 폐기). - val owner = itemRepository.findByCanonicalHash(canonical.hash) - owner ?: run { - // 충돌 직후 승자가 지워진 극단 경합 — 병합 불가. 관측만 남기면 다음 재파싱이 자연 복구한다. - log.warn("canonical 충돌인데 소유 item 조회 실패 - 관측만 남김 item={}", itemId) - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_CONFLICT) - return - } - merge(loserId = itemId, winnerId = owner.getId(), parsed = parsed) - } - } - - // 병합(#825 결정 3b) — snapshot 재부모화가 몸통이다: tournament_item 은 snapshot 만 참조하므로 부모 포인터가 - // 바뀌면 자동 추종하고, item 을 직접 참조하는 위시(#1051)는 ItemMerged 이벤트로 같은 트랜잭션에서 따라간다. - // 별칭도 승자에게 이관하고 빈 임시 item 은 soft delete. - // 이벤트(SSE·알림)는 snapshotId 라우팅(#576)이라 병합과 무관하게 정확하다. - // - // item 행 락을 선점하지 않는 이유: 병합 중 등록이 loser 에 끼워 넣은 새 PENDING 은 reparentAll 시점에 - // 미커밋이라 남을 수 있지만, 그 버전의 파싱이 완료되면 loser 의 canonical 충돌이 다시 이 병합 경로를 타서 - // 잔여 버전이 승자로 수렴한다(자연 복구). 드문 경합을 락으로 선차단하는 대신 수렴에 맡기는 결(3c TODO 와 동일)이고, - // 등록 쪽 관점의 보호는 ItemSharingService.resolveAttachment 의 병합 경합 재시도가 진다. - // - // TODO(#825 결정 3c): 병합으로 같은 사용자가 같은 상품의 위시를 두 장 갖게 될 수 있다(별칭 미스로 들어온 뒷문 - // 중복). 그 정리(중복 위시 제거)가 올바른 동작이나, 드문 경우를 위해 사용자 데이터를 자동 삭제하는 위험을 지지 - // 않기로 의도적으로 보류했다 — 추후 수정 가능성 있음. - private fun merge( - loserId: Long, - winnerId: Long, - parsed: ProductLink, - ) { - transactionTemplate.executeWithoutResult { - itemSnapshotRepository.reparentAll(fromItemId = loserId, toItemId = winnerId) - itemLinkRepository.reparentAll(fromItemId = loserId, toItemId = winnerId) - itemRepository.softDeleteById(loserId) - // item 을 직접 참조하는 상위 도메인(위시의 item_id, #1051)이 같은 트랜잭션(BEFORE_COMMIT)에서 참조를 따라간다. - eventPublisher.publishEvent(ItemMerged(loserItemId = loserId, winnerItemId = winnerId)) - } - log.info("canonical 병합 완료 loser={} winner={} url={}", loserId, winnerId, parsed.safeLogString()) - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_MERGED) - } - - private fun claim( - canonical: CanonicalLink, - itemId: Long, - ) { - if (itemRepository.claimCanonicalIfAbsent(itemId, canonical.url, canonical.hash)) { - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_CLAIMED) - record(canonical, itemId) - return - } - // 0행 = 이 item 은 이미 canonical 을 가짐(재파싱·갱신). 같은 값이면 정상, 다르면 드리프트 관측. - val current = itemRepository.findById(itemId) - val currentHash = current?.canonicalHash - if (currentHash == canonical.hash) { - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_ALREADY_SAME) - // 귀결점 별칭이 빠졌을 수 있는 과거 기록을 멱등 보수한다(INSERT IGNORE 라 중복 무해). - record(canonical, itemId) - return - } - // 정체성은 불변 — 첫 확정을 유지한다. 몰의 URL 구조 변경·단축링크 만료 등으로 귀결점이 흔들린 경우다. - log.warn( - "canonical 드리프트 관측 item={} 확정 hash 유지, 새 귀결점 무시 url={}", - itemId, - canonical.url.take(120), - ) - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_DRIFT) - } - - private fun record( - canonical: CanonicalLink, - itemId: Long, - ) { - val recorded = itemLinkRepository.recordIfAbsent(canonical.url, canonical.hash, itemId) - val result = if (recorded) ItemIdentityMetrics.ALIAS_RECORDED else ItemIdentityMetrics.ALIAS_DUPLICATE - ItemIdentityMetrics.record(meterRegistry, result) - } - - private fun normalizeOrNull( - link: ProductLink, - itemId: Long, - ): CanonicalLink? { - val canonical = CanonicalLink.of(link) - if (canonical.exceedsStorageLimit) { - // 절단은 다른 상품과 충돌할 수 있는 거짓 정체성이라 기록 자체를 건너뛴다(#843 리뷰 결정). - log.warn("정체성 기록 건너뜀 - 정규화 결과가 저장 상한 초과 item={} host={}", itemId, link.normalizedHost()) - ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_OVERSIZE) - return null - } - return canonical - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemParser.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemParser.kt index f57d9bbe8..65c74a063 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParser.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParser.kt @@ -1,8 +1,10 @@ package com.depromeet.piki.item.service import com.depromeet.piki.common.config.AsyncConfig +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.image.service.ImageSnapshotExtractor import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.LinkSnapshotExtractor import com.depromeet.piki.product.service.ProductSnapshot @@ -12,13 +14,11 @@ import io.micrometer.observation.ObservationRegistry import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component -import java.util.function.Supplier @Component class ItemParser( private val linkSnapshotExtractor: LinkSnapshotExtractor, private val imageSnapshotExtractor: ImageSnapshotExtractor, - private val itemIdentityRecorder: ItemIdentityRecorder, private val itemParsingService: ItemParsingService, private val meterRegistry: MeterRegistry, private val observationRegistry: ObservationRegistry, @@ -27,116 +27,109 @@ class ItemParser( @Async(AsyncConfig.ITEM_PARSING_EXECUTOR) fun parse(itemParseOutboxId: Long) { - val item = itemParsingService.itemToParse(itemParseOutboxId) ?: return failWithoutSource(itemParseOutboxId) - item.link?.let { parseLink(item.getId(), itemParseOutboxId, it) } - ?: item.sourceImageKey?.let { parseImage(item.getId(), itemParseOutboxId, it) } - ?: failWithoutSource(itemParseOutboxId) + val input = runCatchingException { itemParsingService.inputOf(itemParseOutboxId) }.getOrElse { return failWithoutSource(itemParseOutboxId, it) } + when (input) { + is ParseInput.Link -> parseLink(itemParseOutboxId, input.link) + is ParseInput.Image -> parseImage(itemParseOutboxId, input.key) + } } - private fun failWithoutSource(itemParseOutboxId: Long) { - log.error("item parse outbox {} 에 파싱할 입력이 없어 실패로 종결", itemParseOutboxId) + private fun failWithoutSource( + itemParseOutboxId: Long, + cause: Throwable, + ) { + log.error("item parse outbox {} 의 입력을 읽지 못해 실패로 종결", itemParseOutboxId, cause) if (markFailedQuietly(itemParseOutboxId)) { ItemParsingMetrics.record(meterRegistry, ItemParsingMetrics.RESULT_FAILED, ParseFailureReason.INTERNAL_ERROR.metricLabel) } } private fun parseLink( - itemId: Long, itemParseOutboxId: Long, link: ProductLink, ) { - extractAndSettle(itemId, itemParseOutboxId, "type=link url=${link.safeLogString()}") { linkSnapshotExtractor.extract(link) } - .extractedValue() - ?.let { recordIdentity(itemId, it.finalUrl) } + extractAndSettle(itemParseOutboxId, "type=link url=\"$link\"") { linkSnapshotExtractor.extract(link) } } private fun parseImage( - itemId: Long, itemParseOutboxId: Long, - imageKey: String, + key: ProductImageKey, ) { - extractAndSettle(itemId, itemParseOutboxId, "type=image key=$imageKey") { imageSnapshotExtractor.extract(imageKey) } + extractAndSettle(itemParseOutboxId, "type=image key=$key") { imageSnapshotExtractor.extract(key) } } private fun extractAndSettle( - itemId: Long, itemParseOutboxId: Long, logSubject: String, extract: () -> ProductSnapshot, - ): ParsingOutcome = - // TODO: 관측 구간은 @Observed(AOP)로 뺄 수 있음 + ) { Observation.createNotStarted(PARSE_OBSERVATION, observationRegistry).observe( - Supplier { + Runnable { val started = System.nanoTime() runCatchingException(extract).fold( - onSuccess = { settle(itemId, itemParseOutboxId, logSubject, it, started) }, - onFailure = { failExtraction(itemId, itemParseOutboxId, logSubject, it, started) }, + onSuccess = { settle(itemParseOutboxId, logSubject, it, started) }, + onFailure = { failExtraction(itemParseOutboxId, logSubject, it, started) }, ) }, ) + } private fun settle( - itemId: Long, itemParseOutboxId: Long, logSubject: String, extracted: ProductSnapshot, started: Long, - ): ParsingOutcome = - runCatchingException { itemParsingService.markExtracted(itemParseOutboxId, extracted) }.fold( - onSuccess = { status -> status?.let { recordSettled(itemId, logSubject, extracted, it, started) } ?: ParsingOutcome.NotApplied }, + ) { + runCatchingException { UniqueConflictRetry.once("outbox=$itemParseOutboxId") { itemParsingService.markExtracted(itemParseOutboxId, extracted) } }.fold( + onSuccess = { settlement -> if (settlement is ExtractionSettlement.Applied) recordSettled(itemParseOutboxId, logSubject, extracted, settlement.status, started) }, onFailure = { e -> markError(e) - if (e.isInvariantViolation()) rejectReady(itemId, itemParseOutboxId, logSubject, e, started) else leaveForRecovery(itemId, e) + if (e.isInvariantViolation()) rejectReady(itemParseOutboxId, logSubject, e, started) else leaveForRecovery(itemParseOutboxId, e) }, ) + } // 같은 값으로 다시 돌려도 또 막히므로 실패로 닫음 private fun rejectReady( - itemId: Long, itemParseOutboxId: Long, logSubject: String, cause: Throwable, started: Long, - ): ParsingOutcome { - log.warn("item.parse.error item={} reason={} READY 전이 거부", itemId, ParseFailureReason.READY_REJECTED.metricLabel, cause) - return fail(itemId, itemParseOutboxId, logSubject, ParseFailureReason.READY_REJECTED, started) + ) { + log.warn("item.parse.error outbox={} reason={} READY 전이 거부", itemParseOutboxId, ParseFailureReason.READY_REJECTED.metricLabel, cause) + fail(itemParseOutboxId, logSubject, ParseFailureReason.READY_REJECTED, started) } private fun leaveForRecovery( - itemId: Long, + itemParseOutboxId: Long, cause: Throwable, - ): ParsingOutcome { - log.error("item {} 추출 결과 기록 실패, 회수 대기", itemId, cause) - return ParsingOutcome.NotApplied + ) { + log.error("item parse outbox {} 추출 결과 기록 실패, 회수 대기", itemParseOutboxId, cause) } // require·check·error 가 던지는 예외. 일시 오류와 달리 재실행해도 결과가 같음 private fun Throwable.isInvariantViolation(): Boolean = this is IllegalArgumentException || this is IllegalStateException private fun recordSettled( - itemId: Long, + itemParseOutboxId: Long, logSubject: String, extracted: ProductSnapshot, status: ItemStatus, started: Long, - ): ParsingOutcome { + ) { val missing = extracted.takeIf { status == ItemStatus.INCOMPLETE }?.let { " missing=${ItemParsingMetrics.missingFieldsOf(it)}" }.orEmpty() - recordResult(itemId, resultOf(status), reasonOf(status), started, "$logSubject$missing") - return ParsingOutcome.Settled(extracted, status) + recordResult(itemParseOutboxId, resultOf(status), reasonOf(status), started, "$logSubject$missing") } private fun failExtraction( - itemId: Long, itemParseOutboxId: Long, logSubject: String, cause: Throwable, started: Long, - ): ParsingOutcome { + ) { markError(cause) val reason = ItemParsingMetrics.failureReasonOf(cause) - val outcome = fail(itemId, itemParseOutboxId, logSubject, reason, started) - if (outcome.isApplied()) log.info("item.parse.error item={} reason={} cause={}", itemId, reason.metricLabel, cause.message) - return outcome + if (fail(itemParseOutboxId, logSubject, reason, started)) log.info("item.parse.error outbox={} reason={} cause={}", itemParseOutboxId, reason.metricLabel, cause.message) } private fun markError(cause: Throwable) { @@ -144,15 +137,14 @@ class ItemParser( } private fun fail( - itemId: Long, itemParseOutboxId: Long, logSubject: String, reason: ParseFailureReason, started: Long, - ): ParsingOutcome { - if (!markFailedQuietly(itemParseOutboxId)) return ParsingOutcome.NotApplied - recordResult(itemId, ItemParsingMetrics.RESULT_FAILED, reason.metricLabel, started, logSubject) - return ParsingOutcome.Failed + ): Boolean { + if (!markFailedQuietly(itemParseOutboxId)) return false + recordResult(itemParseOutboxId, ItemParsingMetrics.RESULT_FAILED, reason.metricLabel, started, logSubject) + return true } private fun markFailedQuietly(itemParseOutboxId: Long): Boolean = @@ -161,14 +153,14 @@ class ItemParser( .getOrDefault(false) private fun recordResult( - itemId: Long, + itemParseOutboxId: Long, result: String, reason: String, started: Long, trailingFields: String, ) { val latencyMs = (System.nanoTime() - started) / 1_000_000 - log.info("item.parse.result item={} result={} reason={} latency={}ms {}", itemId, result, reason, latencyMs, trailingFields) + log.info("item.parse.result outbox={} result={} reason={} latency={}ms {}", itemParseOutboxId, result, reason, latencyMs, trailingFields) ItemParsingMetrics.record(meterRegistry, result, reason) } @@ -177,20 +169,12 @@ class ItemParser( ItemStatus.READY -> ItemParsingMetrics.RESULT_READY ItemStatus.INCOMPLETE -> ItemParsingMetrics.RESULT_INCOMPLETE ItemStatus.FAILED -> ItemParsingMetrics.RESULT_FAILED - ItemStatus.PENDING, ItemStatus.PROCESSING -> error("추출 전이가 만들 수 없는 상태 $status") + ItemStatus.PARSING -> error("추출 전이가 만들 수 없는 상태 $status") } private fun reasonOf(status: ItemStatus): String = if (status == ItemStatus.FAILED) ParseFailureReason.EXTRACT_QUALITY.metricLabel else ItemParsingMetrics.REASON_NONE - private fun recordIdentity( - itemId: Long, - finalUrl: String?, - ) { - runCatchingException { itemIdentityRecorder.recordParsingIdentity(itemId, finalUrl) } - .onFailure { e -> log.warn("item.identity.error item={} 정체성 기록 실패", itemId, e) } - } - companion object { private const val PARSE_OBSERVATION = "item.parse" } diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt index 1a2241ade..f27263321 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt @@ -19,7 +19,7 @@ object ItemParsingMetrics { const val REASON_NONE = "none" - // 값은 계약(extraction.proto)의 Bucket 이름을 소문자로 쓴 문자열과 같아야 한다. ExtractionErrorCatalogTest 가 대조한다. + // 값은 계약(shared-infra/contracts/extraction.proto)의 Bucket 이름을 소문자로 쓴 것. ExtractionErrorCatalogTest 가 대조한다 const val REASON_NOT_PRODUCT = "not_product" const val REASON_UNREADABLE = "unreadable" const val REASON_BLOCKED = "blocked" diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingService.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingService.kt index 3efe5ef07..46ea674c3 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingService.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingService.kt @@ -4,13 +4,16 @@ import com.depromeet.piki.item.domain.Item import com.depromeet.piki.item.domain.ItemParseOutbox import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput import com.depromeet.piki.item.event.ItemParsingCompleted import com.depromeet.piki.item.event.ItemParsingFailed import com.depromeet.piki.item.event.ItemParsingIncomplete import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductSnapshot +import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Isolation @@ -21,9 +24,13 @@ class ItemParsingService( private val itemRepository: ItemRepository, private val itemSnapshotRepository: ItemSnapshotRepository, private val itemParseOutboxRepository: ItemParseOutboxRepository, + private val itemResolver: ItemResolver, + private val itemSlots: ItemSlots, private val eventPublisher: ApplicationEventPublisher, ) { - // RR 이면 대기열이 limit 보다 적을 때 끝 갭이 잠겨 새 PENDING insert 막힘 + private val log = LoggerFactory.getLogger(javaClass) + + // RR 이면 새 PENDING insert 막힘 @Transactional(isolation = Isolation.READ_COMMITTED) fun claim(limit: Int): List = itemParseOutboxRepository.findPendingForUpdate(limit).map { outbox -> @@ -32,33 +39,43 @@ class ItemParsingService( } @Transactional(readOnly = true) - fun itemToParse(itemParseOutboxId: Long): Item? { - val outbox = itemParseOutboxRepository.findById(itemParseOutboxId) ?: return null - val snapshot = itemSnapshotRepository.findById(outbox.itemSnapshotId) ?: return null - return itemRepository.findById(snapshot.itemId) - } + fun inputOf(itemParseOutboxId: Long): ParseInput = + itemParseOutboxRepository.findById(itemParseOutboxId)?.input ?: error("item parse outbox $itemParseOutboxId 이 없다") - // 이미 끝난 행이면 null 을 돌려주고 아무것도 쓰지 않는다. @Transactional fun markExtracted( itemParseOutboxId: Long, extracted: ProductSnapshot, - ): ItemStatus? { - val outbox = findProcessingForUpdate(itemParseOutboxId) ?: return null - val target = snapshotOf(outbox) - val status = target.markExtracted(extracted) - if (status == ItemStatus.FAILED) outbox.fail() else outbox.succeed() - eventPublisher.publishEvent(parsingFact(status, target)) - return status + ): ExtractionSettlement { + val outbox = findProcessingForUpdate(itemParseOutboxId) ?: return ExtractionSettlement.Superseded + val slot = + when (val state = itemSlots.find(outbox.slot)) { + is ItemSlotState.Present -> state + ItemSlotState.Gone -> return discard(outbox) + } + if (!ItemSnapshot.accepts(extracted)) { + close(outbox, slot) + return ExtractionSettlement.Applied(ItemStatus.FAILED) + } + val item = itemOf(outbox, slot, extracted.finalUrl) + val version = itemSnapshotRepository.save(ItemSnapshot.extracted(item.getId(), extracted, slot.owner)) + val fill = itemSlots.fill(outbox.slot, item.getId()) + outbox.succeed() + when (fill) { + ItemSlotFill.Filled -> eventPublisher.publishEvent(parsingFact(outbox, slot, version)) + // 합침 사실은 ItemSlots 가 발행한다 + is ItemSlotFill.AlreadyHeld -> Unit + } + return ExtractionSettlement.Applied(if (version.isReady()) ItemStatus.READY else ItemStatus.INCOMPLETE) } - // 버전이 없거나 이미 끝났어도 아웃박스는 닫음. 남겨 두면 PROCESSING 으로 영영 멈춤 @Transactional fun markFailed(itemParseOutboxId: Long): Boolean { val outbox = findProcessingForUpdate(itemParseOutboxId) ?: return false - val failed = itemSnapshotRepository.findById(outbox.itemSnapshotId)?.takeIf { it.isInProgress() }?.apply { markFailed() } - outbox.fail() - failed?.let { eventPublisher.publishEvent(ItemParsingFailed(it.itemId, it.getId())) } + when (val state = itemSlots.find(outbox.slot)) { + is ItemSlotState.Present -> close(outbox, state) + ItemSlotState.Gone -> discard(outbox) + } return true } @@ -70,18 +87,62 @@ class ItemParsingService( private fun findProcessingForUpdate(itemParseOutboxId: Long): ItemParseOutbox? = itemParseOutboxRepository.findByIdForUpdate(itemParseOutboxId)?.takeIf { it.isProcessing() } - private fun snapshotOf(outbox: ItemParseOutbox): ItemSnapshot = - itemSnapshotRepository.findById(outbox.itemSnapshotId) - ?: error("item parse outbox ${outbox.getId()} 의 snapshot ${outbox.itemSnapshotId} 이 없다") + // 탈퇴로 슬롯 행이 하드 삭제되면 결과를 받을 곳이 없어 알림 없이 닫는다 + private fun discard(outbox: ItemParseOutbox): ExtractionSettlement.Discarded { + outbox.fail() + return ExtractionSettlement.Discarded + } + + // 아이템 행을 잠가 토너먼트 시작과 버전 생성을 직렬화한다 + private fun itemOf( + outbox: ItemParseOutbox, + slot: ItemSlotState.Present, + finalUrl: ProductLink?, + ): Item { + val resolvedId = slot.itemId ?: itemResolver.resolve(outbox.input, finalUrl).getId() + val item = itemRepository.findByIdForUpdate(resolvedId) ?: error("item $resolvedId 이 없다") + slot.itemId?.let { itemResolver.warnIfDrifted(item, finalUrl) } + return item + } + + private fun close( + outbox: ItemParseOutbox, + slot: ItemSlotState.Present, + ) { + outbox.fail() + if (outbox.isRefresh()) logRefreshFailure(outbox, slot) + eventPublisher.publishEvent( + ItemParsingFailed( + slot = outbox.slot, + owner = slot.owner, + refresh = outbox.isRefresh(), + ), + ) + } + + // 정체 URL 은 리다이렉트의 끝이라 앞선 홉이 만든 쿠키·상태 없이는 안 열리는 페이지일 수 있다. 대응 코드를 두기 전에 실제로 생기는지 본다 + private fun logRefreshFailure( + outbox: ItemParseOutbox, + slot: ItemSlotState.Present, + ) { + log.error( + "item.refresh.failed outbox={} item={} url=\"{}\" 정체 URL 로 다시 가져오지 못함", + outbox.getId(), + slot.itemId, + outbox.inputValue, + ) + } private fun parsingFact( - status: ItemStatus, - target: ItemSnapshot, - ): Any = - when (status) { - ItemStatus.READY -> ItemParsingCompleted(target.itemId, target.getId()) - ItemStatus.INCOMPLETE -> ItemParsingIncomplete(target.itemId, target.getId()) - ItemStatus.FAILED -> ItemParsingFailed(target.itemId, target.getId()) - ItemStatus.PENDING, ItemStatus.PROCESSING -> error("추출 전이가 만들 수 없는 상태 $status") + outbox: ItemParseOutbox, + slot: ItemSlotState.Present, + version: ItemSnapshot, + ): Any { + val refresh = outbox.isRefresh() + return if (version.isReady()) { + ItemParsingCompleted(outbox.slot, slot.owner, version.itemId, version.getId(), refresh) + } else { + ItemParsingIncomplete(outbox.slot, slot.owner, version.itemId, version.getId(), refresh) } + } } diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemResolver.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemResolver.kt new file mode 100644 index 000000000..1a6ccfeeb --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemResolver.kt @@ -0,0 +1,44 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.NormalizedInput +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.repository.ItemRepository +import com.depromeet.piki.product.domain.ProductLink +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +// 호출부 트랜잭션 안에서 돈다. 같은 새 값의 동시 확정은 UniqueConflictRetry 참조 +@Component +class ItemResolver( + private val itemRepository: ItemRepository, +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun resolve( + input: ParseInput, + finalUrl: ProductLink?, + ): Item { + val canonical = (normalizedFinalOf(finalUrl) ?: normalizedOf(input) ?: error("귀결점과 등록 입력이 모두 저장 상한을 넘는다")).asCanonical() + return itemRepository.findByCanonical(canonical) ?: itemRepository.save(Item.of(input.type, canonical)) + } + + // 정체성은 바꾸지 않는다. 몰의 URL 구조 변경·단축링크 만료가 늘어나는지만 본다 + fun warnIfDrifted( + item: Item, + finalUrl: ProductLink?, + ) { + val normalized = normalizedFinalOf(finalUrl) ?: return + if (item.canonicalUrl == normalized.asCanonical()) return + log.warn("canonical 드리프트 관측 item={} 확정값 유지 url=\"{}\"", item.getId(), normalized) + } + + private fun normalizedFinalOf(finalUrl: ProductLink?): NormalizedInput? = finalUrl?.let { normalizedOf(ParseInput.Link(it)) } + + // 서버가 다루지 않기로 한 길이다. 실제로 들어오면 상한을 다시 볼 근거라 에러로 남긴다 + private fun normalizedOf(input: ParseInput): NormalizedInput? = + NormalizedInput.of(input) ?: run { + log.error("item.normalized.oversize type={} value=\"{}\"", input.type, input.value) + null + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemSharingService.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemSharingService.kt deleted file mode 100644 index c8f65c7c2..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemSharingService.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.depromeet.piki.item.service - -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.repository.ItemLinkRepository -import com.depromeet.piki.item.repository.ItemRepository -import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.product.domain.CanonicalLink -import com.depromeet.piki.product.domain.ProductLink -import org.springframework.stereotype.Component -import java.time.LocalDateTime -import java.util.UUID - -// 공유 등록(#825 활성화)의 정책 계층 — 별칭으로 기존 상품을 알아보고(resolveExistingItem), 어느 버전에 붙을지 -// (resolveAttachment)를 정한다. 등록 영속화 빈(위시·토너먼트)이 자기 트랜잭션 안에서 호출한다. -@Component -class ItemSharingService( - private val itemRepository: ItemRepository, - private val itemLinkRepository: ItemLinkRepository, - private val itemSnapshotRepository: ItemSnapshotRepository, - private val parsingEnqueuer: ParsingEnqueuer, -) { - // 정규화된 입력이 이미 아는 링크 모양이면 그 item(공유 대상)을 돌려준다. 처음 보는 모양·저장 상한 초과는 null — - // 그 경우 호출부가 기존 신규 경로(새 item + 별칭 기록 + PENDING)로 간다. 별칭은 병합 시 승자에게 이관되므로 - // 여기서 찾은 item 은 살아 있는 정체성이다. - fun resolveExistingItem(link: ProductLink): Item? { - val canonical = CanonicalLink.of(link) - if (canonical.exceedsStorageLimit) return null - val alias = itemLinkRepository.findByUrlHash(canonical.hash) ?: return null - return itemRepository.findById(alias.itemId) - } - - // 공유 item 에서 붙을 버전을 정한다. item 행 락으로 동시 등록의 attach 판정을 직렬화한다(#826) — 락 없이는 - // 두 등록이 각자 "진행 중 없음"을 읽고 PENDING 을 두 개 만들어 같은 상품을 중복 파싱한다. - // - // 우선순위(#825 결정 3a, #853 개정): - // 1. 진행 중(PENDING/PROCESSING) 버전 → 합류. 모두 같은 파싱 결과를 기다린다. - // 2. 마지막 기계 READY → 신선도 무관 재사용(reused=true). 재사용 확정은 서버가 하지 않는다 — 낡은 - // 값(REFRESH_NEEDED_HOURS 초과)은 refreshNeeded 로 표시해 클라가 "새로 가져올까요?"를 묻고, - // 사용자가 원할 때 수동 새로고침으로 재추출한다. 등록이 자동으로 파싱을 만들면 위시 행 수에 비례해 - // 부하가 커져 위험하다(#853 — 자동 갱신은 사용자당 상한 설계와 함께 별도 검토). - // 수기(MANUAL)는 카드·추적이 믿지 않는 값이라 재사용 판정에서 제외. - // 3. 값이 아예 없음(첫 등록·FAILED 뿐) → 새 PENDING. 등록이 파싱을 만드는 유일한 경우다. - // - // 병합 경합 재시도: resolveExistingItem(비락)과 여기의 행 락 사이에 이 item 이 병합(merge)의 loser 로 - // soft delete 될 수 있다. 그 순간 별칭은 이미 승자 소속이므로, 원본 링크로 한 번 재해석해 승자에 붙는다 — - // 등록 요청이 밀리초 창의 경합으로 500 으로 죽지 않게 한다. 재해석 후에도 없으면 코드 버그(500). - // requestedBy 는 등록하는 사람 — 새 PENDING 을 만들게 되면 그 버전의 요청자로 기록된다(#1051). - fun resolveAttachment( - itemId: Long, - link: ProductLink, - requestedBy: UUID, - ): SharedAttachment { - attachOrNull(itemId, requestedBy)?.let { return it } - val winner = resolveExistingItem(link) ?: error("공유 대상 item $itemId 이 없다") - return attachOrNull(winner.getId(), requestedBy) ?: error("공유 대상 item ${winner.getId()} 이 없다") - } - - private fun attachOrNull( - itemId: Long, - requestedBy: UUID, - ): SharedAttachment? { - // 잠근 item 을 결과에 담아 전파한다 — 병합 재시도 경로에선 입력 itemId(loser)와 실제로 붙은 - // item(winner)이 다르므로, 호출부의 중복 검사·응답 구성은 반드시 이 item 을 기준으로 해야 한다. - val item = itemRepository.findByIdForUpdate(itemId) ?: return null - itemSnapshotRepository.findLatestInProgressByItemId(itemId)?.let { - // 진행 중 합류 — 곧 새 값이 오므로 "캐시 값 사용" 질문 대상이 아니다. - return SharedAttachment(item = item, snapshot = it, reused = false, refreshNeeded = false) - } - itemSnapshotRepository.findLatestMachineReadyByItemId(itemId)?.let { - return SharedAttachment(item = item, snapshot = it, reused = true, refreshNeeded = staleForRefresh(it)) - } - return SharedAttachment( - item = item, - snapshot = parsingEnqueuer.enqueue(itemId, requestedBy), - reused = false, - refreshNeeded = false, - ) - } - - private fun staleForRefresh(snapshot: ItemSnapshot): Boolean { - val extractedAt = snapshot.extractedAt ?: return true - return extractedAt.isBefore(LocalDateTime.now().minusHours(REFRESH_NEEDED_HOURS)) - } - - companion object { - // 재사용 값의 갱신 권고 임계 — 이보다 낡은 캐시 값은 refreshNeeded 로 표시해 클라가 "새로 가져올까요?"를 - // 묻게 한다(#853). 판정은 서버가 진다: 클라에 임계를 복제하면 이 값을 조정할 때 서버·클라가 어긋난다. - const val REFRESH_NEEDED_HOURS = 24L - } -} - -// attach 결과 — 실제로 붙은 정체성(item)·버전(snapshot)과 "어떻게 붙었는지"의 메타(#853). -// item 은 행 락으로 잠근 실체다: 병합 재시도 경로에선 입력 itemId 와 다를 수 있어(승자로 재해석), -// 호출부의 중복 검사·응답 구성은 이 item 을 정체성 기준으로 삼는다. -// reused = 파싱 없이 완성된 기존 값에 붙음(캐시), refreshNeeded = 그 캐시 값이 갱신 권고 임계보다 낡음(서버 판정). -// 등록 응답이 이 메타를 그대로 내려 클라가 "기존 값 사용/새로 가져오기" 선택 UI 를 그린다. -data class SharedAttachment( - val item: Item, - val snapshot: ItemSnapshot, - val reused: Boolean, - val refreshNeeded: Boolean, -) diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemSlotPort.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemSlotPort.kt new file mode 100644 index 000000000..425054dfa --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemSlotPort.kt @@ -0,0 +1,69 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSlotType +import com.depromeet.piki.item.event.ItemSlotMerged +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component +import java.util.UUID + +// 슬롯(위시·토너먼트 아이템)을 소유한 도메인이 구현한다. item 도메인은 그 도메인을 모른다 +interface ItemSlotPort { + val type: ItemSlotType + + // soft delete 된 슬롯도 Present 다. 슬롯이 지워져도 파싱 결과는 아이템에 남는다 + fun find(id: Long): ItemSlotState + + // 같은 상품임을 요청 시점에 알면 등록 경로가 409 로 막고, 아이템이 확정될 때에야 알면 여기서 남은 슬롯에 합친다 + // 지운 슬롯과 이미 아이템이 있는 슬롯은 합치지 않는다 + fun fill( + id: Long, + itemId: Long, + ): ItemSlotFill +} + +sealed interface ItemSlotState { + data class Present( + val owner: UUID, + val itemId: Long?, + ) : ItemSlotState + + // 탈퇴로 행이 하드 삭제됨 + data object Gone : ItemSlotState +} + +sealed interface ItemSlotFill { + data object Filled : ItemSlotFill + + data class AlreadyHeld( + val holder: ItemSlot, + val owner: UUID, + val holderOwner: UUID, + ) : ItemSlotFill +} + +@Component +class ItemSlots( + ports: List, + private val eventPublisher: ApplicationEventPublisher, +) { + private val portByType = ports.associateBy { it.type } + + fun find(slot: ItemSlot): ItemSlotState = portOf(slot).find(slot.id) + + // 파싱 성공과 사용자 입력 어느 쪽으로 합쳐져도 합침 사실이 빠지지 않게 여기서 발행한다 + fun fill( + slot: ItemSlot, + itemId: Long, + ): ItemSlotFill { + val fill = portOf(slot).fill(slot.id, itemId) + when (fill) { + ItemSlotFill.Filled -> Unit + is ItemSlotFill.AlreadyHeld -> eventPublisher.publishEvent(ItemSlotMerged(slot, fill.holder, fill.owner, fill.holderOwner, itemId)) + } + return fill + } + + private fun portOf(slot: ItemSlot): ItemSlotPort = + portByType[slot.type] ?: error("슬롯 타입 ${slot.type} 을 다루는 도메인이 없다") +} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ParsingEnqueuer.kt b/src/main/kotlin/com/depromeet/piki/item/service/ParsingEnqueuer.kt index 8aeccf864..5beb0638e 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ParsingEnqueuer.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ParsingEnqueuer.kt @@ -1,26 +1,22 @@ package com.depromeet.piki.item.service import com.depromeet.piki.item.domain.ItemParseOutbox -import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose import com.depromeet.piki.item.repository.ItemParseOutboxRepository -import com.depromeet.piki.item.repository.ItemSnapshotRepository import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional -import java.util.UUID @Component class ParsingEnqueuer( - private val itemSnapshotRepository: ItemSnapshotRepository, private val itemParseOutboxRepository: ItemParseOutboxRepository, ) { @Transactional(propagation = Propagation.MANDATORY) fun enqueue( - itemId: Long, - requestedBy: UUID, - ): ItemSnapshot { - val snapshot = itemSnapshotRepository.save(ItemSnapshot.pending(itemId, requestedBy)) - itemParseOutboxRepository.save(ItemParseOutbox(itemSnapshotId = snapshot.getId())) - return snapshot - } + slot: ItemSlot, + purpose: ParsePurpose, + input: ParseInput, + ): ItemParseOutbox = itemParseOutboxRepository.save(ItemParseOutbox.of(slot, purpose, input)) } diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ParsingOutcome.kt b/src/main/kotlin/com/depromeet/piki/item/service/ParsingOutcome.kt deleted file mode 100644 index 0b05adcff..000000000 --- a/src/main/kotlin/com/depromeet/piki/item/service/ParsingOutcome.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.depromeet.piki.item.service - -import com.depromeet.piki.item.domain.ItemStatus -import com.depromeet.piki.product.service.ProductSnapshot - -sealed interface ParsingOutcome { - fun isApplied(): Boolean - - fun extractedValue(): ProductSnapshot? - - data class Settled( - val extracted: ProductSnapshot, - val status: ItemStatus, - ) : ParsingOutcome { - override fun isApplied(): Boolean = true - - override fun extractedValue(): ProductSnapshot? = extracted.takeIf { status != ItemStatus.FAILED } - } - - data object Failed : ParsingOutcome { - override fun isApplied(): Boolean = true - - override fun extractedValue(): ProductSnapshot? = null - } - - // 이 실행의 결과가 DB 에 남지 않음(쓰기 실패, 또는 다른 실행이 이미 끝낸 행) - data object NotApplied : ParsingOutcome { - override fun isApplied(): Boolean = false - - override fun extractedValue(): ProductSnapshot? = null - } -} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/UniqueConflictRetry.kt b/src/main/kotlin/com/depromeet/piki/item/service/UniqueConflictRetry.kt new file mode 100644 index 000000000..bd890dd33 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/UniqueConflictRetry.kt @@ -0,0 +1,23 @@ +package com.depromeet.piki.item.service + +import org.hibernate.exception.ConstraintViolationException +import org.hibernate.exception.ConstraintViolationException.ConstraintKind +import org.slf4j.LoggerFactory +import org.springframework.dao.DataIntegrityViolationException + +// 새 트랜잭션의 스냅샷만 먼저 커밋된 확정을 본다. 그래서 attempt 는 트랜잭션 밖에서 불러 매번 새 트랜잭션을 열어야 한다 +object UniqueConflictRetry { + private val log = LoggerFactory.getLogger(javaClass) + + fun once( + subject: String, + attempt: () -> T, + ): T = + try { + attempt() + } catch (e: DataIntegrityViolationException) { + val violation = (e.cause as? ConstraintViolationException)?.takeIf { it.kind == ConstraintKind.UNIQUE } ?: throw e + log.info("item.confirm.retry {} key={} 먼저 확정된 행을 보고 다시 실행", subject, violation.constraintName) + attempt() + } +} diff --git a/src/main/kotlin/com/depromeet/piki/item/service/UserInputRecorder.kt b/src/main/kotlin/com/depromeet/piki/item/service/UserInputRecorder.kt new file mode 100644 index 000000000..f98c10852 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/service/UserInputRecorder.kt @@ -0,0 +1,40 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.UserInputValues +import com.depromeet.piki.item.repository.ItemParseOutboxRepository +import com.depromeet.piki.item.repository.ItemRepository +import com.depromeet.piki.item.repository.ItemSnapshotRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class UserInputRecorder( + private val itemRepository: ItemRepository, + private val itemSnapshotRepository: ItemSnapshotRepository, + private val itemParseOutboxRepository: ItemParseOutboxRepository, + private val itemResolver: ItemResolver, + private val itemSlots: ItemSlots, +) { + // 아이템 행을 잠가 토너먼트 시작과 버전 생성을 직렬화한다 + @Transactional(propagation = Propagation.MANDATORY) + fun record( + slot: ItemSlot, + itemId: Long?, + values: UserInputValues, + createdBy: UUID, + ): ItemSlotFill { + val resolvedId = itemId ?: requestedItemIdOf(slot) + val item = itemRepository.findByIdForUpdate(resolvedId) ?: error("item $resolvedId 가 없다") + itemSnapshotRepository.save(ItemSnapshot.userInput(item.getId(), values, createdBy)) + return itemSlots.fill(slot, item.getId()) + } + + private fun requestedItemIdOf(slot: ItemSlot): Long { + val latest = itemParseOutboxRepository.findLatestBySlot(slot) ?: error("슬롯 ${slot.type} ${slot.id} 의 요청이 없다") + return itemResolver.resolve(latest.input, finalUrl = null).getId() + } +} diff --git a/src/main/kotlin/com/depromeet/piki/metrics/dashboard/MetricsRepository.kt b/src/main/kotlin/com/depromeet/piki/metrics/dashboard/MetricsRepository.kt index 4375b261e..1aebad9fa 100644 --- a/src/main/kotlin/com/depromeet/piki/metrics/dashboard/MetricsRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/metrics/dashboard/MetricsRepository.kt @@ -86,7 +86,7 @@ class MetricsRepository( exclude: Boolean, ): Long = count("SELECT COUNT(*) FROM wishes WHERE created_at >= ? AND created_at < ?${notInternal(exclude, "user_id")}", ts(from), ts(to)) - // source_url IS NULL = 이미지 등록, NOT NULL = URL 등록. wish 는 item 을 직접 참조한다(#1051, wishes.item_id). + // 출처는 그 위시의 요청 입력으로 가른다. 확정된 아이템에 바로 붙은 위시는 요청이 없고 늘 URL 등록이다 fun countWishesBySource( from: LocalDateTime, to: LocalDateTime, @@ -95,9 +95,8 @@ class MetricsRepository( val byImage = keyCounts( """ - SELECT i.source_url IS NULL AS is_image, COUNT(*) + SELECT $IS_IMAGE_WISH AS is_image, COUNT(*) FROM wishes w - JOIN items i ON i.id = w.item_id WHERE w.created_at >= ? AND w.created_at < ?${notInternal(exclude, "w.user_id")} GROUP BY is_image """.trimIndent(), ts(from), @@ -114,24 +113,16 @@ class MetricsRepository( val byImage = keyCounts( """ - SELECT i.source_url IS NULL AS is_image, COUNT(*) + SELECT $IS_IMAGE_WISH AS is_image, COUNT(*) FROM wishes w - JOIN items i ON i.id = w.item_id WHERE w.deleted_at IS NULL${notInternal(exclude, "w.user_id")} GROUP BY is_image """.trimIndent(), ) return (byImage["0"] ?: 0L) to (byImage["1"] ?: 0L) } - // 파싱(item_snapshots READY/FAILED)을 출처별로 나눈다 — 아이템이 위시로 참조되면 '위시 파싱'(등록+새로고침 전부), - // 아니면 '토너먼트 파싱'(토너먼트 전용 아이템). 위시·토너먼트가 별도 item 을 만들어 item 단위로 출처가 갈린다. - // 파싱은 user 차원이 없어 개발진 제외는 못 한다(전체 파싱과 동일한 인지된 한계) — 토글과 무관하게 개발진 파싱도 포함된다. - // - // 한계: 출처 분류는 스냅샷 생성 시점 고정이 아니라 조회 시점의 "이 아이템에 위시가 있나"로 판정한다. 현재는 위시·토너먼트가 - // 서로 다른 item 을 만들어(공유 경로 없음) 한 아이템이 두 출처를 오가지 않으므로 과거 구간 집계가 안정적이다. 다만 앞으로 - // 토너먼트 아이템을 위시로 자동 편입하는 등 두 흐름이 item 을 공유하게 되면, 위시가 생긴 순간 그 아이템의 과거 스냅샷이 - // 토너먼트→위시로 재분류돼 지난 구간 수치가 달라진다 — 그 시점엔 출처를 스냅샷 생성 시점에 영속화(컬럼)해 고정해야 한다. - // (스냅샷 활성-위시로만 좁히면 새로고침·비활성 버전이 빠져 '위시 파싱=위시 아이템 전체 추출' 의미가 깨지므로 쓰지 않는다.) + // 끝난 파싱을 요청 출처(위시·토너먼트)별로 센다. 성공은 일부만 채운 경우를 포함한다 + // 파싱은 user 차원이 없어 개발진 제외는 못 한다(전체 파싱과 동일한 인지된 한계) fun countParsingBySource( from: LocalDateTime, to: LocalDateTime, @@ -139,25 +130,24 @@ class MetricsRepository( val rows = jdbcTemplate.query( """ - SELECT - EXISTS(SELECT 1 FROM wishes w WHERE w.item_id = s.item_id) AS is_wish, - s.status, COUNT(*) - FROM item_snapshots s - WHERE s.created_at >= ? AND s.created_at < ? AND s.status IN ('READY','FAILED') - GROUP BY is_wish, s.status + SELECT o.slot_type = 'WISH' AS is_wish, o.status, COUNT(*) + FROM item_parse_outbox o + WHERE o.updated_at >= ? AND o.updated_at < ? AND o.status IN ('SUCCEEDED','FAILED') + GROUP BY is_wish, o.status """.trimIndent(), { rs, _ -> Triple(rs.getBoolean(1), rs.getString(2), rs.getLong(3)) }, ts(from), ts(to), ) + fun pick( isWish: Boolean, status: String, ): Long = rows.firstOrNull { it.first == isWish && it.second == status }?.third ?: 0L return ParsingBySource( - wishReady = pick(true, "READY"), + wishReady = pick(true, "SUCCEEDED"), wishFailed = pick(true, "FAILED"), - tournamentReady = pick(false, "READY"), + tournamentReady = pick(false, "SUCCEEDED"), tournamentFailed = pick(false, "FAILED"), ) } @@ -372,7 +362,7 @@ class MetricsRepository( ) // 파싱 평균 시도 횟수(추출 건강도). 확정 상태(READY/FAILED)만. 대상이 없으면 null. - // TODO: #1176 1단계부터 attempt_count 를 쓰지 않아 새 행은 0. 3단계 재시도에서 아웃박스 기준으로 다시 연결 + // TODO: #1176 1단계부터 attempt_count 를, 2단계부터 status 를 쓰지 않아 새 행은 집계에 안 잡힘. 3단계 재시도에서 아웃박스 기준으로 다시 연결 fun avgParsingAttempts( from: LocalDateTime, to: LocalDateTime, @@ -421,6 +411,10 @@ class MetricsRepository( private fun ts(value: LocalDateTime): Timestamp = Timestamp.valueOf(value) companion object { + private const val IS_IMAGE_WISH = + "EXISTS(SELECT 1 FROM item_parse_outbox o WHERE o.slot_type = 'WISH' AND o.slot_id = w.id " + + "AND o.input_type = 'IMAGE')" + // 개발진 user_id 명단(developers 테이블). "개발진 포함" 토글이 꺼져 있을 때(기본) 이 user_id 들을 집계에서 뺀다. // 명단 추가는 이메일로 1회 해석해 넣는다: INSERT INTO developers (user_id) SELECT user_id FROM user_details WHERE email = '...'. private const val DEVELOPER_IDS = "SELECT user_id FROM developers" diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt index e19f4605f..18261c0cc 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt @@ -32,37 +32,42 @@ interface NotificationHistoryApi { "- 각 항목 셰입은 SSE `notification` 이벤트 payload 와 동일하다 — `type` 으로 화면을, `kind` 로 라벨·아이콘(위시/토너먼트/시스템)과 딥링크 출처를, " + "`refId` 로 이동 대상을 정하고, `id` 로 단건 읽음 처리(`POST /read`)를 한다. " + "다만 이동 대상이 `refId` 가 아닌 경우가 있다 — 아래 딥링크 좌표 설명을 함께 본다.\n\n" + - "**알림 타입 카탈로그 (전 10종)**\n\n" + + "**알림 타입 카탈로그 (전 15종)**\n\n" + "`type` 으로 화면을 분기하고 `refId` 로 이동 대상을 정한다. `kind` 는 **전 알림 공통 필드**로 항상 실리며 카드 라벨·아이콘(위시/토너먼트/시스템)이 된다. " + - "`body` 는 **`ITEM_PARSING_COMPLETED` 와 `ANNOUNCEMENT` 만 값이 있고 나머지 타입은 빈 문자열(`\"\"`)** 이다. " + - "전자는 `title` 에 아이템 이름을, `body` 에 상태 문구를 나눠 싣는다 — OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, " + - "이름과 상태를 한 줄에 담으면 이름이 길 때 상태가 사라지기 때문이다. 후자는 관리자가 입력한 공지 본문이 그대로 `body` 에 실린다. " + - "클라이언트는 `body` 가 비어 있을 수 있음을 전제로 그린다.\n\n" + + "기본 문구에서 `body` 가 빈 문자열(`\"\"`)인 타입은 **토너먼트 알림 7종과 `ITEM_PARSING_FAILED`** 다. " + + "이름을 싣는 파싱 알림은 `title` 에 아이템 이름을, `body` 에 상태 문구를 나눠 싣는다 — OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, " + + "이름과 상태를 한 줄에 담으면 이름이 길 때 상태가 사라지기 때문이다. `ANNOUNCEMENT` 는 관리자가 입력한 공지 본문이 그대로 `body` 에 실린다. " + + "문구는 백오피스에서 바뀔 수 있으므로 클라이언트는 `body` 가 비어 있을 수 있음을 전제로 그린다.\n\n" + "| `type` | 트리거 | `kind` | `refId` | `title` 예시 |\n" + "|---|---|---|---|---|\n" + "| `TOURNAMENT_JOINED` | 토너먼트 참가 | `TOURNAMENT` | tournamentId | {참가자}님이 참가했어요 |\n" + - "| `TOURNAMENT_ITEM_ADDED` | 아이템 추가 | `TOURNAMENT` | tournamentId | {참가자}님이 아이템을 추가했어요 |\n" + - "| `TOURNAMENT_ITEM_DELETED` | 아이템 삭제 | `TOURNAMENT` | tournamentId | {참가자}님이 '{상품명}'을(를) 삭제했어요 |\n" + + "| `TOURNAMENT_ITEM_ADDED` | 토너먼트 후보 추가 | `TOURNAMENT` | tournamentId | {참가자}님이 토너먼트 후보를 담았어요 |\n" + + "| `TOURNAMENT_ITEM_DELETED` | 토너먼트 후보 삭제 | `TOURNAMENT` | tournamentId | {참가자}님이 '{상품명}'을(를) 삭제했어요 |\n" + "| `TOURNAMENT_STARTED` | 토너먼트 시작 | `TOURNAMENT` | tournamentId | {주최자}님이 토너먼트를 시작했어요 |\n" + "| `TOURNAMENT_PLAYED_FROM_LINK` | 플레이링크로 플레이 시작 | `TOURNAMENT` | ROOT 토너먼트 id | {플레이어}님이 회원님 토너먼트를 플레이했어요 |\n" + - "| `TOURNAMENT_COMPLETED` | 멤버가 클론 완료 | `TOURNAMENT` | ROOT 토너먼트 id | {멤버}님이 회원님 토너먼트를 완료했어요 |\n" + - "| `TOURNAMENT_RESULT_READY` | 주최자가 ROOT 완료 | `TOURNAMENT` | ROOT 토너먼트 id | 참여하신 {주최자}님의 토너먼트 결과가 나왔어요 |\n" + - "| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 수신자별 `WISH`(+`wishId`) 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = 위시 \"위시 저장이 성공했어요\" / 토너먼트 \"아이템이 등록됐어요\") |\n" + - "| `ITEM_PARSING_FAILED` | 상품 추출 실패 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | 상품 정보를 가져오지 못했어요 |\n" + + "| `TOURNAMENT_COMPLETED` | 멤버·게스트가 플레이 완료 (주최자에게) | `TOURNAMENT` | ROOT 토너먼트 id | {참여자}님이 회원님 토너먼트를 완료했어요 |\n" + + "| `TOURNAMENT_RESULT_READY` | 주최자가 플레이 완료 (참여자에게) | `TOURNAMENT` | ROOT 토너먼트 id | 참여하신 {주최자}님의 토너먼트 결과가 나왔어요 |\n" + + "| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH`(+`wishId`) 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = 위시 \"위시 저장이 성공했어요\" / 토너먼트 \"토너먼트 후보가 등록됐어요\") |\n" + + "| `ITEM_PARSING_INCOMPLETE` | 상품 정보 일부만 추출 (등록·새로고침 공통) | 출처에 따라 `WISH`(+`wishId`) 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = \"일부 정보만 찾았어요. 나머지는 직접 채워주세요.\") |\n" + + "| `ITEM_PARSING_FAILED` | 상품 추출 실패 | 출처에 따라 `WISH`(+`wishId`) 또는 `TOURNAMENT` | 요청한 카드 id (wishId 또는 tournamentItemId) | 상품 정보를 가져오지 못했어요 |\n" + + "| `ITEM_PARSING_RECOVERED` | 미완으로 멈춘 내 카드가 같은 상품의 다른 추출 성공으로 채워짐 | 받는 사람 카드에 따라 `WISH`(+`wishId`) 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = \"상품 정보가 새로 확인돼 채워졌어요\"). 좌표는 받는 사람의 카드 |\n" + + "| `ITEM_PARSING_MERGED` | 같은 상품의 카드가 이미 있어 요청한 카드가 그쪽에 합쳐짐 | 출처에 따라 `WISH`(+`wishId`) 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = 위시 \"기존 위시에 합쳐졌어요\" / 토너먼트 \"기존 토너먼트 후보에 합쳐졌어요\"). 좌표는 남은 카드 |\n" + "| `ITEM_REFRESH_COMPLETED` | 위시 새로고침(재추출) 성공 | `WISH`(+`wishId`) | itemId | {아이템 이름} (+ `body` = \"최신 상품 정보로 새로고침했어요\") |\n" + - "| `ITEM_REFRESH_FAILED` | 위시 새로고침(재추출) 실패 | `WISH`(+`wishId`) | itemId | 새로고침에 실패했어요 (+ `body` = \"기존 상품 정보는 그대로 남아 있어요\") |\n" + + "| `ITEM_REFRESH_FAILED` | 위시 새로고침(재추출) 실패 | `WISH`(+`wishId`) | 요청한 위시 id (wishId) | 새로고침에 실패했어요 (+ `body` = \"기존 상품 정보는 그대로 남아 있어요\") |\n" + "| `ANNOUNCEMENT` | 관리자 공지(후속) | `SYSTEM` | 공지 id/0 | (관리자 입력) |\n\n" + - "> `ITEM_PARSING_COMPLETED` 의 `body` 는 **수신자별 등록 출처로 갈린다** — 위시에 담은 사람은 \"위시 저장이 성공했어요\", " + - "토너먼트에 올린 사람은 \"아이템이 등록됐어요\" 를 받는다. 한 상품을 두 사람이 각각 위시·토너먼트로 등록하면 같은 파싱 한 번에 " + - "수신자마다 다른 `body` 가 나간다(#933). 화면 분기는 `body` 문구가 아니라 `type`·`kind` 로 한다.\n\n" + + "> `ITEM_PARSING_COMPLETED` 의 `body` 는 **등록 출처로 갈린다** — 위시에 담은 사람은 \"위시 저장이 성공했어요\", " + + "토너먼트에 올린 사람은 \"토너먼트 후보가 등록됐어요\" 를 받는다. 화면 분기는 `body` 문구가 아니라 `type`·`kind` 로 한다.\n\n" + + "> **실패 알림(`ITEM_PARSING_FAILED`·`ITEM_REFRESH_FAILED`)의 `refId` 는 상품 id 가 아니라 요청한 카드 id 다.** " + + "파싱에 실패한 카드는 확정된 상품이 없을 수 있어서다. 이동은 다른 파싱 알림처럼 `wishId` 또는 토너먼트 좌표로 한다. " + + "이 계약 이전에 발송된 실패 알림에는 itemId 가 실려 있다.\n\n" + "> 파싱 알림(`ITEM_PARSING_*`)만 `kind` 가 발행 출처(위시 등록 / 토너먼트 추가)에 따라 갈린다 — 같은 `type` 이 두 플로우에서 발행되기 때문. " + - "나머지 타입은 위 표의 값 하나로 고정이다.\n\n" + + "`ITEM_PARSING_RECOVERED` 는 받는 사람 자신의 카드 출처를 따른다. 나머지 타입은 위 표의 값 하나로 고정이다.\n\n" + "> **등록과 새로고침은 `type` 으로 갈린다(#1036).** 위시 새로고침(`POST /api/v1/wishlists/{wishId}/refresh`)의 결과는 " + "`ITEM_PARSING_*` 이 아니라 `ITEM_REFRESH_COMPLETED` / `ITEM_REFRESH_FAILED` 로 온다. 새로고침은 위시에서만 일어나 `kind` 는 항상 `WISH` 고 " + - "`wishId` 가 실린다. 일부 필드만 채워진 결과는 갈리지 않는다 — 새로고침이든 등록이든 `ITEM_PARSING_INCOMPLETE` 로 온다(일부만 빈 상태로 수기를 기다린다). " + + "`wishId` 가 실린다. 일부 필드만 채워진 결과는 갈리지 않는다 — 새로고침이든 등록이든 `ITEM_PARSING_INCOMPLETE` 로 온다(일부만 빈 상태로 사용자 입력을 기다린다). " + "새로고침 실패 뒤에도 카드는 옛 성공본을 그대로 보이므로(표시값 파생) 클라가 값을 지우지 않는다.\n\n" + "> **딥링크 좌표는 `kind` 별로 다른 키를 쓴다.** `kind`=WISH 인 파싱 알림은 `wishId` 로 위시 상세(`GET /api/v1/wishlists/{wishId}`)로 간다 — " + - "`refId`(itemId)로는 그 화면에 갈 수 없으므로 `wishId` 가 있으면 그것을 쓴다. `wishId` 는 **수신자별로 다르고**, 컬럼 도입(#933) 이전에 발송된 " + + "`refId` 는 대개 itemId 라 그 화면에 갈 수 없으므로 `wishId` 가 있으면 그것을 쓴다. `wishId` 는 **수신자별로 다르고**, 컬럼 도입(#933) 이전에 발송된 " + "과거 알림에는 값이 없다. 값이 없을 때만 종전처럼 `refId` 로 역추적한다. " + "값 없음의 모양은 채널마다 다르다 — 이 API 와 SSE payload 는 `wishId` 키를 남기고 `null` 로 내리고, FCM data 는 그 키를 아예 생략한다. " + "클라는 **키 없음과 `null` 을 모두 폴백으로** 처리한다.\n\n" + diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationSseApi.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationSseApi.kt index 7613fe170..227cd1db8 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationSseApi.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationSseApi.kt @@ -27,7 +27,8 @@ interface NotificationSseApi { "| `notification` | 알림 1건마다 | `type` 으로 화면을, 전 알림 공통 필드 `kind`(`WISH`\\|`TOURNAMENT`\\|`SYSTEM`)로 " + "라벨·아이콘과 딥링크 출처를 분기. payload 셰입과 아이템 좌표(`tournamentId`·`tournamentItemId`)가 실리는 조건은 `notification-sse-spec.md` 참조 |\n" + "| `silent-sync` | 화면 갱신 사건마다 | 조용한 화면 갱신 신호(알림 아님). payload 의 `type` 으로 사건을 분기한다: " + - "`TOURNAMENT_ITEM_PARSED`(`{type, tournamentId, tournamentItemId, status}`, status=`READY`\\|`FAILED`) · " + + "`TOURNAMENT_ITEM_PARSED`(`{type, tournamentId, tournamentItemId, status}`, status=`READY`\\|`INCOMPLETE`\\|`FAILED`) · " + + "`TOURNAMENT_ITEM_MERGED`(`{type, tournamentId, tournamentItemId, mergedIntoTournamentItemId}`, 후보가 같은 상품의 다른 후보에 합쳐져 사라짐) · " + "`UNREAD_COUNT_CHANGED`(`{type, unreadCount}`, 읽음 후 멀티 디바이스 인앱 배지 동기화). 알림센터·푸시 없이 SSE 로만 흐른다. `notification-sse-spec.md` 참조 |\n" + "| `heartbeat` | 약 30초 간격 | 서버 ping. `data=`(connect 와 같은 번호). **60초 동안 안 오면 스트림이 죽은 것이므로 재연결**한다 |\n\n" + "- 토너먼트 알림은 해당 토너먼트 참여자에게만 fan-out 되므로, **자기 스트림 1개만 구독**하면 토너먼트·개인 알림이 모두 도착한다.\n" + diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationSsePayload.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationSsePayload.kt index e3820baf2..240050402 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationSsePayload.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationSsePayload.kt @@ -35,7 +35,7 @@ sealed interface NotificationSsePayload { override val createdAt: LocalDateTime, ) : NotificationSsePayload - // 위시 출처 파싱 알림. refId(=itemId) + kind=WISH + wishId(#933). 클라는 wishId 로 위시 상세 + // 위시 출처 파싱 알림. kind=WISH + wishId(#933). 클라는 wishId 로 위시 상세 // (GET /api/v1/wishlists/{wishId})로 딥링크한다. wishId 는 수신자별로 다르며, 컬럼 도입 전 과거 행은 null 이라 // 그 경우 클라가 refId(itemId)로 역추적하는 기존 폴백을 쓴다. data class WishParsing( @@ -51,7 +51,7 @@ sealed interface NotificationSsePayload { ) : NotificationSsePayload // 토너먼트 아이템을 지목하는 알림. 입장(tournamentId)·아이템 지목(tournamentItemId) 좌표를 싣는다. - // 두 용도가 공유한다: 파싱 알림(refId=itemId) · 아이템 삭제 알림(refId=tournamentId). refId 의미는 type 별로 갈리니 + // 두 용도가 공유한다: 파싱 알림 · 아이템 삭제 알림. refId 의미는 type 별로 갈리니(NotificationHistoryApi 표) // 클라는 type 으로 먼저 분기한다. (파싱 전용이 아니라 "토너먼트 아이템 라우팅" 셰입이라 이름이 중립적이다.) data class TournamentRouted( override val id: Long, diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/dto/SilentSyncPayload.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/SilentSyncPayload.kt index a3dbb368c..32df664be 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/dto/SilentSyncPayload.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/SilentSyncPayload.kt @@ -7,7 +7,7 @@ import com.depromeet.piki.item.domain.ItemStatus // 거치지 않고 열린 SSE 연결로만 흐른다(NotificationSsePayload 와 별개 경로). 클라는 type 으로 갱신 사건을 분기한다. // // notification 봉투가 sealed + type 으로 여러 알림 셰입을 묶듯, silent-sync 도 sealed + type 으로 여러 갱신 사건을 묶는다. -// 첫 사건은 토너먼트 출전 아이템 파싱 완료/실패(TournamentItemParsed). 갱신 사건이 늘면 SilentSyncType + 구현체를 더한다. +// 갱신 사건이 늘면 SilentSyncType + 구현체를 더한다. sealed interface SilentSyncPayload { val type: SilentSyncType } @@ -15,12 +15,13 @@ sealed interface SilentSyncPayload { // silent-sync 갱신 사건의 종류. 클라가 화면 갱신 동작을 분기하는 키. enum class SilentSyncType { TOURNAMENT_ITEM_PARSED, + TOURNAMENT_ITEM_MERGED, UNREAD_COUNT_CHANGED, } // 토너먼트 출전 아이템의 파싱이 끝났음을 그 토너먼트 참여자 화면에 반영하는 갱신 신호. // 클라는 (tournamentId, tournamentItemId)로 그 출전 카드를 찾아 status 로 갱신한다: -// PENDING/PROCESSING 로딩 → READY(상품 정보 표시) / FAILED(에러·재시도). status 는 항상 READY·FAILED 중 하나다. +// 로딩 중이던 카드를 READY(상품 정보 표시)·INCOMPLETE(나머지 입력 요청)·FAILED(에러) 로 바꾼다. status 는 이 셋 중 하나다 data class TournamentItemParsed( val tournamentId: Long, val tournamentItemId: Long, @@ -29,6 +30,15 @@ data class TournamentItemParsed( override val type: SilentSyncType = SilentSyncType.TOURNAMENT_ITEM_PARSED } +// 클라는 tournamentItemId 카드를 지우고 mergedIntoTournamentItemId 카드를 다시 읽는다 +data class TournamentItemMerged( + val tournamentId: Long, + val tournamentItemId: Long, + val mergedIntoTournamentItemId: Long, +) : SilentSyncPayload { + override val type: SilentSyncType = SilentSyncType.TOURNAMENT_ITEM_MERGED +} + // 읽음 처리 후 갱신된 안읽음 수를 그 유저의 온라인(SSE 연결) 기기 인앱 배지에 즉시 반영하는 갱신 신호(#487 의 SSE 보강). // FCM silent push 가 오프라인 기기의 OS 아이콘 badge 를 맞추는 것과 짝 — 온라인 인앱 배지는 이 SSE 로 맞춘다(FCM-only 면 // 앱을 열어둔 기기의 인앱 숫자가 갱신되지 않는다). REST 읽음 응답(NotificationReadResponse)과 같은 셰입이라 클라가 diff --git a/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationKind.kt b/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationKind.kt index 8616b9b10..4324f8967 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationKind.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationKind.kt @@ -36,6 +36,7 @@ enum class NotificationKind { NotificationType.ITEM_PARSING_INCOMPLETE, NotificationType.ITEM_PARSING_FAILED, NotificationType.ITEM_PARSING_RECOVERED, + NotificationType.ITEM_PARSING_MERGED, // 새로고침 알림(#1036)은 위시에서만 발행돼 실질 WISH 지만, 라우팅 컬럼이 가리키는 출처를 따르는 규칙을 // 같이 타서 kind 와 payload 셰입이 어긋날 여지를 없앤다. NotificationType.ITEM_REFRESH_COMPLETED, diff --git a/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationType.kt b/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationType.kt index 20f0a99b8..dfe3e03a0 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationType.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationType.kt @@ -17,12 +17,14 @@ enum class NotificationType { // 완료와 문구가 갈려야 해 타입을 따로 둔다 (템플릿은 타입당 하나). ITEM_PARSING_INCOMPLETE, ITEM_PARSING_FAILED, - // 실패·미완으로 멈춰 있던 사람의 카드가 **다른 사람의 성공 파싱**으로 채워진 사실 — 해소 통지다(#1028). - // 같은 파싱 완료 사실에서 갈라지지만 수신자가 배타적이라(그 버전을 기다린 사람 vs 다른 버전에 멈춰 있던 사람) + // 미완으로 멈춰 있던 사람의 카드가 **같은 상품의 다른 성공 파싱**으로 채워진 사실 — 해소 통지다(#1028). + // 같은 파싱 완료 사실에서 갈라지지만 대상 카드가 배타적이라(요청한 카드 vs 같은 상품의 다른 카드) // 문구가 달라야 해 타입을 따로 둔다. ITEM_PARSING_RECOVERED, + // 이동 대상이 남은 카드라 완료와 타입을 가른다 + ITEM_PARSING_MERGED, // 위시 **새로고침**(재추출)의 결과 — 등록 파싱과 같은 이벤트에서 갈라지지만 수신자가 배타적이라(#1036) - // (그 위시가 스냅샷보다 먼저 있었으면 새로고침, 아니면 등록) 문구가 달라야 해 타입을 따로 둔다. + // (파싱 요청의 목적이 REFRESH 면 새로고침, 아니면 등록) 문구가 달라야 해 타입을 따로 둔다. // 새로고침은 위시에서만 일어나므로 라우팅은 항상 WISH 다. ITEM_REFRESH_COMPLETED, // 새로고침 실패 — 카드는 표시값 파생(#858)으로 옛 성공본을 그대로 보이므로 "기존 정보는 남아 있다" 를 알린다. diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt index 9b880f7d5..bd51945a0 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemDisplayName.kt @@ -2,14 +2,14 @@ package com.depromeet.piki.notification.handler import java.text.BreakIterator -// 알림 문구에 실을 상품명을 표시용으로 다듬는다. 파싱 완료(#913)와 아이템 삭제 두 알림이 공유한다. +// 알림 문구에 실을 상품명을 표시용으로 다듬는다. 파싱 알림(#913)과 아이템 삭제 알림이 공유한다. // -// 표시 글자 절단은 **선택**이다 — 두 알림의 사정이 다르다. -// - 파싱 완료: title 이 이름뿐이라 OS 가 잘라도 의미가 안 사라진다. 절단하지 않는다(maxGraphemes = null). +// 표시 글자 절단은 **선택**이다 — 두 쪽의 사정이 다르다. +// - 파싱 알림: title 이 이름뿐이라 OS 가 잘라도 의미가 안 사라진다. 절단하지 않는다(maxGraphemes = null). // - 아이템 삭제: "OO님이 '{이름}'을(를) 삭제했어요" 처럼 이름 뒤에 문장이 붙어, 이름이 길면 정작 무슨 일인지가 // OS 절단에 먹힌다. 그래서 이쪽은 캡이 필요하다. object ItemDisplayName { - // 이름이 없거나 공백뿐일 때. 파싱 전(PENDING·PROCESSING)이나 추출 실패로 name 이 비는 경우가 실재한다. + // 이름이 없거나 공백뿐일 때. 이름 없이 가격·이미지만 추출된 버전이 실재한다 const val FALLBACK = "상품" // 표시 글자 수와 무관하게 거는 UTF-16 char 상한. grapheme cluster 1개가 조합 부호로 수십~수백 char 일 수 있어 diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt index 8a8152b8d..867db836a 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingCompletedHandler.kt @@ -2,25 +2,13 @@ package com.depromeet.piki.notification.handler import com.depromeet.piki.item.event.ItemParsingCompleted import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.notification.domain.NotificationKind import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component import java.util.UUID -// 아이템 파싱 완료 알림. 수신자는 snapshotId(버전)를 역조회해 위시 주인 ∪ 토너먼트 참가자로 모은다 -// (ItemParsingRecipientResolver, 실패·미완 알림과 공유). 단 그 버전으로 **새로고침한** 위시 주인은 뺀다 — 같은 사실이 -// ITEM_REFRESH_COMPLETED(#1036)로 따로 가고, 둘은 수신자가 배타적이다(ItemRefreshCompletedHandler). -// -// 문구는 title=아이템 이름 / body=상태 로 나뉜다(#913). OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한 -// 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. 이름만 제목에 두면 잘려도 잃는 게 없고, body 는 두 줄까지 -// 보이므로 상태 문구가 온전히 남는다. -// -// body 문구는 등록 출처(위시/토너먼트)로 갈린다(#933) — 위시 "위시 저장이 성공했어요" / 토너먼트 "아이템이 등록됐어요". -// 한 snapshot 에 위시 주인·토너먼트 등록자가 함께 붙을 수 있어(공유 #825 의 "진행 중 합류") 수신자마다 딥링크·문구가 -// 달라야 한다. dispatcher 가 수신자별 컨텍스트(resolveRecipientContexts)를 받아 각 수신자용으로 렌더한다. -// (#924 가 출처별 문구를 채택하려다 "수신자 루프 밖 1회 해석"이라 위시 주인이 토너먼트 문구를 받아 되돌렸던 것을, -// 수신자별 해석이 생겨 되살린다.) +// 등록 파싱 완료. 새로고침 완료는 ITEM_REFRESH_COMPLETED(#1036)로 따로 간다 +// title=이름 / body=상태로 나눈다(#913). OS 푸시 제목은 뒤가 잘려 한 줄에 담으면 상태가 사라진다 @Component class ItemParsingCompletedHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -28,42 +16,31 @@ class ItemParsingCompletedHandler( ) : NotificationEventHandler(NotificationType.ITEM_PARSING_COMPLETED) { override fun resolveRefId(event: ItemParsingCompleted): Long = event.itemId - override fun resolveRecipients(event: ItemParsingCompleted): Set = - recipientResolver.resolveRegistered(event.snapshotId) + override fun resolveRecipients(event: ItemParsingCompleted): Set = if (event.refresh) emptySet() else setOf(event.owner) - // title 변수 itemName 은 전 수신자 공유 — 같은 아이템이라 동일하다. (버전이 없거나 이름이 비면 기본값, best-effort) override fun resolveActorContext(event: ItemParsingCompleted): ActorContext = ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) - // 수신자별 라우팅 + 출처별 body 문구(#933). 위시 주인 → Wish(자기 wishId) + 위시 문구, - // 토너먼트 등록자 → 자기 Tournament 좌표 + 토너먼트 문구. body 템플릿은 ${completionMessage} 한 변수라 - // 이 값이 통째로 채운다. + // body 문구는 출처(위시/토너먼트)로 갈린다(#933) override fun resolveRecipientContexts( event: ItemParsingCompleted, recipients: Set, ): Map { - val routings = recipientResolver.resolveRoutingsBySnapshot(event.snapshotId) - return recipients.associateWith { userId -> - // 방어: 수신자인데 좌표를 못 찾으면(등록 직후 경합 등) 위시 폴백 — wishId 없이도 알림은 나간다. - val routing = routings[userId] ?: NotificationRouting.Wish(null) - RecipientContext( - routing = routing, - variables = mapOf("completionMessage" to completionMessageOf(routing)), - ) + val routing = recipientResolver.routingOf(event.slot) + return recipients.associateWith { + RecipientContext(routing = routing, variables = mapOf("completionMessage" to completionMessageOf(routing))) } } private fun completionMessageOf(routing: NotificationRouting): String = - when (routing.routingKind) { - NotificationKind.TOURNAMENT -> TOURNAMENT_COMPLETION_MESSAGE - else -> WISH_COMPLETION_MESSAGE + when (routing) { + is NotificationRouting.Wish -> WISH_COMPLETION_MESSAGE + is NotificationRouting.Tournament -> TOURNAMENT_COMPLETION_MESSAGE } companion object { - // body 템플릿(${completionMessage})에 채우는 출처별 완료 문구. 템플릿이 아니라 여기(코드)가 소유한다 — - // 출처별로 갈라야 하는데 템플릿 테이블은 타입당 한 행(PK=type)이라 두 문구를 담을 자리가 없어서다. - // 그래서 이 body 문구는 백오피스(#252) 편집 대상이 아니다(카탈로그 주석에도 남긴다). title 은 여전히 템플릿 소유. + // 출처별로 갈라야 하는데 템플릿 테이블은 타입당 한 행이라 코드가 소유한다. 백오피스(#252) 편집 대상이 아니다 const val WISH_COMPLETION_MESSAGE = "위시 저장이 성공했어요" - const val TOURNAMENT_COMPLETION_MESSAGE = "아이템이 등록됐어요" + const val TOURNAMENT_COMPLETION_MESSAGE = "토너먼트 후보가 등록됐어요" } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingFailedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingFailedHandler.kt index c2c3a4b9b..2e91506a1 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingFailedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingFailedHandler.kt @@ -1,44 +1,21 @@ package com.depromeet.piki.notification.handler import com.depromeet.piki.item.event.ItemParsingFailed -import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component import java.util.UUID -// 아이템 파싱 실패 알림. 수신자 규칙은 완료 알림과 동일 — snapshotId 역조회로 위시 주인 ∪ 토너먼트 참가자에서 -// 새로고침한 위시 주인을 뺀 집합(ITEM_REFRESH_FAILED #1036 로 따로 간다). -// (ItemParsingRecipientResolver 를 완료·미완 핸들러와 공유해 동일 로직 중복을 없앤다.) -// -// 라우팅은 수신자별로 갈린다(#933) — 각 수신자가 자기 위시(wishId)/자기 토너먼트 딥링크를 받는다. 다만 body 문구는 -// 완료와 달리 출처로 가르지 않는다 — 실패한 버전은 이름이 비어(추출 자체 실패) 출처별 문구를 만들 근거가 약하고, -// 단일 문구로 충분하다. 그래서 문구는 그대로 템플릿이 소유하고 여기선 라우팅만 수신자별로 채운다. -// -// title 변수 itemName 은 완료·미완 알림과 동일하게 채운다 — 현재 실패 템플릿은 고정 문구라 안 쓰지만, 어드민이 -// 실패 문구에도 이름을 넣어 편집할 수 있게 카탈로그 선언과 짝을 맞춘다. 추출 실패로 이름이 비면 ItemDisplayName -// 기본값("상품")이 메운다. +// 등록 파싱 실패. 새로고침 실패는 ITEM_REFRESH_FAILED(#1036)로 따로 간다. 실패하면 이름이 없어 기본 이름을 싣는다 @Component class ItemParsingFailedHandler( private val recipientResolver: ItemParsingRecipientResolver, - private val itemSnapshotRepository: ItemSnapshotRepository, ) : NotificationEventHandler(NotificationType.ITEM_PARSING_FAILED) { - override fun resolveRefId(event: ItemParsingFailed): Long = event.itemId + override fun resolveRefId(event: ItemParsingFailed): Long = event.slot.id - override fun resolveRecipients(event: ItemParsingFailed): Set = - recipientResolver.resolveRegistered(event.snapshotId) + override fun resolveRecipients(event: ItemParsingFailed): Set = if (event.refresh) emptySet() else setOf(event.owner) override fun resolveActorContext(event: ItemParsingFailed): ActorContext = - ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(null))) - override fun resolveRecipientContexts( - event: ItemParsingFailed, - recipients: Set, - ): Map { - val routings = recipientResolver.resolveRoutingsBySnapshot(event.snapshotId) - // 방어: 수신자인데 좌표를 못 찾으면 위시 폴백(wishId 없이도 알림은 나간다). - return recipients.associateWith { userId -> - RecipientContext(routing = routings[userId] ?: NotificationRouting.Wish(null)) - } - } + override fun resolveRouting(event: ItemParsingFailed) = recipientResolver.routingOf(event.slot) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt index cd9667e9a..a83c61ea8 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt @@ -2,19 +2,11 @@ package com.depromeet.piki.notification.handler import com.depromeet.piki.item.event.ItemParsingIncomplete import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component import java.util.UUID -// 아이템 파싱이 일부 필드만 채우고 끝났음을 알린다(#944). 수신자·라우팅 규칙은 완료·실패 알림과 동일하다 -// (ItemParsingRecipientResolver 공유 — 위시 주인 ∪ 토너먼트 참가자). -// -// 문구 변수는 완료 알림과 같이 itemName(제목) 하나이고 body 는 고정이다. 다만 이 알림은 이름조차 못 얻은 버전에도 -// 나갈 수 있다 — 가격·이미지만 건지고 이름을 놓친 경우다. 그때는 ItemDisplayName 의 기본값이 제목을 메운다. -// -// 라우팅은 수신자별로 갈린다(#933) — 각 수신자가 자기 위시(wishId)/자기 토너먼트 딥링크를 받는다. body 문구는 -// 출처로 가르지 않아(단일 문구) 라우팅만 수신자별로 채운다. +// 일부만 채운 파싱(#944). 등록·새로고침으로 갈리지 않는다 @Component class ItemParsingIncompleteHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -22,18 +14,10 @@ class ItemParsingIncompleteHandler( ) : NotificationEventHandler(NotificationType.ITEM_PARSING_INCOMPLETE) { override fun resolveRefId(event: ItemParsingIncomplete): Long = event.itemId - override fun resolveRecipients(event: ItemParsingIncomplete): Set = recipientResolver.resolve(event.snapshotId) + override fun resolveRecipients(event: ItemParsingIncomplete): Set = setOf(event.owner) override fun resolveActorContext(event: ItemParsingIncomplete): ActorContext = ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) - override fun resolveRecipientContexts( - event: ItemParsingIncomplete, - recipients: Set, - ): Map { - val routings = recipientResolver.resolveRoutingsBySnapshot(event.snapshotId) - return recipients.associateWith { userId -> - RecipientContext(routing = routings[userId] ?: NotificationRouting.Wish(null)) - } - } + override fun resolveRouting(event: ItemParsingIncomplete) = recipientResolver.routingOf(event.slot) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingMergedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingMergedHandler.kt new file mode 100644 index 000000000..cbe473cf1 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingMergedHandler.kt @@ -0,0 +1,45 @@ +package com.depromeet.piki.notification.handler + +import com.depromeet.piki.item.event.ItemSlotMerged +import com.depromeet.piki.item.service.ItemCardService +import com.depromeet.piki.notification.domain.NotificationRouting +import com.depromeet.piki.notification.domain.NotificationType +import org.springframework.stereotype.Component +import java.util.UUID + +// 사라진 카드로 가면 404 라 남은 카드 좌표로 보낸다 +@Component +class ItemParsingMergedHandler( + private val recipientResolver: ItemParsingRecipientResolver, + private val itemCardService: ItemCardService, +) : NotificationEventHandler(NotificationType.ITEM_PARSING_MERGED) { + override fun resolveRefId(event: ItemSlotMerged): Long = event.itemId + + override fun resolveRecipients(event: ItemSlotMerged): Set = setOf(event.owner) + + // 눌러서 가는 남은 카드와 같은 이름이 보이게 그 카드 주인의 맥락으로 고른다 + override fun resolveActorContext(event: ItemSlotMerged): ActorContext = + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemCardService.versionsOf(event.itemId).displayFor(event.holderOwner).name))) + + override fun resolveRecipientContexts( + event: ItemSlotMerged, + recipients: Set, + ): Map { + val routing = recipientResolver.routingOf(event.holder) + return recipients.associateWith { + RecipientContext(routing = routing, variables = mapOf("mergedMessage" to mergedMessageOf(routing))) + } + } + + private fun mergedMessageOf(routing: NotificationRouting): String = + when (routing) { + is NotificationRouting.Wish -> WISH_MERGED_MESSAGE + is NotificationRouting.Tournament -> TOURNAMENT_MERGED_MESSAGE + } + + companion object { + // 출처별로 갈라야 하는데 템플릿 테이블은 타입당 한 행이라 코드가 소유한다 + const val WISH_MERGED_MESSAGE = "기존 위시에 합쳐졌어요" + const val TOURNAMENT_MERGED_MESSAGE = "기존 토너먼트 후보에 합쳐졌어요" + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecipientResolver.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecipientResolver.kt index e9e26d196..42a63ca2d 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecipientResolver.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecipientResolver.kt @@ -1,96 +1,62 @@ package com.depromeet.piki.notification.handler -import com.depromeet.piki.item.service.ItemDisplayService +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSlotType +import com.depromeet.piki.item.service.ItemCardService import com.depromeet.piki.notification.domain.NotificationRouting +import com.depromeet.piki.tournament.repository.TournamentItemCardView import com.depromeet.piki.tournament.repository.TournamentItemRepository -import com.depromeet.piki.tournament.repository.TournamentItemUserRoutingView import com.depromeet.piki.wishlist.repository.WishRepository import org.springframework.stereotype.Component import java.util.UUID -// 아이템 파싱 완료·실패 알림의 수신자와 딥링크 라우팅 컨텍스트를 **snapshotId(버전)** 로 역조회한다(#576). -// 완료/실패 핸들러가 동일 규칙이라 공유한다. -// -// "파싱 완료/실패 알림" 은 그 버전의 결과를 기다리던 본인에게 간다 = 그 버전을 활성으로 가리키는 위시 주인 ∪ -// 그 버전을 pin 해 올린 adder. itemId 가 아니라 버전인 이유: 한 item 에 여러 버전이 공존(갱신)하고 공유(#825)로 -// 버전이 여러 곳에 pin 될 수 있어, item 단위 역조회는 "이 파싱과 무관한 옛 버전을 보는 사람"까지 끌어들인다 — -// 위시 갱신 파싱의 알림이 그 item 이 출전했던 토너먼트 딥링크로 새던 기존 라우팅 버그도 이 전환으로 함께 사라진다. -// 토너먼트의 다른 참가자는 추가 시점에 TOURNAMENT_ITEM_ADDED 로 이미 갱신하므로 파싱완료를 또 보내지 않는다(노이즈 방지). -// Set 이라 같은 유저는 1번만 받는다. +// 파싱 알림은 요청한 카드의 주인에게 그 카드 좌표로 간다. 해소 통지만 같은 상품의 다른 카드로 간다 @Component class ItemParsingRecipientResolver( private val wishRepository: WishRepository, private val tournamentItemRepository: TournamentItemRepository, - private val itemDisplayService: ItemDisplayService, + private val itemCardService: ItemCardService, ) { - fun resolve(snapshotId: Long): Set { - val wishOwners = wishRepository.findOwnerWishIdsBySnapshotId(snapshotId).map { it.userId } - val tournamentAdders = tournamentItemRepository.findUserIdsBySnapshotId(snapshotId) - return (wishOwners + tournamentAdders).toSet() - } + fun routingOf(slot: ItemSlot): NotificationRouting = + when (slot.type) { + ItemSlotType.WISH -> NotificationRouting.Wish(slot.id) + ItemSlotType.TOURNAMENT_ITEM -> tournamentRoutingOf(slot) + } - // 위 집합에서 **새로고침한 사람** 을 뺀 등록 출처 수신자(#1036). 완료·실패 알림은 등록/새로고침으로 타입이 갈려 - // 이쪽을 쓰고, 미완 알림은 갈리지 않아 resolve 그대로 쓴다. 한 사람이 위시를 새로고침하면서 같은 버전을 - // 토너먼트에도 올린 경우는 WISH 우선 규칙과 같은 결로 새로고침 쪽이 가져간다. - fun resolveRegistered(snapshotId: Long): Set { - val wishOwners = wishRepository.findOwnerWishIdsBySnapshotId(snapshotId) - val tournamentAdders = tournamentItemRepository.findUserIdsBySnapshotId(snapshotId) - val refreshers = wishOwners.filter { it.refreshed }.map { it.userId } - return (wishOwners.map { it.userId } + tournamentAdders).toSet() - refreshers.toSet() + // 토너먼트 아이템 행은 하드 삭제하지 않아 지운 행까지 보면 늘 있다 + fun tournamentRoutingOf(slot: ItemSlot): NotificationRouting.Tournament { + require(slot.type == ItemSlotType.TOURNAMENT_ITEM) { "$slot 는 토너먼트 아이템이 아니다" } + val tournamentItem = tournamentItemRepository.findByIdIncludingDeleted(slot.id) ?: error("토너먼트 아이템 ${slot.id} 이 없다") + return NotificationRouting.Tournament(tournamentItem.tournamentId, tournamentItem.getId()) } - // 새로고침 알림(#1036)의 수신자별 컨텍스트 — 그 버전으로 새로고침한 위시 주인(WishOwnerView.refreshed)에게 - // 자기 위시(wishId) 라우팅. 등록 파싱과 수신자가 배타적인 근거는 그 플래그 하나다 — 등록·공유 합류로 태어난 위시는 - // 버전보다 뒤라 refreshed 가 아니다. 새로고침은 위시에서만 일어나 라우팅은 항상 Wish(wishId) 고, 문구는 템플릿이 - // 소유해 수신자별 변수는 없다. 완료·실패 두 핸들러가 수신자 도출(keys)과 컨텍스트 해석에 공유한다. - fun resolveRefreshContexts(snapshotId: Long): Map = - wishRepository - .findOwnerWishIdsBySnapshotId(snapshotId) - .filter { it.refreshed } - .associate { it.userId to RecipientContext(routing = NotificationRouting.Wish(it.wishId)) } - - // 파싱 알림의 딥링크 라우팅을 **수신자별로** 배치 해석한다(#933·#408·#576). 위시 주인 → Wish(그 유저의 wishId), - // 토너먼트 등록자 → 자기 Tournament(tournamentId, tournamentItemId). 한 유저가 양쪽이면 WISH 우선 — 파싱은 - // 결국 그 사람 위시의 결과이고, 토너먼트 아이템은 토너먼트에서 도달 가능해 중복이 적다. - // 조회는 2회(위시·토너먼트)로 고정 — 수신자 수만큼 늘지 않는다(N+1 방지). 공유(#825)로 한 유저가 같은 버전을 - // 여러 토너먼트에 올렸으면 id 오름차순 첫 좌표를 골라 결정성만 확보한다(카드 갱신은 SSE 전 좌표 브로드캐스트가 진다). - // dispatch 는 수신자가 있을 때만 호출하므로 각 수신자는 위시·토너먼트 중 적어도 한쪽에 있어 맵에 반드시 담긴다. - fun resolveRoutingsBySnapshot(snapshotId: Long): Map = - routingsOf( - wishRepository.findOwnerWishIdsBySnapshotId(snapshotId).associate { it.userId to it.wishId }, - tournamentItemRepository.findRoutingsWithUserBySnapshotId(snapshotId), - ) - - // 해소 통지(#1028)의 수신자와 라우팅 — 이 상품의 카드 중 **화면값이 실패·미완이었다가 이 버전으로 채워지는** 카드의 - // 주인(#1051). 판정(ItemVersions.recovers)은 카드 표시값 규칙 그대로라 알림과 카드가 어긋날 수 없고, 적재도 - // 표시값과 같은 경로(ItemDisplayService)를 탄다. 이 버전을 기다리는 카드는 완료·새로고침 완료 알림을 받으므로 제외된다. - fun resolveRecoveredRoutingsBySnapshot(snapshotId: Long): Map { - // 상품은 버전에서 되짚는다(ItemDisplayService.versionsContaining) — 병합 경합으로 이벤트의 itemId 가 진 상품이어도 - // 판정은 이긴 상품의 버전들로 한다. 카드 조회도 같은 상품으로 맞춘다. - val versions = itemDisplayService.versionsContaining(snapshotId) - val itemId = versions.itemId - // 상태로 후보를 SQL 에서 좁히지 않는다 — 내 INCOMPLETE 가 기다리는 행(READY)보다 새로우면 규칙 2 로 카드가 - // 미완성이 되므로, 기다리는 행의 상태만 보는 선필터는 그 카드를 놓친다. 출전은 파생을 타는 대기실(PENDING)만 후보다: - // 시작된 토너먼트는 pin 을 박제해 읽으므로 채워질 카드 자체가 없다. + // 해소 통지(#1028). 이 버전 직전엔 미완이던 카드가 이 버전으로 채워지면 그 주인에게 간다. 요청한 카드는 완료 알림을 받아 뺀다 + fun resolveRecoveredRoutings( + itemId: Long, + snapshotId: Long, + slot: ItemSlot, + ): Map { + val versions = itemCardService.versionsOf(itemId) val wishIdByUser = wishRepository .findCardsByItemId(itemId) - .filter { versions.recovers(viewer = it.userId, waitingOn = it.waitingSnapshotId, snapshotId) } + .filterNot { ItemSlot.wish(it.wishId) == slot } + .filter { versions.recovers(viewer = it.userId, snapshotId = snapshotId) } .associate { it.userId to it.wishId } - val tournamentRoutings = + val tournamentCards = tournamentItemRepository .findPendingCardsByItemId(itemId) - .filter { versions.recovers(viewer = it.userId, waitingOn = it.snapshotId, snapshotId = snapshotId) } - return routingsOf(wishIdByUser, tournamentRoutings) + .filterNot { ItemSlot.tournamentItem(it.tournamentItemId) == slot } + .filter { versions.recovers(viewer = it.userId, snapshotId = snapshotId) } + return routingsOf(wishIdByUser, tournamentCards) } - // 위시 좌표 ∪ 토너먼트 좌표를 수신자별 라우팅 하나로 접는다. 한 유저가 양쪽이면 WISH 우선(위 규칙), - // 같은 유저의 토너먼트 좌표가 여럿이면 id 오름차순 첫 행(쿼리의 ORDER BY)으로 결정성만 확보한다. + // 한 유저가 양쪽이면 WISH 우선. 같은 유저의 토너먼트 좌표가 여럿이면 id 오름차순 첫 행으로 결정성만 확보한다 private fun routingsOf( wishIdByUser: Map, - tournamentRoutings: List, + tournamentCards: List, ): Map { - val tournamentByUser = tournamentRoutings.groupBy { it.userId }.mapValues { (_, rows) -> rows.first() } + val tournamentByUser = tournamentCards.groupBy { it.userId }.mapValues { (_, rows) -> rows.first() } return (wishIdByUser.keys + tournamentByUser.keys).associateWith { userId -> wishIdByUser[userId]?.let { NotificationRouting.Wish(it) } ?: tournamentByUser.getValue(userId).let { NotificationRouting.Tournament(it.tournamentId, it.tournamentItemId) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecoveredHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecoveredHandler.kt index e9e2ff201..75100898e 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecoveredHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingRecoveredHandler.kt @@ -7,18 +7,8 @@ import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component import java.util.UUID -// 실패·미완으로 멈춰 있던 카드가 **다른 사람의 성공 파싱**으로 채워졌음을 알린다(#1028). -// -// 파싱 완료라는 한 사실(ItemParsingCompleted)에서 완료 알림과 함께 갈라져 나온다. 같은 링크는 한 item 을 공유하므로 -// (#825) 남이 성공시키면 표시값 파생(#858)이 멈춰 있던 사람의 카드까지 채운다 — 그런데 그 사람이 받아 둔 실패 알림은 -// 발송 후 불변이라 낡은 채로 남는다. 낡은 알림을 고치는 대신 해소 사실을 새로 알리는 것이 이 알림이다. -// -// 수신자는 완료 알림과 **배타적**이다 — 그 버전을 기다린 사람은 완료·새로고침 완료 알림, 그 밖에 화면값이 실패·미완이었다가 -// 이 버전으로 채워지는 사람은 이 알림. 판정은 카드 표시값 규칙(ItemVersions) 그대로라 알림과 화면이 어긋날 수 없다(#1051). -// -// 포인터는 건드리지 않는다. 남의 파싱 완료 핸들러가 다른 사용자의 wish 행을 쓰면 그 사람의 새로고침·수정과 락 경합이 -// 나는데, 표시값 파생이 이미 카드를 채워 주고 담기 판정·수정 base·새로고침 판정도 전부 표시값을 보므로(#1006·#1007) -// 포인터가 FAILED 로 남아 있어도 어디에도 걸리지 않는다. +// 미완으로 멈춰 있던 카드가 다른 사람의 성공 파싱으로 채워졌음을 알린다(#1028). 받아 둔 미완 알림은 발송 후 불변이라 +// 낡은 알림을 고치는 대신 해소 사실을 새로 알린다 @Component class ItemParsingRecoveredHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -26,27 +16,20 @@ class ItemParsingRecoveredHandler( ) : NotificationEventHandler(NotificationType.ITEM_PARSING_RECOVERED) { override fun resolveRefId(event: ItemParsingCompleted): Long = event.itemId - // 아무도 안 멈춰 있으면 빈 집합이고 dispatch 가 거기서 끝난다 — 대다수 파싱 완료가 이 경우다. - override fun resolveRecipients(event: ItemParsingCompleted): Set = - recipientResolver.resolveRecoveredRoutingsBySnapshot(event.snapshotId).keys + override fun resolveRecipients(event: ItemParsingCompleted): Set = routingsOf(event).keys - // title 의 itemName 은 **방금 성공한 버전** 의 이름이다 — 수신자가 지금 카드에서 보게 되는 그 값. - override fun resolveActorContext(event: ItemParsingCompleted): ActorContext { - val name = itemSnapshotRepository.findById(event.snapshotId)?.name - return ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(name))) - } + override fun resolveActorContext(event: ItemParsingCompleted): ActorContext = + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) - // 라우팅은 수신자별로 갈린다(#933) — 각자 자기 위시(wishId)/자기 토너먼트 좌표로 간다. dispatcher 가 수신자 도출과 - // 이 해석을 따로 부르므로 같은 판정(버전 전체 + 미완성 카드 조회 2회)이 이벤트당 두 번 돈다 — 수신자 수에는 비례하지 - // 않고, 다른 파싱 핸들러와 같은 비용 구조다. override fun resolveRecipientContexts( event: ItemParsingCompleted, recipients: Set, ): Map { - val routings = recipientResolver.resolveRecoveredRoutingsBySnapshot(event.snapshotId) - // 방어: 수신자인데 좌표를 못 찾으면(그 사이 삭제 등) 위시 폴백 — wishId 없이도 알림은 나간다. - return recipients.associateWith { userId -> - RecipientContext(routing = routings[userId] ?: NotificationRouting.Wish(null)) - } + val routings = routingsOf(event) + // 수신자를 고른 뒤 다시 계산하는 사이에 카드가 지워지면 좌표가 없다. 좌표 없는 파싱 알림은 어차피 위시 종류다(NotificationKind.of) + return recipients.associateWith { RecipientContext(routing = routings[it] ?: NotificationRouting.Wish(null)) } } + + private fun routingsOf(event: ItemParsingCompleted): Map = + recipientResolver.resolveRecoveredRoutings(event.itemId, event.snapshotId, event.slot) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshCompletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshCompletedHandler.kt index 91f43cadd..ee33d3441 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshCompletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshCompletedHandler.kt @@ -2,20 +2,11 @@ package com.depromeet.piki.notification.handler import com.depromeet.piki.item.event.ItemParsingCompleted import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component import java.util.UUID -// 위시 **새로고침**(재추출)이 성공했음을 알린다(#1036). -// -// 파싱 완료라는 한 사실(ItemParsingCompleted)에서 등록 완료 알림과 갈라진다. 새로고침은 등록과 같은 PENDING 버전을 -// 만들어 같은 이벤트로 끝나는데, 새로고침한 사람이 "위시 저장이 성공했어요" 를 받으면 어긋난다. 클라가 알림 종류로 -// 등록/갱신을 구분해야 해 타입을 따로 둔다(템플릿은 타입당 하나). -// -// 수신자는 등록 완료 알림과 **배타적**이다 — 그 버전으로 새로고침한 위시 주인만 받고(ItemParsingRecipientResolver -// .resolveRefreshContexts), 등록 완료 알림은 그들을 뺀다(resolveRegistered). 갈리는 근거는 위시 생성시각이 버전보다 -// 앞서는가 하나뿐이라 등록/새로고침을 적는 컬럼이 없다. 토너먼트 출전은 새로고침이 없어 여기 오지 않는다. +// 새로고침(재추출) 성공(#1036). 등록 완료 알림과 수신자가 배타적이다 @Component class ItemRefreshCompletedHandler( private val recipientResolver: ItemParsingRecipientResolver, @@ -23,26 +14,10 @@ class ItemRefreshCompletedHandler( ) : NotificationEventHandler(NotificationType.ITEM_REFRESH_COMPLETED) { override fun resolveRefId(event: ItemParsingCompleted): Long = event.itemId - // 아무도 새로고침하지 않은 버전이면 빈 집합이고 dispatch 가 거기서 끝난다 — 등록 파싱 대다수가 이 경우다. - override fun resolveRecipients(event: ItemParsingCompleted): Set = - recipientResolver.resolveRefreshContexts(event.snapshotId).keys + override fun resolveRecipients(event: ItemParsingCompleted): Set = if (event.refresh) setOf(event.owner) else emptySet() - // title 의 itemName 은 방금 성공한 새 버전의 이름 — 수신자가 새로고침된 카드에서 보게 되는 그 값이다. - override fun resolveActorContext(event: ItemParsingCompleted): ActorContext { - val name = itemSnapshotRepository.findById(event.snapshotId)?.name - return ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(name))) - } + override fun resolveActorContext(event: ItemParsingCompleted): ActorContext = + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) - // 라우팅은 수신자별 자기 위시(wishId). dispatcher 가 수신자 도출과 이 해석을 따로 부르므로 같은 조회가 이벤트당 - // 두 번 돈다(다른 파싱 핸들러와 같은 비용 구조) — 수신자 수에는 비례하지 않는다. - override fun resolveRecipientContexts( - event: ItemParsingCompleted, - recipients: Set, - ): Map { - val contexts = recipientResolver.resolveRefreshContexts(event.snapshotId) - // 방어: 수신자인데 좌표를 못 찾으면(그 사이 삭제 등) wishId 없이도 알림은 나간다. - return recipients.associateWith { userId -> - contexts[userId] ?: RecipientContext(routing = NotificationRouting.Wish(null)) - } - } + override fun resolveRouting(event: ItemParsingCompleted) = recipientResolver.routingOf(event.slot) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshFailedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshFailedHandler.kt index 3832b76aa..f2305d096 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshFailedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemRefreshFailedHandler.kt @@ -1,35 +1,18 @@ package com.depromeet.piki.notification.handler import com.depromeet.piki.item.event.ItemParsingFailed -import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import org.springframework.stereotype.Component import java.util.UUID -// 위시 **새로고침**(재추출)이 실패했음을 알린다(#1036). 수신자·배타성 규칙은 새로고침 완료와 같다 -// (ItemRefreshCompletedHandler 참고) — 그 버전으로 새로고침한 위시 주인만 받고, 등록 실패 알림은 그들을 뺀다. -// -// 등록 실패와 문구가 갈려야 하는 이유: 새로고침은 성공(READY) 항목에서만 시작되므로 실패해도 카드는 표시값 파생(#858)으로 -// 옛 성공본을 그대로 보인다. "가져오지 못했어요" 만 받으면 사용자는 정보가 사라졌다고 오해한다. -// -// 템플릿 변수를 채우지 않는다 — 실패한 버전은 이름이 비어(추출 자체 실패) itemName 이 늘 기본값이라 쓸모가 없고, -// 문구는 title·body 모두 고정이다. 그래서 스냅샷 원본 저장소도 필요 없다. +// 새로고침 실패(#1036). 기존 값은 그대로라 카드 상태는 바뀌지 않는다 @Component class ItemRefreshFailedHandler( private val recipientResolver: ItemParsingRecipientResolver, ) : NotificationEventHandler(NotificationType.ITEM_REFRESH_FAILED) { - override fun resolveRefId(event: ItemParsingFailed): Long = event.itemId + override fun resolveRefId(event: ItemParsingFailed): Long = event.slot.id - override fun resolveRecipients(event: ItemParsingFailed): Set = - recipientResolver.resolveRefreshContexts(event.snapshotId).keys + override fun resolveRecipients(event: ItemParsingFailed): Set = if (event.refresh) setOf(event.owner) else emptySet() - override fun resolveRecipientContexts( - event: ItemParsingFailed, - recipients: Set, - ): Map { - val contexts = recipientResolver.resolveRefreshContexts(event.snapshotId) - return recipients.associateWith { userId -> - contexts[userId] ?: RecipientContext(routing = NotificationRouting.Wish(null)) - } - } + override fun resolveRouting(event: ItemParsingFailed) = recipientResolver.routingOf(event.slot) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandler.kt index c4e729845..3f78f2543 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandler.kt @@ -37,8 +37,7 @@ abstract class NotificationEventHandler( // 수신자별 라우팅 + 추가 템플릿 변수(#933). 기본값은 전 수신자가 resolveRouting(event) 을 공유하고 추가 변수는 없다 — // 대부분의 알림은 한 이벤트의 전 수신자가 같은 화면으로 가므로 이대로 충분하다(기존 핸들러 불변). - // 파싱 알림처럼 한 snapshot 에 위시 주인·토너먼트 등록자가 함께 붙어(공유 #825 의 "진행 중 합류") 수신자마다 - // 딥링크(자기 위시/자기 토너먼트)와 body 문구(출처별)가 갈리는 경우만 override 한다. + // 해소 알림처럼 수신자마다 딥링크(자기 위시/자기 토너먼트)가 갈리거나, 완료 알림처럼 body 문구가 출처별로 갈리는 경우만 override 한다. // 배치(전 수신자 한 번)로 받는 이유: 수신자 수만큼 조회가 늘지 않게 핸들러가 한 번에 해석하게 한다(N+1 방지). open fun resolveRecipientContexts( event: E, diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentCompletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentCompletedHandler.kt index b483d7a2e..64d29ebf7 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentCompletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentCompletedHandler.kt @@ -5,7 +5,7 @@ import com.depromeet.piki.tournament.event.TournamentCompleted import org.springframework.stereotype.Component import java.util.UUID -// 멤버/게스트가 자기 클론(=내 토너먼트의 클론)을 완료하면 ROOT 주최자에게 "OO님이 회원님 토너먼트를 완료했어요" 를 보낸다. +// 멤버/게스트가 자기 플레이를 완료하면 ROOT 주최자에게 "OO님이 회원님 토너먼트를 완료했어요" 를 보낸다. // 수신자 = 원본(ROOT) 주최자. actor(완료한 사람)가 곧 주최자면 제외(− actorId). (ROOT 자체 완료는 TournamentResultReady 가 담당) @Component class TournamentCompletedHandler( diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt index 349e20709..8a9445479 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/TournamentItemDeletedHandler.kt @@ -14,7 +14,7 @@ import java.util.UUID // 재조회 없이 그 항목만 빼고 "OO님이 '상품명'을 삭제" 로 보여줄 수 있게 한다. // - refId=tournamentId : 딥링크(탭하면 그 토너먼트로 입장). 추가 알림과 동일. // - routing=Tournament(tournamentId, tournamentItemId) : payload 에 아이템 좌표를 싣는다(파싱 알림과 공유 셰입). -// - itemName : snapshotId 로 상품명 해석. 아직 파싱 전(PROCESSING)이라 name 이 없으면 fallback. +// - itemName : snapshotId 로 상품명 해석. 파싱 전이라 버전이 없으면 fallback. @Component class TournamentItemDeletedHandler( private val tournamentUserRepository: TournamentUserRepository, @@ -31,7 +31,7 @@ class TournamentItemDeletedHandler( override fun resolveActorContext(event: TournamentItemDeleted): ActorContext { val base = tournamentVariables.context(event.tournamentId, event.actorId) - val itemName = ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name, MAX_NAME_LENGTH) + val itemName = ItemDisplayName.of(event.snapshotId?.let { itemSnapshotRepository.findById(it) }?.name, MAX_NAME_LENGTH) return base.copy(variables = base.variables + ("itemName" to itemName)) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt index d75453afd..82ad53ce9 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationTemplateVariables.kt @@ -1,10 +1,11 @@ package com.depromeet.piki.notification.service import com.depromeet.piki.notification.domain.NotificationType +import com.depromeet.piki.notification.handler.ItemParsingCompletedHandler +import com.depromeet.piki.notification.handler.ItemParsingMergedHandler // 타입별 사용 가능한 템플릿 변수 카탈로그. 백오피스(#250) 편집 화면의 "쓸 수 있는 변수" 표시 + 검증(선언 안 된 -// 변수 차단) + 미리보기 샘플값의 SSOT 다. 현재는 발송 dispatch 가 실제로 채우는 변수만 선언한다 — -// actorName·tournamentName(토너먼트), itemName(파싱 완료·미완·실패), title/body(공지). 채우는 핸들러 resolver 와 함께 추가한다. +// 변수 차단) + 미리보기 샘플값의 SSOT 다. 발송 dispatch 가 실제로 채우는 변수만 선언하고, 채우는 핸들러와 함께 추가한다. data class TemplateVariable( val name: String, val sample: String, @@ -23,9 +24,7 @@ object NotificationTemplateVariables { // 삭제 알림은 어느 상품이 빠졌는지 문구에 담는다 — 공유 TOURNAMENT 변수에 itemName 을 더한다. private val TOURNAMENT_WITH_ITEM = TOURNAMENT + TemplateVariable("itemName", "나이키 에어맥스") - // 파싱 알림 3종(완료·미완·실패)이 공유하는 title 변수 — 각 핸들러의 resolveActorContext 가 snapshot 이름을 - // 역조회해 dispatch 시점에 채운다(ItemDisplayName 기본값 "상품" 폴백). 셋 다 title=이름 이라 OS 푸시 절단에도 - // 무슨 아이템인지가 남는다(#913). + // 파싱 알림이 공유하는 이름 변수. 이름이 없으면 ItemDisplayName 기본값으로 채운다 private val ITEM_NAME = TemplateVariable("itemName", "나이키 에어맥스") private val catalog: Map> = @@ -43,7 +42,7 @@ object NotificationTemplateVariables { NotificationType.ITEM_PARSING_COMPLETED to listOf( ITEM_NAME, - TemplateVariable("completionMessage", "위시 저장이 성공했어요"), + TemplateVariable("completionMessage", ItemParsingCompletedHandler.WISH_COMPLETION_MESSAGE), ), // 미완 알림도 title=${itemName} 이고 핸들러가 채운다(#944). 선언이 빠져 있어 어드민에서 이 템플릿을 // 저장하면 "선언 안 된 변수" 검증에 걸려 저장 자체가 막혔다 — itemName 을 선언해 정합을 맞춘다. @@ -54,6 +53,12 @@ object NotificationTemplateVariables { // 해소 통지(#1028)도 title=${itemName} 이고 핸들러가 같은 방식으로 채운다 — 다만 이름의 출처는 // 방금 성공한 버전이라, 실패로 비어 있던 이름 대신 실제 상품명이 제목에 뜬다. NotificationType.ITEM_PARSING_RECOVERED to listOf(ITEM_NAME), + // mergedMessage 는 완료 문구처럼 출처별로 갈려 코드(ItemParsingMergedHandler)가 소유한다 + NotificationType.ITEM_PARSING_MERGED to + listOf( + ITEM_NAME, + TemplateVariable("mergedMessage", ItemParsingMergedHandler.WISH_MERGED_MESSAGE), + ), // 새로고침 완료(#1036)도 title=${itemName} 이고 핸들러가 새 성공본의 이름으로 채운다. NotificationType.ITEM_REFRESH_COMPLETED to listOf(ITEM_NAME), // 새로고침 실패는 변수를 채우지 않는다 — 실패한 버전은 이름이 비어 itemName 이 늘 기본값이라 쓸모가 없고, diff --git a/src/main/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseBroadcaster.kt b/src/main/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseBroadcaster.kt index 191eae4c6..18327c021 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseBroadcaster.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseBroadcaster.kt @@ -1,12 +1,18 @@ package com.depromeet.piki.notification.sse import com.depromeet.piki.common.config.AsyncConfig +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSlotType import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.item.event.ItemParsingCompleted import com.depromeet.piki.item.event.ItemParsingFailed import com.depromeet.piki.item.event.ItemParsingIncomplete +import com.depromeet.piki.item.event.ItemSlotMerged +import com.depromeet.piki.notification.controller.dto.SilentSyncPayload +import com.depromeet.piki.notification.controller.dto.TournamentItemMerged import com.depromeet.piki.notification.controller.dto.TournamentItemParsed -import com.depromeet.piki.tournament.repository.TournamentItemRepository +import com.depromeet.piki.notification.domain.NotificationRouting +import com.depromeet.piki.notification.handler.ItemParsingRecipientResolver import com.depromeet.piki.tournament.repository.TournamentUserRepository import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Async @@ -14,22 +20,10 @@ import org.springframework.stereotype.Component import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener -// 토너먼트 출전 아이템의 파싱 완료/실패를 그 토너먼트 참여자 화면에 실시간 반영한다. -// -// 문제: 주최자가 링크/이미지로 아이템을 추가하면 참여자는 TOURNAMENT_ITEM_ADDED 로 PENDING 카드(로딩바)를 띄우지만, -// 파싱이 끝나도 별도 신호가 없어 새로고침 전까지 로딩이 멈추지 않았다. 파싱 완료/실패 알림(ITEM_PARSING_*)은 -// 올린 본인(adder)·위시 주인에게만 가고(노이즈 방지, ItemParsingRecipientResolver) 다른 참여자에겐 가지 않기 때문. -// -// 해결: 파싱이 끝나면(READY/FAILED) 그 아이템이 출전한 모든 토너먼트의 참여자 전원에게 SSE 조용한 갱신 이벤트 -// (silent-sync, type=TOURNAMENT_ITEM_PARSED)를 보내 카드를 갱신하게 한다. 이건 "알림"이 아니라 화면 갱신 신호라 -// 알림센터·FCM 을 거치지 않고 SSE 로만 흐른다(NotificationDispatcher 경로와 별개 — 추가 알림 노이즈를 만들지 않는다). -// -// 결합 방향: 알림 -> 도메인 (단방향). item·tournament 도메인은 이 클래스를 모른다(자기 이벤트만 발행). -// AFTER_COMMIT + @Async: 파싱 상태 전이가 커밋된 뒤에만(롤백 시 미발송) 워커 스레드와 분리해 전달한다 -// (notification 의 NotificationEventListener 와 같은 결, 같은 executor). +// 파싱 알림은 요청한 카드 주인에게만 간다. 같은 토너먼트의 다른 참여자는 이 조용한 갱신을 받아야 카드 로딩이 멈춘다 @Component class TournamentItemParsedSseBroadcaster( - private val tournamentItemRepository: TournamentItemRepository, + private val recipientResolver: ItemParsingRecipientResolver, private val tournamentUserRepository: TournamentUserRepository, private val localDelivery: LocalSseDelivery, ) { @@ -38,69 +32,59 @@ class TournamentItemParsedSseBroadcaster( @Async(AsyncConfig.NOTIFICATION_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun on(event: ItemParsingCompleted) { - broadcast(event.snapshotId, ItemStatus.READY) + broadcast(event.slot, ItemStatus.READY) } - // 일부만 채워 끝난 경우도 카드 갱신 대상이다 — 참여자 화면의 로딩바를 멈추게 해야 하는 건 완료·실패와 같고, - // 카드가 "나머지를 채워 주세요" 상태로 바뀌어야 하기 때문이다(#944). status 를 그대로 실어 보내므로 - // 클라이언트는 이미 쓰던 필드로 새 값 하나를 더 받는다. @Async(AsyncConfig.NOTIFICATION_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun on(event: ItemParsingIncomplete) { - broadcast(event.snapshotId, ItemStatus.INCOMPLETE) + broadcast(event.slot, ItemStatus.INCOMPLETE) } @Async(AsyncConfig.NOTIFICATION_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun on(event: ItemParsingFailed) { - broadcast(event.snapshotId, ItemStatus.FAILED) + broadcast(event.slot, ItemStatus.FAILED) + } + + @Async(AsyncConfig.NOTIFICATION_EXECUTOR) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun on(event: ItemSlotMerged) { + broadcastMerged(event) } - // snapshotId(버전)로 그 버전을 pin 한 토너먼트 출전 좌표와 참여자 전원을 풀어 카드 갱신을 보낸다. - // adder(주최자)도 참여자라 함께 받는다 — 이 신호는 "알림"이 아니라 카드 갱신이라, 보는 화면이 모두 동일하게 - // 갱신되는 게 옳다(adder 의 ITEM_PARSING_COMPLETED 알림과는 목적이 다르다). - // - // 라우팅 키가 itemId 가 아니라 버전인 이유(#576): 파싱 사실의 주체가 버전이라, 버전으로 짚으면 이벤트 status 가 - // 어느 좌표에서든 참이다. itemId 라우팅은 공유(#825)·갱신으로 한 item 에 버전이 여럿일 때 "다른 버전을 pin 한 - // 카드"에 남의 완료·실패를 전파(spurious 갱신)한다. 공유로 한 버전이 여러 출전에 pin 되면 전 좌표가 받는 것이 - // 맞다 — 같은 버전은 같은 사실이다. 위시 전용(pin 없음)이면 routings 가 비어 아무 것도 보내지 않는다 - // (위시 주인은 ITEM_PARSING_* 알림으로 받음). - // @Async 워커라 여기서 throw 를 삼키지 않으면 기본 핸들러가 맥락 없는 스택트레이스만 남겨 동기화 누락이 무음이 된다. - // 전체를 runCatching 으로 감싸 itemId 맥락을 실어 warn 으로 남긴다(NotificationDispatcher 가 fan-out 실패를 - // 격리·기록하는 결). emitter write 실패는 deliver 내부(broadcast)가 연결 단위로 이미 격리한다. fun broadcast( - snapshotId: Long, + slot: ItemSlot, status: ItemStatus, ) { + deliver(slot, "status=$status") { TournamentItemParsed(it.tournamentId, it.tournamentItemId, status) } + } + + // 위시끼리 합쳐지면 토너먼트 좌표가 없어 보내지 않는다 + fun broadcastMerged(merged: ItemSlotMerged) { + deliver(merged.holder, "merged=${merged.slot.id}") { TournamentItemMerged(it.tournamentId, merged.slot.id, it.tournamentItemId) } + } + + // @Async 워커라 throw 를 삼키지 않으면 맥락 없는 스택트레이스만 남아 동기화 누락이 무음이 된다 + private fun deliver( + slot: ItemSlot, + detail: String, + payloadOf: (NotificationRouting.Tournament) -> SilentSyncPayload, + ) { + if (slot.type != ItemSlotType.TOURNAMENT_ITEM) return runCatching { - val routings = tournamentItemRepository.findRoutingBySnapshotId(snapshotId) - if (routings.isNotEmpty()) { - // 참여자는 토너먼트들을 한 번에 모아 bulk 조회한다(반복문 내 쿼리 N+1 회피). tournamentId -> userIds 색인 후 재사용. - val participantsByTournament = - tournamentUserRepository - .findByTournamentIds(routings.map { it.tournamentId }.distinct()) - .groupBy({ it.tournamentId }, { it.userId }) - routings.forEach { routing -> - val participants = participantsByTournament[routing.tournamentId].orEmpty() - localDelivery.deliverSilentSync( - participants, - TournamentItemParsed( - tournamentId = routing.tournamentId, - tournamentItemId = routing.tournamentItemId, - status = status, - ), - ) - log.info( - "토너먼트 아이템 파싱 동기화 전송 tournamentId={} tournamentItemId={} status={} 참여자={}명", - routing.tournamentId, - routing.tournamentItemId, - status, - participants.size, - ) - } - } + val tournament = recipientResolver.tournamentRoutingOf(slot) + val participants = tournamentUserRepository.findByTournamentId(tournament.tournamentId).map { it.userId } + localDelivery.deliverSilentSync(participants, payloadOf(tournament)) + log.info( + "토너먼트 아이템 파싱 동기화 전송 tournamentId={} tournamentItemId={} {} 참여자={}명", + tournament.tournamentId, + tournament.tournamentItemId, + detail, + participants.size, + ) }.onFailure { e -> - log.warn("토너먼트 아이템 파싱 동기화 실패 snapshotId={} status={}", snapshotId, status, e) + log.warn("토너먼트 아이템 파싱 동기화 실패 slotType={} slotId={} {}", slot.type, slot.id, detail, e) } } } diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt b/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt deleted file mode 100644 index 74fb7b86e..000000000 --- a/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.depromeet.piki.product.domain - -import java.security.MessageDigest - -// 정규화된 귀결점 — 상품 정체성(#825)의 단일 표현. "어디까지를 같은 상품으로 볼 것인가"의 경계를 이 정규화가 -// 정하고, hash 는 그 결과를 색인 가능하게 줄이는 고정 길이 대리키(SHA-256 hex)일 뿐 같음의 정의에 관여하지 않는다 -// (utf8mb4 인덱스 상한 768자 < URL 컬럼 2048자라 unique 를 hash 가 대신 진다). -// -// 두 차선이 같은 함수를 쓴다: 등록 입력(대체로 이미 깨끗함)과 파싱 귀결점(finalUrl). 무게중심은 후자다 — -// 리다이렉트가 공유자 계정 ID(af_referrer_customer_id)·리워드 토큰(reward_key)·클릭 UUID(event_uuid)· -// 밀리초 타임스탬프(referrer_timestamp)를 붙여 와, 정규화 없이는 같은 링크의 재파싱끼리도 정체성이 갈라진다 -// (2026-07-30 실측). 이 값들은 개인·세션 연동 정보라 저장 위생 문제이기도 하다 — 정규화가 DB 진입 전에 떨군다. -class CanonicalLink private constructor( - val url: String, - val hash: String, -) { - // 저장 한계: canonical 은 items.canonical_url·item_links.url 의 VARCHAR(2048) 에 저장된다. 정규화는 구성요소 - // 재조립뿐이라 입력을 늘리지 않지만, 입력 자체(특히 리다이렉트 귀결점 finalUrl)는 우리 밖에서 와 상한이 없다. - // 초과 URL 은 자를 수 없다 — 절단된 canonical 은 다른 상품과 충돌할 수 있는 거짓 정체성이다. 그래서 영속 경로가 - // 이 플래그를 보고 canonical 확정·별칭 기록을 건너뛴다(그 item 은 정체성 미확정으로 남고, 빈도는 메트릭으로 관측). - val exceedsStorageLimit: Boolean - get() = url.length > STORAGE_MAX_LENGTH - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is CanonicalLink) return false - return url == other.url - } - - override fun hashCode(): Int = url.hashCode() - - override fun toString(): String = url - - companion object { - // 쿼리 전부 제거 몰 — "상품 번호가 경로에 있다"를 실측(prod 전수 쿼리 키 인벤토리 + 쿼리 유/무 A/B fetch - // 동일 확인, 2026-07-30)으로 검증한 몰만 올린다. 미확인 몰을 올리면 쿼리로 상품을 식별하는 몰 - // (cafe24 의 product_no 등)에서 서로 다른 상품이 하나로 합쳐지는 사고가 난다 — 잘못 합쳐짐(정체성 오염)이 - // 안 합쳐짐(병합 효과 손실)보다 훨씬 비싸다는 비대칭이 이 목록을 보수적으로 유지하는 이유다. - // onelink.me 는 AppsFlyer 단축 플랫폼 전체 — 경로가 곧 공유 코드라 쿼리가 상품을 식별할 수 없다. - // W컨셉은 페이지가 JS 셸이라 A/B 검증 증거가 약해 제외(기본 규칙 적용). - private val STRIP_ALL_QUERY_DOMAINS = - setOf("musinsa.com", "29cm.co.kr", "zigzag.kr", "kream.co.kr", "a-bly.com", "onelink.me") - - // 미확인 몰용 보험 — 확실한 추적 파라미터만 이름으로 지운다. 여기 없는 이름이 남아 병합이 덜 되는 건 - // 감수하는 손실이고, 상품 식별 파라미터를 지우는 사고는 감수 불가라 목록을 최소로 유지한다. - // pid·channel·campaign 처럼 범용적인 이름은 어느 몰이 의미 있게 쓸지 몰라 전역 목록에 넣지 않는다 - // (그런 이름이 붙는 몰은 실측 후 STRIP_ALL_QUERY_DOMAINS 로 올리는 게 정답). - private val TRACKING_PARAM_PREFIXES = listOf("utm_", "af_", "airbridge") - private val TRACKING_PARAM_NAMES = setOf("fbclid", "gclid", "igshid", "yclid") - - // 같은 상품이 경로 형식 두 가지로 존재하는 몰의 접기 규칙. 지그재그는 단축링크 귀결점이 /p/{id}, - // 웹 주소가 /catalog/products/{id} 로 갈리는데 같은 번호 = 같은 상품임을 실측 확인(2026-07-30). - private val ZIGZAG_SHORT_PATH = Regex("^/p/(\\d+)$") - - private const val HASH_LENGTH = 64 - - // items.canonical_url · item_links.url 컬럼 길이와 일치시킨다. - const val STORAGE_MAX_LENGTH = 2048 - - // fragment 는 여기서 별도 처리하지 않는다 — 재조립이 rawPath·rawQuery 만 쓰므로 구조적으로 탈락한다. - // fragment 는 HTTP 요청에 실리지 않아 서버 렌더 몰에선 상품을 바꿀 수 없고(원리), prod 582건 중 의미 있는 - // fragment 는 0건(실측). 예외는 해시로 화면을 가르는 SPA 를 실제로 렌더해 읽는 경우뿐 — 그런 몰이 나타나면 - // 몰별 예외로 보존 규칙을 더한다. - fun of(link: ProductLink): CanonicalLink { - val host = requireNotNull(link.normalizedHost()) { "host 없는 링크는 canonical 을 만들 수 없다" } - val path = normalizePath(host, link.value.rawPath.orEmpty()) - val query = normalizeQuery(link, link.value.rawQuery) - val url = - buildString { - append("https://").append(host).append(path) - query?.let { append('?').append(it) } - } - return CanonicalLink(url = url, hash = sha256Hex(url)) - } - - private fun normalizePath( - host: String, - rawPath: String, - ): String { - // trailing slash 유무로 정체성이 갈리지 않게 접는다 (cafe24 계열이 /153/ 형태를 쓴다). - val stripped = rawPath.trimEnd('/').ifEmpty { "/" } - if (host == "zigzag.kr" || host.endsWith(".zigzag.kr")) { - val folded = - ZIGZAG_SHORT_PATH.matchEntire(stripped)?.let { "/catalog/products/${it.groupValues[1]}" } - return folded ?: stripped - } - return stripped - } - - private fun normalizeQuery( - link: ProductLink, - rawQuery: String?, - ): String? { - rawQuery ?: return null - if (rawQuery.isBlank()) return null - if (link.matchesAnyDomain(STRIP_ALL_QUERY_DOMAINS)) return null - val kept = - rawQuery - .split('&') - .filter { it.isNotBlank() && !isTrackingParam(it) } - // 파라미터 순서만 다른 URL 이 다른 정체성이 되지 않게 정렬한다 — GET 상품 페이지에서 - // 서버가 파라미터 순서에 의미를 두는 경우는 없다. - .sorted() - return kept.takeIf { it.isNotEmpty() }?.joinToString("&") - } - - private fun isTrackingParam(segment: String): Boolean { - val name = segment.substringBefore('=').lowercase() - return name in TRACKING_PARAM_NAMES || TRACKING_PARAM_PREFIXES.any { name.startsWith(it) } - } - - private fun sha256Hex(value: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) - val hex = digest.joinToString("") { "%02x".format(it) } - check(hex.length == HASH_LENGTH) { "SHA-256 hex 는 항상 64자다" } - return hex - } - } -} diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ParseInputType.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ParseInputType.kt new file mode 100644 index 000000000..d5f511baa --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ParseInputType.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.product.domain + +enum class ParseInputType( + val description: String, +) { + LINK("쇼핑몰 상품 URL"), + IMAGE("업로드한 이미지의 저장 키"), +} diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt index e356276d4..b0c8f0b35 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt @@ -7,24 +7,15 @@ class ProductLink private constructor( ) { override fun toString(): String = value.toString() - // 로그/메트릭용. 쿼리스트링·fragment 에 토큰/세션이 섞일 수 있어 host+path 만 노출한다. - fun safeLogString(): String = "${value.host ?: "?"}${value.rawPath ?: ""}" - - // host 가 주어진 도메인 목록의 항목과 같거나 그 서브도메인이면 true 인 도메인 단위 매칭의 단일 술어. - // 도메인 접근 정책 판정(DomainAccessPolicy)이 쓰는 도메인 매칭 술어다 — - // 정규화 규칙(trailing dot 제거, lowercase, 부분 문자열이 아닌 도메인 단위)이 바뀔 때 사본들이 갈라지지 않게 - // 도메인이 규칙의 주인을 맡는다. host 가 없으면(형식 이상은 parse 가 이미 처리) 어느 목록과도 매칭되지 않는다. - // trailing dot(절대 도메인 표기, 예: "naver.com.")은 제거해 우회를 막는다. Kotlin lowercase() 는 locale 무관(invariant). - // domains 항목은 소문자·trailing dot 없는 정규형이어야 한다 — 출처(DB 정책의 admin 경계와 엔티티 불변식)가 - // 정규화를 책임진다. 새 출처가 생기면 그 출처가 같은 정규화를 진다. + // 부분 문자열이 아니라 도메인 단위로 본다. evil-naver.com 이 naver.com 으로 통과하면 안 된다 + // domains 는 소문자이고 끝에 점이 없어야 한다. 그 정규화는 목록을 만드는 쪽이 한다 fun matchesAnyDomain(domains: Collection): Boolean { - val host = normalizedHost() ?: return false + val host = normalizedHost() return domains.any { host == it || host.endsWith(".$it") } } - // host 정규형(trailing dot 제거·lowercase). 도메인 매칭(위 matchesAnyDomain)과 출처 몰 표시명 유도 - // (SourcePlatformResolver)가 같은 정규형을 공유한다 — 규칙이 갈라지지 않게 여기가 주인이다. - fun normalizedHost(): String? = value.host?.trimEnd('.')?.lowercase() + // "naver.com." 같은 끝점 표기로 도메인 판정을 우회하지 못하게 끝점을 뗀다 + fun normalizedHost(): String = value.host.trimEnd('.').lowercase() override fun equals(other: Any?): Boolean { if (this === other) return true @@ -35,30 +26,32 @@ class ProductLink private constructor( override fun hashCode(): Int = value.hashCode() companion object { + // 모든 URL 길이 상한 + const val MAX_LENGTH = 768 + private val ENTRY_SCHEMES = setOf("https") - // 귀결점은 extractor 가 매 홉 http 도 따라가(내부 주소 판정은 scheme 과 무관하게 거기서 끝남) http 일 수 있다. - // 정체성은 CanonicalLink 가 https 로 다시 조립하므로 같은 host·path 의 http·https 가 하나로 모인다. + // 귀결점은 extractor 가 홉마다 http 도 따라가 http 일 수 있다. 정체 URL 은 https 로 다시 조립해 하나로 모인다(NormalizedInput) private val HOP_SCHEMES = setOf("http", "https") fun parse(raw: String): ProductLink = parse(raw, ENTRY_SCHEMES) - // 파싱 귀결점(redirect·렌더가 도착한 주소) 전용. CanonicalLink 가 host 를 요구하므로 여기서 함께 거른다. - fun parseHop(raw: String): ProductLink { - val link = parse(raw, HOP_SCHEMES) - requireNotNull(link.value.host) { "host 없는 귀결점" } - return link - } + // 상품 주소 규칙에 안 맞는 귀결점은 없는 것으로 본다. 추출기가 돌려준 귀결점처럼 우리가 검증하지 않은 출처에서 쓴다 + fun parseHopOrNull(raw: String): ProductLink? = + try { + parse(raw, HOP_SCHEMES) + } catch (e: ProductLinkException) { + null + } catch (e: IllegalArgumentException) { + null + } private fun parse( raw: String, schemes: Set, ): ProductLink { val trimmed = raw.trim() - // 계약이 아니라 불변식이라 require 다 — 두 등록 DTO 의 @field:NotBlank 가 빈 값을 먼저 걸러 - // 멀쩡한 클라이언트는 여기 닿을 수 없다. 닿았다면 어느 입력 경계가 자기 검증을 빠뜨린 것이다. - // 그래도 message 는 사용자 친화 문구로 둔다 — IllegalArgumentException 캐치올이 400 응답의 - // detail 로 그대로 싣기 때문에 내부 문구를 쓰면 그 순간 사용자에게 노출된다. + // 빈 값은 요청 DTO 가 먼저 걸러 여기 닿지 않는다. 닿으면 캐치올이 이 문구를 응답 detail 로 내보내므로 사용자 문구로 둔다 require(trimmed.isNotBlank()) { "링크를 입력해 주세요." } val uri = try { @@ -66,9 +59,10 @@ class ProductLink private constructor( } catch (e: IllegalArgumentException) { throw ProductLinkException.invalidFormat(e) } - // URI.create 는 스킴 없는 "example.com/product" 도 relative URI 로 통과시키므로 명시 검증. - // RFC 3986 은 scheme 을 case-insensitive 로 정의하므로 비교 전에 lowercase 정규화한다. + // URI.create 는 스킴 없는 "example.com/product" 도 통과시킨다 if (uri.scheme?.lowercase() !in schemes) throw ProductLinkException.unsupportedScheme() + // "https:foo" 나 호스트에 밑줄이 든 주소는 URI 로는 읽히지만 host 가 없다 + uri.host ?: throw ProductLinkException.invalidFormat() return ProductLink(uri) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkConverter.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkConverter.kt deleted file mode 100644 index 51653cc82..000000000 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkConverter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.depromeet.piki.product.domain - -import jakarta.persistence.AttributeConverter -import jakarta.persistence.Converter - -@Converter(autoApply = false) -class ProductLinkConverter : AttributeConverter { - override fun convertToDatabaseColumn(attribute: ProductLink?): String? = attribute?.toString() - - // 읽기 시점에 parse 실패가 그대로 IllegalArgumentException 으로 새면 - // 한 행이 깨졌을 때 리스트/페이징 쿼리 전체가 500 으로 떨어진다. - // 도메인 예외로 감싸 호출 측이 row 단위로 식별·격리할 수 있도록 한다. - override fun convertToEntityAttribute(dbData: String?): ProductLink? = - dbData?.let { - runCatching { ProductLink.parse(it) } - .getOrElse { e -> - throw IllegalStateException("저장된 product link 가 정책에 어긋납니다: $it", e) - } - } -} diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt index 789b85bb2..09f357f5b 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt @@ -7,7 +7,7 @@ import com.depromeet.piki.common.exception.ErrorCode // code·category·message 를 한 엔트리에 모아 single source 로 둔다: status 는 category.httpStatus 로, // 응답 detail·로그·OpenAPI 카탈로그는 message 로 파생된다. // -// 3개 전부 공개 JSON API 도달이라 ErrorCodeRegistry 에 등록한다. 링크 등록 경계(ProductLink.of · +// 3개 전부 공개 JSON API 도달이라 ErrorCodeRegistry 에 등록한다. 링크 등록 경계(ProductLink.parse · // DomainAccessPolicy)가 위시 등록(POST /wishlists)·토너먼트 아이템 등록(POST /tournaments/{id}/items) // 양쪽에서 GlobalExceptionHandler 를 거쳐 wire code 로 나간다(WishlistApiExamples·TournamentItemApiExamples 문서화). // diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkException.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkException.kt index a0584d239..3c9337d24 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkException.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkException.kt @@ -20,12 +20,12 @@ class ProductLinkException private constructor( // 원본 URL 은 message 에 박지 않는다. GlobalExceptionHandler 가 message 를 응답 detail·로그 // 양쪽에 박는 구조라, 쿼리스트링/fragment 에 섞일 수 있는 토큰·세션이 외부로 새는 경로가 되기 때문. // 디버깅용 컨텍스트는 cause 로 연결해 stack trace 로만 남긴다. - fun invalidFormat(cause: Throwable): ProductLinkException = ProductLinkException(ProductLinkErrorCode.INVALID_FORMAT, cause) + fun invalidFormat(cause: Throwable? = null): ProductLinkException = ProductLinkException(ProductLinkErrorCode.INVALID_FORMAT, cause) fun unsupportedScheme(): ProductLinkException = ProductLinkException(ProductLinkErrorCode.UNSUPPORTED_SCHEME) // fetch 로 상품 정보를 가져올 수 없는 플랫폼(직접 접근을 봇 차단하는 쇼핑몰)을 등록 시점에 거른다. - // 어느 플랫폼인지는 message 에 박지 않는다(safeLogString 으로 로그). 사용자에겐 "아직 안 되는 곳" 안내만. + // 어느 플랫폼인지는 message 에 박지 않는다. 사용자에겐 "아직 안 되는 곳" 안내만. fun unsupportedPlatform(): ProductLinkException = ProductLinkException(ProductLinkErrorCode.UNSUPPORTED_PLATFORM) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt index dc28b2c42..bf0866b36 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt @@ -27,8 +27,7 @@ enum class ExtractionFailureBucket { // 확정 실패 예외가 자기 bucket 을 스스로 밝히게 하는 ErrorCode. 예외 클래스가 늘어도 워커는 이 인터페이스만 // 보므로 reason 파생 경로가 한 줄로 유지되고, bucket 은 code 정의 옆(single source)에 박힌다. // -// bucket 이 nullable 인 이유: 일시(transient) code 는 bucket 이 없다(계약도 같은 모양). 일시 실패는 -// 소유권 반납으로 되살아나 종결 집계(reason)에 닿지 않고, 상한을 소진하면 recover 가 retry_exhausted 로 센다. +// 일시(transient) code 는 bucket 이 없다(카탈로그도 같은 모양) interface ExtractionFailureCode : ErrorCode { val bucket: ExtractionFailureBucket? } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshot.kt b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshot.kt index 916783a92..b49eaa63d 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshot.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ProductSnapshot.kt @@ -1,70 +1,40 @@ package com.depromeet.piki.product.service +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.product.domain.CurrencyCode import com.depromeet.piki.product.domain.ProductLink -// 상품 추출 시점의 상태를 캡처한 결과. URL 추출(link)·이미지 추출(image) 두 경로가 공유하는 표현이며, -// 영속 표현(Item 엔티티)과 분리되어 extract 가 트랜잭션·영속 컨텍스트 바깥에서 다뤄질 수 있게 한다. -// 이미지 추출은 URL 이 없어 link 가 null 이다. +// 추출 결과. 링크 추출과 이미지 추출이 같은 모양으로 돌려준다. 엔티티가 아니라서 추출을 트랜잭션 밖에서 끝낼 수 있다 data class ProductSnapshot( - val link: ProductLink? = null, val name: String? = null, val imageUrl: String? = null, val price: Int? = null, val currency: String? = null, - // 리다이렉트를 따라간 최종 페이지 URL(extractor 계약의 additive 필드). 상품 정체성(canonical, #825) 정규화의 - // 입력이며, 구버전 extractor·이미지 경로에선 null 이다 — null 이면 정체성 확정을 건너뛴다(배포 순서 무관). - val finalUrl: String? = null, - // 값을 만든 추출 경로의 wire 문자열(STRUCTURED|LLM). 소비처(ItemSnapshotSource.fromWireMethod)가 출처 enum 으로 - // 번역하고, 모르는 값·null 은 출처 미기록으로 둔다(tolerant). 정규화·검증 대상이 아니라 원문 그대로 나른다. - val extractionMethod: String? = null, + // 리다이렉트를 따라간 최종 페이지 URL. 아이템 정체 URL 의 재료이고 이미지 추출에는 없다 + val finalUrl: ProductLink? = null, + val method: ExtractionMethod? = null, ) { companion object { - // items 테이블 컬럼 길이와 일치시킨다. - private const val NAME_MAX_LENGTH = 512 - private const val IMAGE_URL_MAX_LENGTH = 2048 - - // 원시 추출값(구조화 파싱·LLM 추출이 공유)을 정규화·범위검증해 만드는 단일 진실 원천. - // name blank→null, imageUrl 은 https 만(클라이언트가 로 쓸 때의 XSS 사다리 차단), - // currency 는 ISO 4217 로 정규화한다. 추출값이 DB 컬럼 제약·상식을 벗어나면(가격 음수·길이 초과) - // 추출 실패로 보고 untrustworthyValue 를 던진다 — 입력 경계의 계약 검증. - // - // 실패 처리는 호출부가 고른다: 구조화 경로는 이 예외를 runCatching 으로 흡수해 Miss.INVALID_VALUE(→LLM fallback)로, - // LLM 경로는 그대로 흘려 FAILED 로 떨어뜨린다. 같은 검증, 실패 표현만 다르다. + // extractor 가 정상 값을 보장해도 신뢰 경계를 넘어온 값이라 다시 거른다 + // 이미지 URL 은 https 만 받는다. 클라이언트가 로 쓰므로 다른 스킴은 XSS 통로가 된다 fun fromExtracted( - link: ProductLink?, name: String?, imageUrl: String?, price: Int?, currency: String?, - // 출처 메타(finalUrl·extractionMethod)는 값 검증 대상이 아니라 그대로 나른다 — finalUrl 의 정규화·길이 - // 판정은 정체성 확정 지점(CanonicalLink)이 지고, method 는 소비처가 tolerant 하게 번역한다. finalUrl: String? = null, - extractionMethod: String? = null, + method: ExtractionMethod? = null, ): ProductSnapshot { - val normalizedName = name?.takeIf { it.isNotBlank() } - val normalizedImageUrl = - imageUrl?.takeIf { it.isNotBlank() && it.startsWith("https://", ignoreCase = true) } - val normalizedCurrency = CurrencyCode.normalizeOrNull(currency) - - if ((price ?: 0) < 0) { - throw ProductSnapshotException.untrustworthyValue() - } - if ((normalizedName?.length ?: 0) > NAME_MAX_LENGTH) { - throw ProductSnapshotException.untrustworthyValue() - } - if ((normalizedImageUrl?.length ?: 0) > IMAGE_URL_MAX_LENGTH) { - throw ProductSnapshotException.untrustworthyValue() - } - + if ((price ?: 0) < 0) throw ProductSnapshotException.untrustworthyValue() return ProductSnapshot( - link = link, - name = normalizedName, - imageUrl = normalizedImageUrl, + name = name?.takeIf { it.isNotBlank() }, + imageUrl = imageUrl?.takeIf { it.isNotBlank() && it.startsWith("https://", ignoreCase = true) }, price = price, - currency = normalizedCurrency, - finalUrl = finalUrl, - extractionMethod = extractionMethod, + currency = CurrencyCode.normalizeOrNull(currency), + // 귀결점이 상품 주소 규칙에 안 맞으면 등록 입력이 정체가 된다 + finalUrl = finalUrl?.let(ProductLink::parseHopOrNull), + // 계약 밖의 값은 기록하지 않는다 + method = method?.takeUnless { it == ExtractionMethod.EXTRACTION_METHOD_UNSPECIFIED || it == ExtractionMethod.UNRECOGNIZED }, ) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt index 92968b36b..bc7051407 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt @@ -11,7 +11,7 @@ import java.time.LocalDateTime @Entity @Table(name = "extraction_models") class ExtractionModelEntity( - // ExtractionTarget 의 이름 문자열. @Enumerated 로 두지 않는 이유는 DomainAccessPolicyEntity.access 와 + // ParseInputType 의 이름 문자열. @Enumerated 로 두지 않는 이유는 DomainAccessPolicyEntity.access 와 // 같다 — 구버전 바이너리가 모르는 target 행 하나가 findAll 하이드레이션을 깨 부팅(@PostConstruct)을 죽인다. // enum 변환은 읽는 쪽(ExtractionModelSettings)이 tolerant 하게 진다. @Id diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt index 772352a01..75fd7b855 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt @@ -3,6 +3,7 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode import com.depromeet.piki.contracts.extraction.v1.ExtractionFailure import com.depromeet.piki.contracts.extraction.v1.ModelProbeRequest +import com.depromeet.piki.product.domain.ParseInputType import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.HttpStatus @@ -28,7 +29,7 @@ interface ExtractionModelProbe { // 유효하면 그냥 반환하고, 아니면 화면에 그대로 띄울 사유를 담아 던진다. 백오피스 컨트롤러가 // IllegalArgumentException 을 화면 에러로 흡수하는 기존 관례(AdminExtractionPolicyController)를 따른다. fun verify( - target: ExtractionTarget, + target: ParseInputType, model: String, ) } @@ -43,7 +44,7 @@ class HttpExtractionModelProbe( private val log = LoggerFactory.getLogger(javaClass) override fun verify( - target: ExtractionTarget, + target: ParseInputType, model: String, ) { try { @@ -73,7 +74,7 @@ class HttpExtractionModelProbe( // 모양이되 그쪽은 ProductSnapshot 을 돌려주므로 코드를 공유하지 않는다. private fun translate( e: RestClientResponseException, - target: ExtractionTarget, + target: ParseInputType, model: String, ): IllegalArgumentException { if (!e.statusCode.isSameCodeAs(HttpStatus.UNPROCESSABLE_ENTITY)) { @@ -95,10 +96,10 @@ class HttpExtractionModelProbe( ) } - private fun ExtractionTarget.toContract(): ModelProbeRequest.Target = + private fun ParseInputType.toContract(): ModelProbeRequest.Target = when (this) { - ExtractionTarget.LINK -> ModelProbeRequest.Target.LINK - ExtractionTarget.IMAGE -> ModelProbeRequest.Target.IMAGE + ParseInputType.LINK -> ModelProbeRequest.Target.LINK + ParseInputType.IMAGE -> ModelProbeRequest.Target.IMAGE } // internal 인 이유: 이 사유 문구가 곧 백오피스 화면에 뜨는 계약이라 테스트가 같은 상수로 단언한다. diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt index 26914b495..37e09e46a 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.product.service.remote +import com.depromeet.piki.product.domain.ParseInputType import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled @@ -8,8 +9,8 @@ import org.springframework.stereotype.Component // 추출 경로별 LLM 모델 지정의 단일 조회 지점. 인터페이스/구현 분리는 DomainAccessPolicy 와 같은 구조 — // 소비자(원격 클라이언트)의 단위 테스트가 DB 없이 지정값을 대체할 수 있게 한다. interface ExtractionModelSettings { - // 지정된 모델. 행이 없으면 null 이고, 그 경우 호출자는 요청에 모델을 싣지 않아 extractor 기본값으로 동작한다. - fun modelOf(target: ExtractionTarget): String? + // 지정이 없으면 null. 호출자는 요청에 모델을 싣지 않아 extractor 기본값으로 동작한다 + fun modelOverrideOf(target: ParseInputType): String? } // DB(extraction_models) 기반 구현. 백오피스가 배포 없이 모델을 바꾼다 — 판정은 잦으므로(파싱마다) 매번 DB 를 @@ -23,7 +24,7 @@ class DbExtractionModelSettings( // 불변 Map 을 통째로 교체(@Volatile)한다 — reader(파싱 스레드)는 항상 옛/새 전체 중 하나만 본다. @Volatile - private var models: Map = emptyMap() + private var models: Map = emptyMap() // @Synchronized 인 이유: 조회와 교체가 갈라져 있으면 늦게 시작한 재적재가 먼저 끝나는 역전이 생긴다. // 주기 재적재가 DB 를 읽는 사이 백오피스 저장의 afterCommit 재적재가 통째로 끝나 버리면, 뒤늦게 완료된 @@ -40,7 +41,7 @@ class DbExtractionModelSettings( @Scheduled(fixedDelay = RELOAD_INTERVAL_MS) fun reload() = load() - override fun modelOf(target: ExtractionTarget): String? = models[target] + override fun modelOverrideOf(target: ParseInputType): String? = models[target] // 캐시 적재와 백오피스 화면이 함께 쓰는 단일 조회 지점. 화면이 캐시가 아니라 저장소를 직접 읽는 이유는 // 캐시가 afterCommit 갱신이라 방금 저장한 값이 아직 안 보일 수 있어서다. @@ -51,11 +52,11 @@ class DbExtractionModelSettings( // // 인터페이스에 두지 않는 이유는 reload 와 같다 — 파싱 경로가 쓰지 않는 관리 기능이라 admin 이 구현을 // 직접 주입받는다 (AdminExtractionPolicyService 가 DbDomainAccessPolicy 를 직접 받는 것과 같다). - fun findAll(): Map = + fun findAll(): Map = repository .findAll() .mapNotNull { entity -> - val target = ExtractionTarget.entries.find { it.name == entity.target } + val target = ParseInputType.entries.find { it.name == entity.target } target ?: run { log.warn("모르는 추출 target 을 스킵(해당 경로는 기본 모델): target={}", entity.target) return@mapNotNull null diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionTarget.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionTarget.kt deleted file mode 100644 index ecdda599b..000000000 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionTarget.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.depromeet.piki.product.service.remote - -// 추출 경로. 어떤 LLM 모델을 쓸지의 축이며, 원격 계약(프로브 요청의 target)과 값이 1:1 이다. -// -// 축을 나누는 이유: 링크는 HTML 에서 텍스트를 읽어 JSON 스키마로 뽑고 이미지는 vision 이라, 한쪽에 맞는 모델이 -// 다른 쪽에 맞지 않을 수 있다. 지금은 두 경로가 extractor 안에서 같은 클라이언트를 지나지만 그건 구현 사정이고, -// 운영자가 고르는 단위는 경로별이어야 한다. -enum class ExtractionTarget { - LINK, - IMAGE, -} diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractor.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractor.kt index 6b2b4c320..c8578cef7 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractor.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractor.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.contracts.extraction.v1.LinkExtractionRequest +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.domain.ProductLinkException import com.depromeet.piki.product.routing.DomainAccessPolicy @@ -26,13 +27,12 @@ class HttpLinkSnapshotExtractor( // 허락 원장은 core DB 에만 있고 extractor·renderer 는 무상태라 요청마다 실음 .setAuthorized(accessPolicy.authorizedFor(link)) // extractor 박스를 여러 환경이 공유해 저쪽 환경변수로 모델을 잡으면 dev 실험이 prod 를 덮음 - modelSettings.modelOf(ExtractionTarget.LINK)?.let(request::setModel) + modelSettings.modelOverrideOf(ParseInputType.LINK)?.let(request::setModel) return RemoteExtractionContract.postForSnapshot( restClient = restClient, path = ExtractionEndpoints.LINK_EXTRACTION, request = request.build(), - link = link, - target = "url=${link.safeLogString()}", + target = "url=\"$link\"", ) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt index d92eb8571..013a76067 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt @@ -3,9 +3,7 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.common.exception.BaseException import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode import com.depromeet.piki.contracts.extraction.v1.ExtractionFailure -import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.contracts.extraction.v1.ExtractionResult -import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.product.service.ProductSnapshotException import com.google.protobuf.util.JsonFormat @@ -18,53 +16,30 @@ import org.springframework.web.client.RestClient import org.springframework.web.client.RestClientException import org.springframework.web.client.RestClientResponseException -// 원격 추출 서비스(extractor) 계약(정본: infra contracts/)의 공용 절반. -// link(HttpLinkSnapshotExtractor)·image(HttpImageSnapshotExtractor) 두 클라이언트가 같은 응답 모양(ExtractionResult)과 -// 같은 3갈래 번역을 쓰므로 호출·번역 전체를 한 곳에 모은다 — 계약이 진화할 때 두 클라이언트가 조용히 어긋나는 것을 막는다. -// 클라이언트별로 갈리는 건 요청 모양(URL vs bucket·key)과 로그 컨텍스트(target)뿐이다. -// -// 요청·응답 클래스는 계약 정본에서 생성된다 — 이쪽에 필드 정의가 없어 한쪽만 개명되는 사고가 불가능하다. internal object RemoteExtractionContract { private val log = LoggerFactory.getLogger(javaClass) - // 기본 파서는 모르는 필드에 실패한다 — 그대로 두면 extractor 가 필드를 먼저 더한 2xx 가 일시 실패로 떨어진다. - // 프린터는 authorized false 를 생략하지 않고 찍어 "무엇을 보냈나"가 와이어와 상대 로그에 남게 한다. + // 기본 파서는 모르는 필드에 실패해 extractor 가 필드를 먼저 더하면 2xx 가 일시 실패로 떨어진다. 프린터는 authorized false 를 생략하지 않는다 fun messageConverter(): HttpMessageConverter<*> = ProtobufJsonFormatHttpMessageConverter( JsonFormat.parser().ignoringUnknownFields(), JsonFormat.printer().alwaysPrintFieldsWithNoPresence(), ).apply { supportedMediaTypes = listOf(MediaType.APPLICATION_JSON) } - // 확정 실패(422) code 전수 → 우리 예외. 계약 정본(shared-infra/contracts/extraction.proto)의 - // permanent code 를 빠짐없이 여기에 명시한다 — 표에 없는 code 는 아래 fallback 으로 떨어져 internal_error 로 - // 세지므로, 매핑 누락이 "우리가 이름을 아는 실패"인 척 묻히지 않는다. - // 표를 when 대신 값으로 둔 이유: 계약과의 전수 대조(ExtractionErrorCatalogTest)가 이 키 집합을 직접 읽는다. - // when 분기는 밖에서 열거할 수 없어 "누락이 else 로 조용히 흡수됐는지"를 기계가 가릴 수 없다. - // - // 어느 예외로 보내는지가 곧 메트릭 reason 이다 — 예외의 errorCode 가 bucket 을 들고 있고(ExtractionFailureCode), - // ItemParsingMetrics.reasonOf 가 그 bucket 을 라벨로 옮긴다. 문구는 여기서 갈리지 않는다(고정 사용자 문구). - // - // 확정은 링크 자체의 사실과 우리 쪽 차단뿐이다. 받은 페이지를 보고 내린 판정(상품 아님·빈 셸·4xx·값 불신)은 - // 계약이 일시로 두므로 여기 오지 않는다 — 그 건은 attempt 소진으로 닫히고 code 별 관측은 extractor 메트릭이 진다. + // when 이 아니라 Map 인 이유: ExtractionErrorCatalogTest 가 키 집합을 열거해 계약과 대조한다. 표에 없는 code 는 internal_error 로 센다 + // 받은 페이지를 보고 내린 판정(상품 아님·빈 셸·4xx·값 불신)은 계약이 일시로 두어 여기 오지 않는다 internal val PERMANENT_TRANSLATIONS: Map BaseException> = mapOf( - // 형식 위반·없는 host — 사용자가 상품 링크가 아닌 걸 넣었다. ExtractionErrorCode.INVALID_URL to { ProductSnapshotException.notProductPage() }, - // 이미지 경로 전용 — 받은 파일을 상품 정보로 쓸 수 없다. ExtractionErrorCode.IMAGE_UNSUPPORTED to { ProductSnapshotException.untrustworthyValue() }, - // 우리 방어(SSRF 가드)가 발동했다 — 정상 흐름이면 우리 경계가 먼저 걸렀어야 한다. ExtractionErrorCode.BLOCKED_HOST to { ProductExtractorException.permanentFailure() }, ) - // 원격 추출 호출 한 건의 전부 — POST 부터 3갈래(2xx 매핑 / 422+code 확정 / 그 외 일시)가 이 함수 안에서 끝난다. - // transport catch 까지 여기 두는 이유: 클라이언트별 복제가 남으면 계약이 진화할 때(타임아웃 구분·status 취급 등) - // 한쪽만 고쳐져 링크·이미지가 조용히 어긋난다 — 클라이언트에는 요청 구성(경로·body)만 남긴다. - // target 은 로그 컨텍스트다. 마스킹된 값만 넘긴다(url 은 safeLogString, key 는 내부 S3 식별자) — raw URL 금지. + // target 에 URL 을 실을 때는 호출자가 따옴표로 감싼다(logfmt) fun postForSnapshot( restClient: RestClient, path: String, request: Any, - link: ProductLink?, target: String, ): ProductSnapshot { val response = @@ -79,48 +54,33 @@ internal object RemoteExtractionContract { } catch (e: RestClientResponseException) { throw translate(e, target) } catch (e: RestClientException) { - // 연결 실패·read timeout(ResourceAccessException)·본문 추출 중 오류 등 transport 장애 — 일시로 본다. throw ProductExtractorException.transientFailure(e) } - return toSnapshot(response, link, target) + return toSnapshot(response, target) } - // 2xx 응답 → ProductSnapshot. extractor 는 2xx 로 name·imageUrl·currentPrice 중 **하나 이상**을 보장한다 - // (자기 쪽 응답 경계가 하나도 못 건졌을 때만 422 UNTRUSTWORTHY_VALUE 로 닫는다, extractor#37). - // 그래서 부분값 2xx 는 계약 위반이 아니라 정상 결과이고, 그것을 READY/INCOMPLETE/FAILED 로 가르는 판정은 - // 경계가 아니라 도메인(ItemSnapshot.markExtracted, #944)이 쥔다 — 여기서 세 필드를 다 요구하면 부분값이 - // INCOMPLETE 로 가는 길 자체가 닫혀 재시도 후 FAILED 로 끝난다(#950 이 고친 것이 정확히 그 상태다). - // 남은 가드는 "계약이 깨진 2xx" 하나다: 셋 다 빈 응답은 extractor 가 422 로 닫았어야 할 것이라 일시 실패로 - // 걸러 재시도한다 — 원인이 "원격 계약 위반"으로 boundary 로그에 또렷이 남는다. - // raw 응답 필드를 본다: "값은 있으나 우리가 정규화로 떨구는" 경우(non-https imageUrl 등)는 여기가 아니라 - // fromExtracted 소관이라, 그건 스냅샷에 null 로 흘러 markExtracted 가 최종 판정한다(정규화가 남긴 값이 - // 일부면 INCOMPLETE, 전부 떨구면 FAILED). 현재 extractor 는 결과 URL 을 https 로 하드코딩 생성하므로 - // non-https 2xx 는 구성 불가능하다 — extractor 가 CDN 등 결과 URL 출처를 바꾸면 이 가드를 재검토한다. + // extractor 는 2xx 에 name·imageUrl·currentPrice 중 하나 이상을 보장한다(extractor#37). 셋 다 비면 계약 위반이라 일시 실패로 재시도한다 private fun toSnapshot( response: ExtractionResult?, - link: ProductLink?, target: String, ): ProductSnapshot { response ?: throw ProductExtractorException.transientFailure(null) if (response.hasNoExtractedValue()) throw contractViolation(target) - return response.toProductSnapshot(link) + return response.toProductSnapshot() } - // 이 응답이 2xx 로 온 것 자체가 계약 위반이다. 판정 기준의 정본은 ItemSnapshot.extractedFields 다. private fun ExtractionResult.hasNoExtractedValue(): Boolean = !(hasName() && name.isNotBlank()) && !hasImageUrl() && !hasCurrentPrice() - // fromExtracted 를 반드시 경유한다 — 원격이 정상 값을 보장해도 신뢰 경계를 넘어온 값은 다시 검증한다(다층 방어). - // has* 로 가르는 이유는 JSON 생략과 null 을 같게 읽기 위해서다. 모르는 method 는 UNSPECIFIED 라 기록하지 않는다. - private fun ExtractionResult.toProductSnapshot(link: ProductLink?): ProductSnapshot = + // has* 는 JSON 생략과 null 을 같게 읽는다 + private fun ExtractionResult.toProductSnapshot(): ProductSnapshot = ProductSnapshot.fromExtracted( - link = link, name = if (hasName()) name else null, imageUrl = if (hasImageUrl()) imageUrl else null, price = if (hasCurrentPrice()) currentPrice else null, currency = if (hasCurrency()) currency else null, finalUrl = if (hasFinalUrl()) finalUrl else null, - extractionMethod = method.takeIf { it != ExtractionMethod.EXTRACTION_METHOD_UNSPECIFIED }?.name, + method = method, ) private fun contractViolation(target: String): ProductExtractorException { @@ -128,27 +88,19 @@ internal object RemoteExtractionContract { return ProductExtractorException.transientFailure(null) } - // 계약 3갈래 번역: 422 만 확정 실패, 그 외 status 는 전부 일시(fail-safe — recover 상한이 재시도를 바운드). - // **전이 판정은 여전히 status 만 본다** — code 는 그 확정 실패를 무엇이라 부르고 어떻게 셀지(bucket)만 가른다. - // 그래서 모르는 code 도 확정 실패라는 결론은 같고(tolerant reader, 계약 §1), 다만 internal_error 로 세어 - // "매핑이 뒤처졌다"가 지표에 드러난다. private fun translate( e: RestClientResponseException, target: String, ): BaseException { if (!e.statusCode.isSameCodeAs(HttpStatus.UNPROCESSABLE_ENTITY)) { - // 예외는 원격 status 를 싣지 않아 여기서 남기지 않으면 base-url 404·인증 401 이 일시 실패로만 보임 log.warn("remote extract transient status={} {}", e.statusCode.value(), target) return ProductExtractorException.transientFailure(e) } - // 모르는 code 문자열은 파서가 무시해 UNSPECIFIED 로 읽힌다 — 표에 없으니 아래 fallback 으로 간다. + // 모르는 code 문자열은 파서가 UNSPECIFIED 로 읽어 표에서 빠진다 val code = runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code } .getOrNull() - // code 는 관측·디버깅용(계약 §1) — 응답엔 노출되지 않고 로그로만 남긴다. 확정 실패는 계약상 정상 결과라 info. - // 원문 code 는 이 줄에만 남는다: 메트릭은 bucket 단위라(카디널리티) 개별 code 추적은 로그가 진다. log.info("remote extract permanent code={} {}", code, target) - // 표에 없는 code — 이 바이너리보다 새 extractor 가 사유를 늘렸거나 body 가 깨진 경우. 확정 실패인 건 같다. val translation = PERMANENT_TRANSLATIONS[code] ?: return ProductExtractorException.permanentFailure() return translation() } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt index 834a02c40..b591aa9b9 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt @@ -22,12 +22,16 @@ data class RemoteExtractionProperties( // 0/음수는 HttpURLConnection 에서 '무한 타임아웃'이라, 상한 검사만 있으면 워커 스레드가 영구 블록될 수 있다. require(readTimeoutMs > 0) { "read-timeout($readTimeoutMs ms)은 양수여야 한다 — 0 은 무한 대기다." } require(readTimeoutMs < STALE_TIMEOUT_MS) { - "read-timeout($readTimeoutMs ms)은 회수 stale 판정(60s)보다 작아야 한다 — 중복 발주 방지." + "read-timeout($readTimeoutMs ms)은 작업 큐 stale 판정(60s)보다 작아야 한다 — recover 유령 중복 발주 방지." } } companion object { - // TODO: 4단계 회수 도입 시 그쪽 stale 판정 상수와 묶음 + // ItemParsingScheduler.STALE_TIMEOUT_SECONDS(60L, 초) 를 ms 로 옮긴 복제값. 스케줄러 상수를 직접 참조하면 + // 순환 의존(item→product 가 이미 있는데 product→item 을 더하는 꼴)이라 값을 복제하고 주석으로 결속한다. + // 한계: 이 결속은 기계 강제가 아니라 주석이라, 스케줄러 쪽 60L 을 바꾸면 여기 60_000 도 함께 봐야 한다 + // (한쪽만 바꾸면 유령 중복 방지 불변식이 조용히 깨진다). 두 값을 공용 상수로 올리는 건 item↔product 패키지 + // 경계를 건드리는 별도 작업이라 복제+주석으로 둔다. private const val STALE_TIMEOUT_MS = 60_000 } } diff --git a/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt b/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt index 920c906d2..2844bbf88 100644 --- a/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt +++ b/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt @@ -43,9 +43,8 @@ class DbSourcePlatformResolver( override fun resolve(link: ProductLink?): String? { link ?: return null - val host = link.normalizedHost() ?: return null return platforms.firstOrNull { link.matchesAnyDomain(it.domains) }?.displayName - ?: SourcePlatformFallback.of(host) + ?: SourcePlatformFallback.of(link.normalizedHost()) } private class DomainDisplayName( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt index fec3b2b12..227a5b7f1 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -127,7 +127,7 @@ interface TournamentApi { sourceTournamentId: **deprecated.** 같은 이유로 항상 null 이라 응답에서 키가 생략된다. 그룹 결과는 응답의 tournamentId 로 호출한다. 응답의 status 필드에 따라 포함되는 데이터가 달라진다. - PENDING: pending 필드 (아이템 목록, 참여자 목록) - - 각 아이템에 status 포함 (READY / PENDING / PROCESSING / FAILED). PENDING·PROCESSING 이면 name·price·imageUrl 은 null 이라 응답에서 제외됨 + - 각 아이템에 status 포함 (PARSING / FAILED / INCOMPLETE / READY). PARSING·FAILED 이면 itemId·name·price·currency·imageUrl 은 null 이라 응답에서 제외됨. INCOMPLETE 는 일부만 있음 - 각 아이템에 userId 포함 (해당 아이템을 담은 참여자의 userId) - 각 참여자에 itemCount 포함 (해당 참여자가 이 토너먼트에 담은 아이템 수) - 각 참여자에 isHost 포함 (이 토너먼트를 만든 주최자면 true). 클라이언트는 이 값으로 프로필 우측 하단에 HostBadge 를 그린다. @@ -532,7 +532,7 @@ interface TournamentApi { ApiResponse( responseCode = "409", description = - "상태 충돌 (PENDING이 아닌 토너먼트 · PROCESSING/FAILED 상품 포함 · " + + "상태 충돌 (PENDING이 아닌 토너먼트 · PARSING/FAILED 상품 포함 · " + "값이 비어 있는 미완성 상품 포함 · 가격 정보 없는 상품 포함 · " + "주최자가 아직 시작하지 않음 — 마지막은 멤버 시작 경로)", content = [ diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt index c2e865fe3..ebe61cecc 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -247,7 +247,6 @@ class TournamentApiExamples( add(TournamentException.itemNotReadyToStart(), name = "아직 준비 중인 상품 포함") add(TournamentException.itemIncompleteToStart(), name = "정보가 일부만 채워진(INCOMPLETE) 상품 포함") add(TournamentException.itemPriceRequired(), name = "가격 정보 없는 상품 포함") - // 멤버 시작 경로(startAsMember) — 주최자가 시작해야 멤버가 자기 판을 시작할 수 있다. add(TournamentException.notInProgressTournament(), name = "주최자가 아직 시작하지 않음 (멤버 시작)") } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt index e08781391..1d5def63c 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.image.controller.dto.ConfirmImageUploadRequest import com.depromeet.piki.image.controller.dto.PresignedImageUploadRequest import com.depromeet.piki.image.controller.dto.PresignedImageUploadResponse +import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.tournament.controller.dto.AddTournamentItemFromLinkRequest import com.depromeet.piki.tournament.controller.dto.AddTournamentItemFromLinkResponse import com.depromeet.piki.tournament.controller.dto.AddTournamentItemsFromImagesResponse @@ -47,12 +48,12 @@ interface TournamentItemApi { description = """ 토너먼트 아이템의 상세 정보와 파싱 상태를 조회한다. 링크·이미지로 아이템을 추가하면 비동기 파싱이 진행되므로, - 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 PENDING → PROCESSING → READY/FAILED 전환(완료·실패 알림)을 통보받고, 이 조회로 최신 status 와 값을 확인한다. - - PENDING: URL·이미지 등록 접수 후 파싱 대기 (디스패처가 집기 전, name·price·imageUrl 은 null). - - PROCESSING: 파싱 진행 중 (name·price·imageUrl 은 null) - - READY: 파싱 완료 (모든 필드 채워짐) - - FAILED: 파싱 실패 (상품 페이지 아님 또는 추출 불가) - sourceUrl: 등록 시 입력한 원본 링크. 이미지로 등록한 경우 null. + 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 완료·실패를 통보받고, 이 조회로 최신 status 와 값을 확인한다. + - PARSING: 상품 확정 전 파싱 중 (itemId·name·price·imageUrl 은 null) + - READY: 이름·가격·이미지가 모두 있음 + - INCOMPLETE: 일부만 있음 (사용자 입력으로 채우면 READY) + - FAILED: 상품을 확정하지 못하고 파싱 실패 (상품 페이지 아님 또는 추출 불가) + sourceUrl: 상품 페이지 링크. 파싱 성공 전에는 등록 때 입력한 링크, 성공 뒤에는 확정된 상품 페이지 주소. 이미지로 등록한 경우 null. memo: 이 상품이 **요청자 본인의 위시리스트에 있으면** 그 위시의 개인 메모(최대 100자). 누가 토너먼트에 추가했는지와 무관하게 본인 위시 기준으로 판정하며, 본인 메모만 내려간다(타인 메모는 어떤 경우에도 없음). 위시에 없거나 메모를 안 적었거나 게스트면 필드 자체가 생략된다. @@ -173,7 +174,7 @@ interface TournamentItemApi { responseCode = "409", description = "상태 충돌 (PENDING이 아닌 토너먼트 · 이미 등록된 아이템 · 요청 내 중복 아이템 · " + - "PENDING/PROCESSING/FAILED 등 미완료 상품 포함 · 값이 비어 있는 미완성 상품 포함)", + "값이 비어 있는 미완성 상품 포함)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -194,16 +195,18 @@ interface TournamentItemApi { description = """ PENDING 상태의 토너먼트에 URL 링크를 통해 아이템을 추가한다. 토너먼트 참여자만 추가할 수 있다. - 아이템이 PENDING 상태로 즉시 생성되어 tournamentItemId 가 반환된다. - 파싱은 비동기로 진행되며, PENDING 에서 READY·INCOMPLETE·FAILED 상태로 전환된다. - 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 파싱 완료·실패를 통보받고, tournamentItemId 로 GET /tournaments/{id}/items/{tournamentItemId} 를 조회해 결과를 확인한다. + 토너먼트 후보가 파싱 중 카드로 즉시 생성되어 tournamentItemId 가 반환된다. 상품은 파싱 성공이나 사용자 입력으로 확정된다. + 파싱은 비동기로 진행되며, PARSING 에서 READY·INCOMPLETE·FAILED 상태로 전환된다. + 확정된 상품을 같은 토너먼트의 다른 후보가 이미 갖고 있으면 이 후보는 그 후보에 합쳐져 사라지고(ITEM_PARSING_MERGED 알림, + 참여자에게 TOURNAMENT_ITEM_MERGED 신호), 이 tournamentItemId 로 조회하면 404 다. + 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 파싱 결과를 통보받고, tournamentItemId 로 GET /tournaments/{id}/items/{tournamentItemId} 를 조회해 결과를 확인한다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "아이템 추가 성공 (item.status=PENDING, 파싱은 백그라운드)", + description = "토너먼트 후보 추가 성공 (파싱은 백그라운드)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -214,7 +217,7 @@ interface TournamentItemApi { ApiResponse( responseCode = "400", description = - "잘못된 요청 (URL 미입력 · URL 2048자 초과 — code: COMMON-INVALID-INPUT · " + + "잘못된 요청 (URL 미입력 · URL ${ProductLink.MAX_LENGTH}자 초과 — code: COMMON-INVALID-INPUT · " + "URL 형식 오류 — code: LINK-001(유효하지 않음)/LINK-002(https 외 스킴) · " + "지원하지 않는 쇼핑몰 — code: LINK-003 · 아이템 최대 32개 초과)", content = [ @@ -438,16 +441,17 @@ interface TournamentItemApi { @Operation( summary = "이미지로 토너먼트 아이템 추가 v2 - 업로드 확정", description = """ - 이미지 등록의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PENDING 아이템으로 즉시 추가하고 tournamentItemIds 를 반환한다. - key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 링크 추가와 같은 작업 큐에 적재하며, 이후 파싱(Gemini Vision)·전이(READY/FAILED) 흐름도 같다. - 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 파싱 완료·실패를 통보받고, tournamentItemId 로 GET /tournaments/{id}/items/{tournamentItemId} 를 조회해 결과를 확인한다. + 이미지 등록의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 파싱 중인 토너먼트 후보로 즉시 추가하고 tournamentItemIds 를 반환한다. + key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 링크 추가와 같은 작업 큐에 적재하며, 이후 파싱·전이(READY/INCOMPLETE/FAILED)와 합침 흐름도 같다. + 이 토너먼트에 남아 있는 후보로 이미 확정한 imageKey 는 다시 추가하지 않고 응답에서도 빠진다. 그래서 요청보다 적은 id 나 빈 배열이 올 수 있다. + 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 파싱 결과를 통보받고, tournamentItemId 로 GET /tournaments/{id}/items/{tournamentItemId} 를 조회해 결과를 확인한다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "아이템 추가 성공 (item.status=PENDING, 파싱은 백그라운드)", + description = "토너먼트 후보 추가 성공 (파싱은 백그라운드)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -526,21 +530,26 @@ interface TournamentItemApi { ): ApiResponseBody @Operation( - summary = "토너먼트 아이템 수정", + summary = "토너먼트 아이템 사용자 입력", description = """ - 파싱 실패(FAILED) 상태인 토너먼트 아이템을 유저가 직접 보정한다. - 수정 성공 시 아이템 상태가 FAILED → READY 로 전환된다. - 수정 가능 필드: 이름, 가격, 가격 단위, 이미지(multipart/form-data 의 image 파트) — null 이면 기존 값 유지. - 이미지는 파일로 업로드하며 서버가 S3 에 저장한 URL 로 item.imageUrl 을 갱신한다. - READY·PENDING·PROCESSING 아이템은 수정 불가(409). 아이템을 등록한 본인만 수정 가능. - 이름은 수정 후에도 반드시 존재해야 한다 — 기존 이름이 없고 name 도 미입력이면 400. + 토너먼트 아이템의 상품 정보를 등록한 본인이 직접 입력한다(multipart/form-data). + 입력 필드: 이름, 가격, 가격 단위, 이미지(image 파트). 이미지는 파일로 업로드하며 서버가 S3 에 저장한 URL 이 대표 이미지가 된다. + 입력은 기존 버전을 고치지 않고 사용자 입력 새 버전으로 쌓이며, 이 아이템의 표시값이 그 버전으로 바뀐다. + + **보내지 않은 필드는 지금 보이는 값을 이어 쓴다.** 합친 결과에 이름·가격·이미지가 모두 있어야 하고, + 하나라도 빠지면 400 이다. 그래서 아이템 상태에 따라 보내야 하는 필드가 다르다. + - READY: 바꿀 필드만 + - INCOMPLETE: 바꿀 필드와 비어 있는 필드 + - FAILED: 이름·가격·이미지 전부 (이어 쓸 값이 없다) + - PARSING: 받지 않는다 (409, code: ITEM-008). 결과가 오기 전에 입력하면 뒤늦게 끝난 파싱과 엇갈린다. + 클라이언트는 이 동안 입력 화면을 막는다고 전제하고, 409 는 그 사이 상태가 바뀐 경합을 막는다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "수정 성공 (FAILED → READY 전환)", + description = "입력 성공 (사용자 입력 새 버전이 이 아이템의 표시값이 됨)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -551,8 +560,8 @@ interface TournamentItemApi { ApiResponse( responseCode = "400", description = - "잘못된 요청 (이름 없이 수정 시도 — code: ITEM-003 · 가격 없이 READY 전환 — code: ITEM-004 · " + - "이미지 없이 READY 전환 — code: ITEM-005 · 이름 1자 미만 512자 초과 · 가격 음수 · 가격단위 8자 초과 · " + + "잘못된 요청 (바꿀 값이 하나도 없음 — code: ITEM-009 · 합친 뒤에도 상품명 없음 — code: ITEM-003 · 합친 뒤에도 가격 없음 — code: ITEM-004 · " + + "합친 뒤에도 이미지 없음 — code: ITEM-005 · 이름 1자 미만 512자 초과 · 가격 음수 · 가격단위 8자 초과 · " + "빈 이미지 — code: PRODUCTIMAGE-001 · 이미지 타입 미지정 — code: PRODUCTIMAGE-002 · " + "지원하지 않는 이미지 형식(png/jpeg/webp/heic/heif만 허용) — code: PRODUCTIMAGE-003 · " + "요청 값 형식 검증 실패 — COMMON-INVALID-INPUT)", @@ -596,7 +605,7 @@ interface TournamentItemApi { ApiResponse( responseCode = "409", description = - "상태 충돌 (PENDING이 아닌 토너먼트)", + "상태 충돌 (PENDING이 아닌 토너먼트 · 파싱 중인 아이템에 상품 정보 입력 — code: ITEM-008)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt index d6f7db15c..41395ab06 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt @@ -1,6 +1,5 @@ package com.depromeet.piki.tournament.controller -import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.common.exception.AlreadyRegisteredException import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.openapi.OpenApiObjectMapper @@ -13,6 +12,7 @@ import com.depromeet.piki.image.controller.dto.PresignedImageUpload import com.depromeet.piki.image.controller.dto.PresignedImageUploadResponse import com.depromeet.piki.image.domain.ImageUploadException import com.depromeet.piki.image.domain.ProductImageException +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.domain.ItemException import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.product.domain.ProductLinkException @@ -70,7 +70,6 @@ class TournamentItemApiExamples( add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") // 다건 경로라 가리킬 아이템 하나를 고를 수 없다 — 사유만 내려간다(TournamentException 주석 참고). add(TournamentException.duplicateTournamentItem(), name = "이미 등록된/중복 아이템") - add(TournamentException.itemNotReady(), name = "PENDING/PROCESSING/FAILED 등 미완료 상품 포함") add(TournamentException.itemIncomplete(), name = "정보가 일부만 채워진(INCOMPLETE) 상품 포함") } @@ -155,12 +154,14 @@ class TournamentItemApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.OK, - name = "수기 수정 성공 (MANUAL 새 버전으로 교체)", + name = "사용자 입력 성공 (새 버전이 표시값이 됨)", payload = ApiResponseBody.ok(), ) - add(ItemException.nameRequiredForReady(), name = "병합 후에도 상품명 없음 (빈 항목에 일부 필드만 수정)") - add(ItemException.priceRequiredForReady(), name = "병합 후에도 가격 없음") - add(ItemException.imageRequiredForReady(), name = "병합 후에도 이미지 없음") + add(ItemException.emptyUserInput(), name = "바꿀 값이 하나도 없음") + add(ItemException.nameRequiredForReady(), name = "합친 뒤에도 상품명 없음 (FAILED 항목에 일부 필드만 입력)") + add(ItemException.priceRequiredForReady(), name = "합친 뒤에도 가격 없음") + add(ItemException.imageRequiredForReady(), name = "합친 뒤에도 이미지 없음") + add(ItemException.parsingInProgress(), name = "파싱 중인 항목") // ProductImage.of 의 형식 검증 3종 — S3 업로드 전에 동기로 거른다. add(ProductImageException.emptyImage(), name = "빈 이미지 파일") add(ProductImageException.unknownType(), name = "이미지 형식을 확인할 수 없음") @@ -228,35 +229,18 @@ class TournamentItemApiExamples( ) add( status = HttpStatus.OK, - name = "PENDING - 파싱 대기 (URL 등록 직후)", + name = "PARSING - 파싱 중 (URL 등록 직후)", payload = ApiResponseBody.ok( TournamentItemDetailResponse( tournamentItemId = 10, - itemId = 100, + itemId = null, sourceUrl = "https://www.nike.com/kr/t/air-max/example", name = null, imageUrl = null, price = null, currency = null, - status = ItemStatus.PENDING, - ), - ), - ) - add( - status = HttpStatus.OK, - name = "PROCESSING - 파싱 진행 중", - payload = - ApiResponseBody.ok( - TournamentItemDetailResponse( - tournamentItemId = 10, - itemId = 100, - sourceUrl = "https://www.nike.com/kr/t/air-max/example", - name = null, - imageUrl = null, - price = null, - currency = null, - status = ItemStatus.PROCESSING, + status = ItemStatus.PARSING, ), ), ) @@ -267,7 +251,7 @@ class TournamentItemApiExamples( ApiResponseBody.ok( TournamentItemDetailResponse( tournamentItemId = 10, - itemId = 100, + itemId = null, sourceUrl = "https://www.nike.com/kr/t/air-max/example", name = null, imageUrl = null, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt index 508d6e769..4a74042f9 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt @@ -49,7 +49,7 @@ class TournamentItemController( @PathVariable tournamentId: Long, @Valid @RequestBody request: AddTournamentItemsRequest, ): ApiResponseBody { - val tournamentItemIds = tournamentService.addItemsFromWish(userId, request.toAddTournamentItemsFromWish(tournamentId)) + val tournamentItemIds = tournamentItemService.addItemsFromWish(userId, request.toAddTournamentItemsFromWish(tournamentId)) return ApiResponseBody.ok(AddTournamentItemsFromWishResponse(tournamentItemIds)) } @@ -81,7 +81,7 @@ class TournamentItemController( @PathVariable tournamentId: Long, @RequestBody request: ConfirmImageUploadRequest, ): ApiResponseBody { - val tournamentItemIds = tournamentItemService.confirmImageRegistration(userId, tournamentId, request.imageKeys) + val tournamentItemIds = tournamentItemService.confirmImageRegistration(userId, tournamentId, request.toImageKeys()) return ApiResponseBody.ok(AddTournamentItemsFromImagesResponse(tournamentItemIds)) } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemFromLinkRequest.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemFromLinkRequest.kt index d197fb3a3..64d44f8ac 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemFromLinkRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemFromLinkRequest.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.tournament.controller.dto +import com.depromeet.piki.product.domain.ProductLink import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema.RequiredMode import jakarta.validation.constraints.NotBlank @@ -7,7 +8,7 @@ import jakarta.validation.constraints.Size data class AddTournamentItemFromLinkRequest( @field:NotBlank(message = "링크를 입력해 주세요.") - @field:Size(max = 2048, message = "링크가 너무 길어요.") + @field:Size(max = ProductLink.MAX_LENGTH, message = "링크가 너무 길어요.") @field:Schema( description = "등록할 상품 페이지 URL", example = "https://www.example-shop.com/products/12345", diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemsRequest.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemsRequest.kt index 6dda0add7..196c36509 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemsRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/AddTournamentItemsRequest.kt @@ -16,6 +16,6 @@ data class AddTournamentItemsRequest( // Bean Validation 위반 메시지의 single source. OpenAPI example(TournamentItemApiExamples)이 같은 상수를 참조해 // "필드 검증 문구가 @field 와 example 두 곳에서 따로 노는" 어긋남을 컴파일 타임에 막는다. companion object { - const val ITEM_IDS_SIZE_MESSAGE = "아이템은 1~32개 사이로 선택해 주세요." + const val ITEM_IDS_SIZE_MESSAGE = "상품은 1~32개 사이로 선택해 주세요." } } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt index d3390371e..0ee1134eb 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt @@ -25,7 +25,7 @@ data class TournamentDetailResponse( @JsonInclude(JsonInclude.Include.NON_NULL) data class ItemDetailResponse( val tournamentItemId: Long, - val itemId: Long, + val itemId: Long?, val userId: UUID, val name: String?, val price: Int?, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentItemDetailResponse.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentItemDetailResponse.kt index e1f2fa2e6..5df3f95d2 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentItemDetailResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentItemDetailResponse.kt @@ -7,7 +7,7 @@ import com.fasterxml.jackson.annotation.JsonInclude @JsonInclude(JsonInclude.Include.NON_NULL) data class TournamentItemDetailResponse( val tournamentItemId: Long, - val itemId: Long, + val itemId: Long?, val sourceUrl: String?, val name: String?, val imageUrl: String?, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt b/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt index 77667a5a6..8d29eb067 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt @@ -12,11 +12,15 @@ import kotlin.random.Random @Entity @Table(name = "tournaments") class Tournament( + ownerTournamentUserId: Long, + @Column(name = "name", nullable = false) val name: String, + @Column(name = "invite_code", nullable = false, length = 6) val inviteCode: String, + inviteExpiresAt: LocalDateTime, @Enumerated(value = EnumType.STRING) @@ -27,18 +31,18 @@ class Tournament( @Column(name = "source_tournament_id") val sourceTournamentId: Long? = null, ) : LongBaseEntity() { - // open class에서 private set이 금지되므로 backing field로 캡슐화한다. - // Hibernate는 field access로 직접 접근하고, 외부에서는 getter만 노출된다. // 방장은 userId 가 아니라 참여 행 id 로 가리킨다 — 이유는 TournamentUser 상단 주석. - @Column(name = "owner_tournament_user_id", nullable = false) - private var _ownerTournamentUserId: Long = ownerTournamentUserId - - val ownerTournamentUserId: Long get() = _ownerTournamentUserId + @Column(nullable = false) + var ownerTournamentUserId: Long = ownerTournamentUserId + protected set - @Column(name = "invite_expires_at", nullable = false) - private var _inviteExpiresAt: LocalDateTime = inviteExpiresAt + @Column(nullable = false) + var inviteExpiresAt: LocalDateTime = inviteExpiresAt + protected set - val inviteExpiresAt: LocalDateTime get() = _inviteExpiresAt + @Column + var startedAt: LocalDateTime? = null + protected set @Column(name = "play_link_expires_at") var playLinkExpiresAt: LocalDateTime? = null @@ -51,12 +55,14 @@ class Tournament( val activeInviteCode: String? = null fun assignOwner(tournamentUserId: Long) { - _ownerTournamentUserId = tournamentUserId + ownerTournamentUserId = tournamentUserId } + // 출전 값은 이 시각까지의 버전으로 계산한다. 호출부가 출전 아이템 행을 잠근 뒤 불러야 버전 생성과 순서가 맞는다 fun start() { check(isPending()) { "start는 PENDING 상태에서만 호출 가능" } this.status = TournamentStatus.IN_PROGRESS + this.startedAt = LocalDateTime.now() } fun complete() { @@ -88,7 +94,7 @@ class Tournament( fun updateInviteExpiry(newExpiresAt: LocalDateTime) { check(isPending()) { "updateInviteExpiry는 PENDING 상태에서만 호출 가능" } - _inviteExpiresAt = newExpiresAt + inviteExpiresAt = newExpiresAt } fun isInviteValid(): Boolean = LocalDateTime diff --git a/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentItem.kt b/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentItem.kt index aa8a13ca8..081b48e3e 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentItem.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentItem.kt @@ -1,45 +1,55 @@ package com.depromeet.piki.tournament.domain import com.depromeet.piki.common.domain.LongBaseEntity +import com.depromeet.piki.item.domain.CardQuery +import com.depromeet.piki.item.domain.ItemSlot import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.Table import java.time.LocalDateTime import java.util.UUID -// 토너먼트에 올라간 아이템. 게스트·회원이 추가한 item 을 토너먼트가 참조하는 연결 엔티티. -// userId 는 이 아이템을 토너먼트에 추가한 주체 (게스트·회원 모두 수용). +// 토너먼트에 올라간 아이템. userId 는 올린 사람(게스트·회원). 파싱이 성공하기 전엔 아이템이 비어 있다 @Entity @Table(name = "tournament_items") class TournamentItem( - @Column(name = "tournament_id", nullable = false) + + @Column(nullable = false) val tournamentId: Long, - @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + + @Column(nullable = false, columnDefinition = "BINARY(16)") val userId: UUID, - snapshotId: Long, + + itemId: Long?, + ) : LongBaseEntity() { - // 출전 시점 고정 snapshot 참조. raw Long(FK 없음). 위시 갱신과 무관하게 고정돼 토너먼트 공정성을 지킨다. - // item 정체성은 snapshot.itemId 단일 출처 — tournament_item 은 itemId 를 따로 들지 않고 snapshot 으로 도달한다. - // 고정의 유일한 예외는 수기 수정(repinSnapshot) — adder 가 의도적으로 만든 MANUAL 새 버전으로 pin 을 옮긴다. - @Column(name = "snapshot_id", nullable = false) - var snapshotId: Long = snapshotId + @Column + var itemId: Long? = itemId protected set - // 엔티티 불변식 — 0·음수는 존재할 수 없는 참조다. 정상 흐름에선 닿지 않고, 닿으면 코드 버그. init { require(tournamentId > 0) { "tournamentId 는 양수여야 한다: $tournamentId" } - require(snapshotId > 0) { "snapshotId 는 양수여야 한다: $snapshotId" } + itemId?.let { require(it > 0) { "itemId 는 양수여야 한다: $it" } } } - fun softDelete() { - deletedAt = LocalDateTime.now() + val slot: ItemSlot get() = ItemSlot.tournamentItem(getId()) + + // 토너먼트가 시작되면 토너먼트 아이템의 버전은 고정된다 + fun cardQuery(tournament: Tournament): CardQuery { + require(tournament.getId() == tournamentId) { "토너먼트 아이템 ${getIdOrNull()} 은 토너먼트 ${tournament.getId()} 의 출전이 아니다" } + return CardQuery(slot = slot, itemId = itemId, owner = userId, asOf = tournament.startedAt) + } + + fun fill(itemId: Long) { + this.itemId?.let { + check(it == itemId) { "토너먼트 아이템 ${getIdOrNull()} 은 이미 아이템 $it 로 확정됐다" } + return + } + require(itemId > 0) { "itemId 는 양수여야 한다: $itemId" } + this.itemId = itemId } - // 수기 수정(#825 결정 4)의 pin 이동 — 기계 버전은 불변이라 수정은 MANUAL 새 버전으로 쌓이고, 출전 카드가 - // 그 버전을 보려면 pin 을 옮겨야 한다. "출전 시점 고정"의 의도는 위시 갱신 등 남의 변경으로부터의 격리인데, - // 이 이동은 adder 본인의 의도적 수정이라 그 격리를 깨지 않는다. - fun repinSnapshot(snapshotId: Long) { - require(snapshotId > 0) { "snapshotId 는 양수여야 한다: $snapshotId" } - this.snapshotId = snapshotId + fun softDelete() { + deletedAt = LocalDateTime.now() } } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentItemDeleted.kt b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentItemDeleted.kt index 36794e3f7..7a4f8994b 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentItemDeleted.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentItemDeleted.kt @@ -5,11 +5,10 @@ import java.util.UUID // 토너먼트 아이템 삭제 — 도메인 사실. actorId 는 삭제한 사용자(아이템 등록자 본인 또는 토너먼트 주최자). // tournamentItemId 는 빠진 출전 아이템(클라가 그 항목만 제거하도록 알림 payload 에 실린다). -// snapshotId 는 그 아이템의 고정 snapshot — 알림 도메인이 여기서 상품명을 해석한다. tournament_item 은 삭제 직후 -// soft delete 라 핸들러(AFTER_COMMIT)가 역조회로 못 닿지만, snapshot 은 공유 immutable 행이라 살아 있다. +// snapshotId 는 삭제 순간 보이던 버전. 파싱 전이면 없다. 알림이 여기서 상품명을 읽는다 data class TournamentItemDeleted( val tournamentId: Long, val tournamentItemId: Long, - val snapshotId: Long, + val snapshotId: Long?, val actorId: UUID, ) : NotificationEvent diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt index 71b68b27e..28dfa1ce6 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt @@ -24,7 +24,6 @@ interface TournamentHistoryJpaRepository : JpaRepository { fun countByTournamentIdAndDeletedAtIsNull(tournamentId: Long): Int fun findByIdAndDeletedAtIsNull(id: Long): TournamentItem? + // 유니크에서 빠진 옛 출전은 같은 아이템으로 여럿 살아 있을 수 있다 + fun findFirstByTournamentIdAndItemIdAndDeletedAtIsNullOrderByIdAsc( + tournamentId: Long, + itemId: Long, + ): TournamentItem? + @Query("SELECT t FROM TournamentItem t WHERE t.tournamentId = :tournamentId AND t.deletedAt IS NULL ORDER BY t.id ASC") fun findAllByTournamentIdAndNotDeleted(@Param("tournamentId") tournamentId: Long): List @@ -26,54 +33,61 @@ interface TournamentItemJpaRepository : JpaRepository { @Query("SELECT t.id FROM TournamentItem t WHERE t.tournamentId = :tournamentId AND t.deletedAt IS NULL ORDER BY t.id ASC") fun findIdsByTournamentId(@Param("tournamentId") tournamentId: Long): List - // 수기 수정의 조회→검사→새 버전 저장→pin 이동 흐름을 행 락으로 직렬화한다(WishJpaRepository.findByIdForUpdate 와 같은 결). - // 락 없이는 동시 수정 두 건이 같은 pin 을 읽고 나중 커밋이 먼저 만든 MANUAL 버전 포인터를 덮어써 유령 버전이 남는다. + // 아이템이 비어 있는 카드를 사용자 입력과 파싱 성공이 동시에 확정하면 아이템이 둘 생기므로 행 락으로 직렬화한다 @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT t FROM TournamentItem t WHERE t.id = :id AND t.deletedAt IS NULL") fun findByIdForUpdate(@Param("id") id: Long): TournamentItem? - // 이 버전(snapshot)을 pin 해 올린 사람들(adder). 파싱 알림 수신자 역조회 — 파싱 사실의 주체가 버전이라 - // 버전으로 직결한다(#576): 같은 item 의 다른 버전을 pin 한 출전(과거 버전·갱신 전 버전)은 이 파싱의 수신자가 아니다. - // 공유(#825)로 한 버전이 여러 출전에 pin 되면 각 adder 가 받는다(DISTINCT). + // 해소 통지(#1028) 후보. 시작한 토너먼트는 시작 시각 기준으로 값이 고정돼 채워질 카드가 없다 @Query( - "SELECT DISTINCT t.userId FROM TournamentItem t " + - "WHERE t.snapshotId = :snapshotId AND t.deletedAt IS NULL", + "SELECT t.userId AS userId, t.tournamentId AS tournamentId, t.id AS tournamentItemId " + + "FROM TournamentItem t, Tournament tour " + + "WHERE t.itemId = :itemId AND t.deletedAt IS NULL " + + "AND tour.id = t.tournamentId AND tour.status = :status AND tour.deletedAt IS NULL " + + "ORDER BY t.id ASC", ) - fun findUserIdsBySnapshotId(@Param("snapshotId") snapshotId: Long): List + fun findPendingCardsByItemId( + @Param("itemId") itemId: Long, + @Param("status") status: TournamentStatus = TournamentStatus.PENDING, + ): List - // 이 버전(snapshot)을 pin 한 토너먼트 출전 좌표(tournamentId·tournament_item id). 파싱 알림 딥링크·SSE 카드 - // 라우팅 역조회(#408·#576). 버전 직결이라 이벤트 status 가 어느 좌표에서든 참이다 — itemId 라우팅 시절의 - // spurious 갱신(다른 버전 카드에 남의 완료·실패가 전파)이 구조적으로 사라진다. id 오름차순은 결정성용. + // 파싱 결과는 지워진 출전에도 기록한다 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT t FROM TournamentItem t WHERE t.id = :id") + fun findByIdIncludingDeletedForUpdate(@Param("id") id: Long): TournamentItem? + + @Query("SELECT t FROM TournamentItem t WHERE t.id = :id") + fun findByIdIncludingDeleted(@Param("id") id: Long): TournamentItem? + + // 같은 입력으로 파싱을 요청한 적 있는 출전. 한 토너먼트의 출전으로 먼저 좁혀져 입력 인덱스가 필요 없다 @Query( - "SELECT t.tournamentId AS tournamentId, t.id AS tournamentItemId FROM TournamentItem t " + - "WHERE t.snapshotId = :snapshotId AND t.deletedAt IS NULL ORDER BY t.id ASC", + "SELECT t.id FROM TournamentItem t, ItemParseOutbox o " + + "WHERE o.slotType = com.depromeet.piki.item.domain.ItemSlotType.TOURNAMENT_ITEM AND o.slotId = t.id " + + "AND t.tournamentId = :tournamentId AND t.deletedAt IS NULL AND o.normalizedInput = :value", ) - fun findRoutingBySnapshotId(@Param("snapshotId") snapshotId: Long): List + fun findIdsByTournamentIdAndNormalizedInput( + @Param("tournamentId") tournamentId: Long, + @Param("value") normalized: NormalizedInput, + ): List - // 위와 같되 등록자(userId)를 함께 싣는다 — 파싱 알림의 수신자별 토너먼트 딥링크 역조회(#933). - // 한 유저가 같은 버전을 여러 토너먼트에 올렸으면 여러 행이라, id 오름차순으로 결정성만 확보하고 - // 수신자별 해석에서 유저당 첫 좌표를 고른다. @Query( - "SELECT t.userId AS userId, t.tournamentId AS tournamentId, t.id AS tournamentItemId FROM TournamentItem t " + - "WHERE t.snapshotId = :snapshotId AND t.deletedAt IS NULL ORDER BY t.id ASC", + "SELECT t.id FROM TournamentItem t, Item i WHERE i.id = t.itemId AND i.canonicalUrl = :canonical " + + "AND t.tournamentId = :tournamentId AND t.deletedAt IS NULL AND i.deletedAt IS NULL", ) - fun findRoutingsWithUserBySnapshotId(@Param("snapshotId") snapshotId: Long): List + fun findIdsByTournamentIdAndItemCanonical( + @Param("tournamentId") tournamentId: Long, + @Param("canonical") canonical: CanonicalInput, + ): List - // 이 상품을 대기실(PENDING) 토너먼트에 출전시킨 카드(등록자, 토너먼트 좌표, pin) — 해소 통지(#1028) 수신자 후보(#1051). - // 출전은 snapshot 만 참조하므로 pin 의 itemId 로 상품에 도달한다. 시작된 토너먼트는 pin 을 박제해 읽어 파생을 타지 - // 않으므로 후보가 아니다. 최종 판정은 알림 쪽이 표시값 규칙으로 한다. @Query( - "SELECT t.userId AS userId, t.tournamentId AS tournamentId, " + - "t.id AS tournamentItemId, t.snapshotId AS snapshotId " + - "FROM TournamentItem t, ItemSnapshot s, Tournament tour " + - "WHERE t.snapshotId = s.id AND s.itemId = :itemId AND t.deletedAt IS NULL " + - "AND tour.id = t.tournamentId AND tour.status = :status AND tour.deletedAt IS NULL " + - "ORDER BY t.id ASC", + "SELECT o.normalizedInput FROM TournamentItem t, ItemParseOutbox o " + + "WHERE o.slotType = com.depromeet.piki.item.domain.ItemSlotType.TOURNAMENT_ITEM AND o.slotId = t.id " + + "AND t.tournamentId = :tournamentId AND t.deletedAt IS NULL AND o.normalizedInput IN :values", ) - fun findPendingCardsByItemId( - @Param("itemId") itemId: Long, - @Param("status") status: TournamentStatus = TournamentStatus.PENDING, - ): List + fun findRequestedInputsByTournamentId( + @Param("tournamentId") tournamentId: Long, + @Param("values") values: Collection, + ): List @Modifying @Query( @@ -95,7 +109,7 @@ interface TournamentItemJpaRepository : JpaRepository { @Param("now") now: LocalDateTime, ) - // 담은 사람만 갈아끼운다(#1081). 유니크 키가 (tournament_id, item_id)라 user_id 는 키에 없어 충돌하지 않는다. + // 담은 사람만 갈아끼운다(#1081). 유니크 키가 (tournament_id, item_id, live)라 user_id 는 키에 없어 충돌하지 않는다. // 참여가 회원에게 넘어간 방으로 범위를 좁힌다 — 넘어가지 않은 방까지 옮기면 그 방에 없는 사람이 아이템 주인이 된다. @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepository.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepository.kt index 69c51bbe7..eadaa1da7 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepository.kt @@ -1,5 +1,8 @@ package com.depromeet.piki.tournament.repository +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.CanonicalInput +import com.depromeet.piki.item.domain.NormalizedInput import com.depromeet.piki.tournament.domain.TournamentItem import java.util.UUID @@ -12,16 +15,33 @@ interface TournamentItemRepository { fun findIdsByTournamentId(tournamentId: Long): List - // 이 아이템을 토너먼트에 추가한 사람들(adder). 파싱 알림 수신자 역조회. 같은 아이템이 여러 토너먼트에 공유될 수 있다. - // 수기 수정(pin 이동) 직렬화용 행 락 조회. fun findByIdForUpdate(id: Long): TournamentItem? - fun findUserIdsBySnapshotId(snapshotId: Long): List + fun findByIdIncludingDeletedForUpdate(id: Long): TournamentItem? - // 이 아이템의 토너먼트 출전 좌표(어느 토너먼트 / 그 안 어느 tournament_item). 파싱 알림 딥링크 라우팅 역조회(#408). - fun findRoutingBySnapshotId(snapshotId: Long): List + fun findByIdIncludingDeleted(id: Long): TournamentItem? - fun findRoutingsWithUserBySnapshotId(snapshotId: Long): List + fun findIdByTournamentIdAndNormalizedInput( + tournamentId: Long, + normalized: NormalizedInput, + ): Long? + + // 이 토너먼트에 이미 요청된 이미지 키 + fun findRequestedImageKeys( + tournamentId: Long, + keys: Collection, + ): Set + + fun findByTournamentIdAndItemId( + tournamentId: Long, + itemId: Long, + ): TournamentItem? + + // 이 토너먼트의 출전 중 정체 값이 같은 아이템을 담은 것 + fun findIdByTournamentIdAndItemCanonical( + tournamentId: Long, + canonical: CanonicalInput, + ): Long? // 이 상품을 대기실(PENDING) 토너먼트에 출전시킨 카드 — 해소 통지 수신자 후보(#1028·#1051). fun findPendingCardsByItemId(itemId: Long): List @@ -41,7 +61,7 @@ interface TournamentItemRepository { fun softDeleteAllByTournamentId(tournamentId: Long) - // 담은 사람을 옮긴다(#1081). 유니크 키가 (tournament_id, item_id)라 user_id 는 키에 없어 충돌하지 않는다. + // 담은 사람을 옮긴다(#1081). 유니크 키가 (tournament_id, item_id, live)라 user_id 는 키에 없어 충돌하지 않는다. fun transferToUser( fromUserId: UUID, toUserId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepositoryImpl.kt index 2aecaf139..8f83e7a8c 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRepositoryImpl.kt @@ -1,9 +1,12 @@ package com.depromeet.piki.tournament.repository +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.CanonicalInput +import com.depromeet.piki.item.domain.NormalizedInput import com.depromeet.piki.tournament.domain.TournamentItem +import org.springframework.stereotype.Repository import java.time.LocalDateTime import java.util.UUID -import org.springframework.stereotype.Repository @Repository class TournamentItemRepositoryImpl( @@ -21,14 +24,36 @@ class TournamentItemRepositoryImpl( override fun findByIdForUpdate(id: Long): TournamentItem? = tournamentItemJpaRepository.findByIdForUpdate(id) - override fun findUserIdsBySnapshotId(snapshotId: Long): List = - tournamentItemJpaRepository.findUserIdsBySnapshotId(snapshotId) + override fun findByIdIncludingDeletedForUpdate(id: Long): TournamentItem? = + tournamentItemJpaRepository.findByIdIncludingDeletedForUpdate(id) + + override fun findByIdIncludingDeleted(id: Long): TournamentItem? = tournamentItemJpaRepository.findByIdIncludingDeleted(id) + + override fun findIdByTournamentIdAndNormalizedInput( + tournamentId: Long, + normalized: NormalizedInput, + ): Long? = tournamentItemJpaRepository.findIdsByTournamentIdAndNormalizedInput(tournamentId, normalized).firstOrNull() + + override fun findByTournamentIdAndItemId( + tournamentId: Long, + itemId: Long, + ): TournamentItem? = tournamentItemJpaRepository.findFirstByTournamentIdAndItemIdAndDeletedAtIsNullOrderByIdAsc(tournamentId, itemId) - override fun findRoutingBySnapshotId(snapshotId: Long): List = - tournamentItemJpaRepository.findRoutingBySnapshotId(snapshotId) + override fun findIdByTournamentIdAndItemCanonical( + tournamentId: Long, + canonical: CanonicalInput, + ): Long? = tournamentItemJpaRepository.findIdsByTournamentIdAndItemCanonical(tournamentId, canonical).firstOrNull() - override fun findRoutingsWithUserBySnapshotId(snapshotId: Long): List = - tournamentItemJpaRepository.findRoutingsWithUserBySnapshotId(snapshotId) + override fun findRequestedImageKeys( + tournamentId: Long, + keys: Collection, + ): Set { + if (keys.isEmpty()) return emptySet() + return tournamentItemJpaRepository + .findRequestedInputsByTournamentId(tournamentId, keys.map { it.value }) + .map(ProductImageKey::parse) + .toSet() + } override fun findPendingCardsByItemId(itemId: Long): List = tournamentItemJpaRepository.findPendingCardsByItemId(itemId) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRoutingView.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRoutingView.kt deleted file mode 100644 index e8eb7ef94..000000000 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemRoutingView.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.depromeet.piki.tournament.repository - -// itemId 로 역조회한 토너먼트 출전 좌표 — 파싱 알림 딥링크용(어느 토너먼트 / 그 안 어느 tournament_item). -// Spring Data interface projection — JPQL 의 별칭(AS tournamentId / AS tournamentItemId)이 getter 에 매핑된다. -interface TournamentItemRoutingView { - val tournamentId: Long - val tournamentItemId: Long -} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemUserRoutingView.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemUserRoutingView.kt deleted file mode 100644 index bedfb5f4f..000000000 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentItemUserRoutingView.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.depromeet.piki.tournament.repository - -import java.util.UUID - -// 이 버전(snapshot)을 pin 한 출전의 (등록자, 토너먼트, 출전 아이템 id) — 파싱 알림의 수신자별 토너먼트 딥링크 -// 역조회용(#933). userId 를 함께 실어 "누가 어느 좌표로 가는가"를 수신자별로 가른다(기존 TournamentItemRoutingView -// 는 userId 가 없어 이벤트-단위 라우팅만 가능했다). Spring Data interface projection. -interface TournamentItemUserRoutingView { - val userId: UUID - val tournamentId: Long - val tournamentItemId: Long -} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt index 3235b85ca..b5826cbbd 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt @@ -1,17 +1,16 @@ package com.depromeet.piki.tournament.repository import com.depromeet.piki.tournament.domain.Tournament -import com.depromeet.piki.tournament.domain.TournamentPlayType import com.depromeet.piki.tournament.domain.TournamentStatus import jakarta.persistence.LockModeType -import java.time.LocalDateTime -import java.util.UUID import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param +import java.time.LocalDateTime +import java.util.UUID interface TournamentJpaRepository : JpaRepository { fun findByIdAndDeletedAtIsNull(id: Long): Tournament? @@ -24,13 +23,12 @@ interface TournamentJpaRepository : JpaRepository { // 필터·정렬·limit 이 앱으로 올라오면 홈 카드(limit=3) 한 번에 내 전체 이력의 참가자·프로필을 선로드하게 된다. // uk_tournament_users (tournament_id, user_id) 가 (유저, 토너먼트) 당 멤버십 행을 1개로 보장해 조인이 행을 늘리지 않는다. // createdAt 동률 시 어느 행이 LIMIT 에 잘릴지 비결정적이므로 생성 순서와 일치하는 id 로 tie-break 한다. - // t._ownerTournamentUserId — 엔티티가 backing field 캡슐화(private var _ownerTournamentUserId)라 JPA 속성명이 field 이름이다. // // #1027: CLONE 이 사라지고 모든 참여자가 ROOT 참여 행(tournament_users) 하나를 가진다. 그래서 가시성·상태는 // 전역 tournament.status 가 아니라 "내 참여 행 status"(tu.status) 로 판정한다 — 주최자·멤버·게스트가 같은 // 토너먼트를 각자의 진행 상태로 본다(방장이 완료해도 아직 안 끝낸 멤버에겐 진행중). CLONE 행은 참여 행이 // 없어(백필로 평탄화) 이 조인에 걸리지 않으므로 자연히 목록에서 빠진다. - // ownedOnly — 홈(내가 생성한 것만)은 TRUE 로 참여만 한 방을 끈다(_ownerTournamentUserId = tu.id 만). 탭은 FALSE. + // ownedOnly — 홈(내가 생성한 것만)은 TRUE 로 참여만 한 방을 끈다(ownerTournamentUserId = tu.id 만). 탭은 FALSE. // // playType(솔로/소셜)은 저장된 컬럼이 아니라 참가 결과로 파생되는 상태다(TournamentPlayType 참고). // 파생값이라 앱에서 거르면 limit 이 파생 필터보다 먼저 걸려 "SOCIAL 3개" 를 요구했는데 그보다 적게 나오므로, @@ -44,7 +42,7 @@ interface TournamentJpaRepository : JpaRepository { AND tu.deletedAt IS NULL AND t.deletedAt IS NULL AND tu.status IN :statuses - AND (:ownedOnly = FALSE OR t._ownerTournamentUserId = tu.id) + AND (:ownedOnly = FALSE OR t.ownerTournamentUserId = tu.id) AND ( ( :includeSocial = TRUE diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt index ac3b7484e..daaafed2b 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt @@ -4,11 +4,11 @@ import com.depromeet.piki.tournament.domain.Tournament import com.depromeet.piki.tournament.domain.TournamentHistory import com.depromeet.piki.tournament.domain.TournamentPlayType import com.depromeet.piki.tournament.domain.TournamentStatus -import java.time.LocalDateTime -import java.util.UUID import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import java.util.UUID @Repository class TournamentRepositoryImpl( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt index ead1ff48f..410c845ab 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -23,20 +23,20 @@ enum class TournamentErrorCode( FORBIDDEN_TOURNAMENT("TOURNAMENT-001", ErrorCategory.FORBIDDEN, "이 토너먼트에 접근할 수 없어요."), NOT_FOUND_TOURNAMENT("TOURNAMENT-002", ErrorCategory.NOT_FOUND, "토너먼트를 찾을 수 없어요. 이미 삭제됐을 수 있어요."), INVALID_WINNER("TOURNAMENT-003", ErrorCategory.INVALID_INPUT, "올바르지 않은 선택이에요."), - INVALID_TOURNAMENT_ITEM("TOURNAMENT-004", ErrorCategory.INVALID_INPUT, "이 토너먼트에 없는 아이템이에요."), + INVALID_TOURNAMENT_ITEM("TOURNAMENT-004", ErrorCategory.INVALID_INPUT, "이 토너먼트에 없는 후보예요."), NOT_PENDING_TOURNAMENT("TOURNAMENT-005", ErrorCategory.CONFLICT, "토너먼트가 시작되기 전에만 할 수 있어요."), NOT_IN_PROGRESS_TOURNAMENT("TOURNAMENT-006", ErrorCategory.CONFLICT, "토너먼트가 진행 중일 때만 할 수 있어요."), - INVALID_ITEM_COUNT("TOURNAMENT-007", ErrorCategory.INVALID_INPUT, "아이템은 2~32개 사이로 담아주세요."), - TOO_MANY_TOURNAMENT_ITEMS("TOURNAMENT-008", ErrorCategory.INVALID_INPUT, "아이템은 최대 32개까지 담을 수 있어요."), - DUPLICATE_TOURNAMENT_ITEM("TOURNAMENT-009", ErrorCategory.CONFLICT, "이미 담은 아이템이에요."), - NOT_FOUND_TOURNAMENT_ITEM("TOURNAMENT-010", ErrorCategory.NOT_FOUND, "토너먼트 아이템을 찾을 수 없어요."), - NOT_FOUND_ITEMS("TOURNAMENT-011", ErrorCategory.NOT_FOUND, "존재하지 않는 아이템이 포함되어 있어요."), + INVALID_ITEM_COUNT("TOURNAMENT-007", ErrorCategory.INVALID_INPUT, "토너먼트 후보는 2~32개 사이로 담아주세요."), + TOO_MANY_TOURNAMENT_ITEMS("TOURNAMENT-008", ErrorCategory.INVALID_INPUT, "토너먼트 후보는 최대 32개까지 담을 수 있어요."), + DUPLICATE_TOURNAMENT_ITEM("TOURNAMENT-009", ErrorCategory.CONFLICT, "이미 담은 토너먼트 후보예요."), + NOT_FOUND_TOURNAMENT_ITEM("TOURNAMENT-010", ErrorCategory.NOT_FOUND, "토너먼트 후보를 찾을 수 없어요."), + NOT_FOUND_ITEMS("TOURNAMENT-011", ErrorCategory.NOT_FOUND, "존재하지 않는 상품이 포함되어 있어요."), ITEM_NOT_READY("TOURNAMENT-012", ErrorCategory.CONFLICT, "아직 정보를 가져오는 중인 상품이에요. 잠시 후 추가해 주세요."), ITEM_NOT_READY_TO_START("TOURNAMENT-013", ErrorCategory.CONFLICT, "아직 준비 중인 상품이 있어요. 모두 준비되면 시작할 수 있어요."), ITEM_PRICE_REQUIRED("TOURNAMENT-014", ErrorCategory.CONFLICT, "가격 정보가 없는 상품이 있어요. 직접 입력 후 시작해 주세요."), INVALID_IMAGE_COUNT("TOURNAMENT-015", ErrorCategory.INVALID_INPUT, "이미지는 1~5장만 올릴 수 있어요."), - ITEM_NOT_IN_WISHLIST("TOURNAMENT-016", ErrorCategory.FORBIDDEN, "위시에 저장된 아이템만 담을 수 있어요."), - ELIMINATED_TOURNAMENT_ITEM("TOURNAMENT-017", ErrorCategory.CONFLICT, "이미 탈락한 아이템이에요."), + ITEM_NOT_IN_WISHLIST("TOURNAMENT-016", ErrorCategory.FORBIDDEN, "위시에 저장된 상품만 담을 수 있어요."), + ELIMINATED_TOURNAMENT_ITEM("TOURNAMENT-017", ErrorCategory.CONFLICT, "이미 탈락한 토너먼트 후보예요."), INVALID_CURRENT_ROUND("TOURNAMENT-018", ErrorCategory.INVALID_INPUT, "지금은 진행할 수 없는 단계예요."), IN_PROGRESS_TOURNAMENT_CANNOT_BE_DELETED("TOURNAMENT-019", ErrorCategory.CONFLICT, "토너먼트가 끝난 후 삭제할 수 있어요."), INVALID_INVITE_CODE("TOURNAMENT-020", ErrorCategory.INVALID_INPUT, "초대 코드가 올바르지 않아요."), @@ -51,7 +51,7 @@ enum class TournamentErrorCode( PLAY_LINK_EXPIRED("TOURNAMENT-027", ErrorCategory.CONFLICT, "플레이 링크가 만료됐어요."), GROUP_RESULT_NOT_AVAILABLE("TOURNAMENT-028", ErrorCategory.CONFLICT, "완료된 토너먼트에서만 결과를 볼 수 있어요."), PARTICIPANT_LIMIT_EXCEEDED("TOURNAMENT-030", ErrorCategory.CONFLICT, "참여 인원이 가득 찼어요."), - CLONED_TOURNAMENT_CANNOT_ADD_ITEMS("TOURNAMENT-032", ErrorCategory.FORBIDDEN, "플레이 링크로 만든 토너먼트에는 아이템을 추가할 수 없어요."), + CLONED_TOURNAMENT_CANNOT_ADD_ITEMS("TOURNAMENT-032", ErrorCategory.FORBIDDEN, "플레이 링크로 만든 토너먼트에는 후보를 추가할 수 없어요."), INVALID_LIMIT("TOURNAMENT-033", ErrorCategory.INVALID_INPUT, "조회 개수는 1 이상이어야 해요."), // 034~035 는 매치 로직 백엔드 이관(#683)에서 추가됐다. 브래킷 무결성 검증이 처음 들어오며 생긴 두 사유다. @@ -68,7 +68,7 @@ enum class TournamentErrorCode( CLONED_TOURNAMENT_CANNOT_MODIFY_ITEMS( "TOURNAMENT-038", ErrorCategory.FORBIDDEN, - "플레이 링크로 만든 토너먼트에서는 아이템을 수정하거나 삭제할 수 없어요.", + "플레이 링크로 만든 토너먼트에서는 후보를 수정하거나 삭제할 수 없어요.", ), // 039~040 은 미완성(INCOMPLETE) 안내를 012·013 에서 갈라낸 것이다(#944 후속). 두 문구는 "잠시 후"·"모두 diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt index 318157788..36009cfd7 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt @@ -44,15 +44,13 @@ class TournamentException private constructor( fun notFoundItems(): TournamentException = TournamentException(TournamentErrorCode.NOT_FOUND_ITEMS) - // 비동기 파싱이 끝나지 않은(PENDING·PROCESSING) 또는 실패한(FAILED) 상품을 위시에서 토너먼트에 추가하려 한 경우. - // 곧 READY 가 되거나 영구 실패라 현재 상태와 충돌 → 409. 미완성(INCOMPLETE)은 itemIncomplete 가 따로 진다. fun itemNotReady(): TournamentException = TournamentException(TournamentErrorCode.ITEM_NOT_READY) // 추출이 일부 필드만 채운(INCOMPLETE) 상품을 담으려 한 경우. itemNotReady 와 가르는 이유는 해법이 달라서다 — // 파싱 중은 기다리면 풀리지만 미완성은 사용자가 빈 값을 채워야 풀린다. fun itemIncomplete(): TournamentException = TournamentException(TournamentErrorCode.ITEM_INCOMPLETE) - // 토너먼트 시작 시 PENDING·PROCESSING·FAILED 아이템이 포함된 경우. + // 토너먼트 시작 시 PARSING·FAILED 아이템이 포함된 경우. fun itemNotReadyToStart(): TournamentException = TournamentException(TournamentErrorCode.ITEM_NOT_READY_TO_START) // 토너먼트 시작 시 미완성(INCOMPLETE) 아이템이 포함된 경우. 담기 경로의 itemIncomplete 와 같은 이유로 가른다. @@ -81,8 +79,6 @@ class TournamentException private constructor( fun notCompletedTournament(): TournamentException = TournamentException(TournamentErrorCode.NOT_COMPLETED_TOURNAMENT) - fun playLinkAlreadyCreated(): TournamentException = TournamentException(TournamentErrorCode.PLAY_LINK_ALREADY_CREATED) - fun playLinkNotCreated(): TournamentException = TournamentException(TournamentErrorCode.PLAY_LINK_NOT_CREATED) fun playLinkExpired(): TournamentException = TournamentException(TournamentErrorCode.PLAY_LINK_EXPIRED) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt index 736a31ddf..62f9613cd 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt @@ -1,15 +1,14 @@ package com.depromeet.piki.tournament.service -import com.depromeet.piki.item.domain.Item -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.item.service.DisplayCard -import com.depromeet.piki.item.service.ItemDisplayService -import com.depromeet.piki.item.service.ItemIdentityRecorder -import com.depromeet.piki.item.service.ItemSharingService -import com.depromeet.piki.item.service.ParsingEnqueuer import com.depromeet.piki.common.exception.AlreadyRegisteredException +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.NormalizedInput +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.UserInput +import com.depromeet.piki.item.service.ItemCardService +import com.depromeet.piki.item.service.ParsingEnqueuer +import com.depromeet.piki.item.service.UserInputRecorder import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.tournament.domain.Tournament import com.depromeet.piki.tournament.domain.TournamentItem @@ -17,119 +16,74 @@ import com.depromeet.piki.tournament.event.TournamentItemAdded import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository -import com.depromeet.piki.tournament.service.dto.PersistedTournamentItem import io.micrometer.core.instrument.MeterRegistry import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID -// TournamentItemService 의 외부 호출(링크 추출·이미지 추출)이 트랜잭션 밖에 있도록 -// 아이템 저장과 토너먼트 아이템 등록만 별도 빈으로 분리한다. -// 같은 빈에서 @Transactional 메서드를 직접 호출하면 Spring AOP proxy 를 거치지 않아 트랜잭션이 무력화된다. -// -// 2단계 쓰기 이중화: item 을 저장/전이하는 곳마다 같은 트랜잭션에서 대응 ItemSnapshot 도 평행하게 처리한다. +// TournamentItemService 의 외부 호출이 트랜잭션 밖에 있도록 저장만 별도 빈으로 둔다 @Service class TournamentItemPersistenceService( private val tournamentRepository: TournamentRepository, private val tournamentUserRepository: TournamentUserRepository, private val tournamentItemRepository: TournamentItemRepository, - private val itemRepository: ItemRepository, - private val itemSnapshotRepository: ItemSnapshotRepository, private val eventPublisher: ApplicationEventPublisher, - private val itemIdentityRecorder: ItemIdentityRecorder, - private val itemSharingService: ItemSharingService, - private val itemDisplayService: ItemDisplayService, + private val itemCardService: ItemCardService, + private val userInputRecorder: UserInputRecorder, private val parsingEnqueuer: ParsingEnqueuer, private val meterRegistry: MeterRegistry, ) { - // 이 토너먼트에 이미 출전 중인 item → 그 tournament_item id. 중복 판정이 "무엇과 겹치는지"까지 답해야 해서(#973) - // 존재 여부가 아니라 매핑으로 만든다. snapshot 을 못 찾는 행은 자연히 빠지는데, 그런 행은 정원·중복 어느 쪽에도 셀 수 없다. - private fun existingTournamentItemIdByItemId(tournamentId: Long): Map { - val tournamentItemIdBySnapshotId = - tournamentItemRepository.findAllByTournamentId(tournamentId).associate { it.snapshotId to it.getId() } - if (tournamentItemIdBySnapshotId.isEmpty()) return emptyMap() - return itemSnapshotRepository - .findByIds(tournamentItemIdBySnapshotId.keys.toList()) - .associate { it.itemId to tournamentItemIdBySnapshotId.getValue(it.getId()) } - } - - // 처음 보는 링크 모양의 중복 — 기존 출전분 중 raw link 가 같은 행의 tournament_item id. 없으면 null. - // 사전 확인과 최종 판정이 같은 규칙을 봐야 하므로(한쪽만 고치면 조용히 어긋난다) 두 곳이 이 함수를 공유한다. - private fun duplicatedByRawLink( - existingByItemId: Map, - link: ProductLink, - ): Long? = - itemRepository - .findByIds(existingByItemId.keys.toList()) - .firstOrNull { it.link == link } - ?.let { existingByItemId.getValue(it.getId()) } - - // 등록 전 사전 확인 — 이미 담긴 링크면 한도를 깎기 전에 409 로 끊는다(#973). - // persistLinkItem 과 같은 두 갈래(raw link 모양 / 공유 정체성)를 본다. - // - // 락 밖 조회라 근사치다. 최종 판정은 persistLinkItem 이 정원 검사와 함께 트랜잭션 안에서 다시 하므로, - // 여기서 놓친 중복도 거기서 걸린다. 그 창에서만 차감이 낭비된다. + // 이미 담긴 링크면 한도를 깎기 전에 409 로 끊는다(#973). 최종 판정은 persistLinkItem 이 트랜잭션 안에서 다시 한다 @Transactional(readOnly = true) fun rejectIfAlreadyAdded( tournamentId: Long, link: ProductLink, ) { - val existingByItemId = existingTournamentItemIdByItemId(tournamentId) - if (existingByItemId.isEmpty()) return - val duplicated = - duplicatedByRawLink(existingByItemId, link) - ?: itemSharingService.resolveExistingItem(link)?.let { existingByItemId[it.getId()] } - duplicated?.let { - throw AlreadyRegisteredException.tournamentItem(TournamentErrorCode.DUPLICATE_TOURNAMENT_ITEM, it) - } + rejectIfAdded(tournamentId, link) } + // 이미 아는 링크여도 아이템 없이 올리고 새로 파싱한다. 성공하면 정체 URL 이 같은 아이템에 붙는다 @Transactional fun persistLinkItem( userId: UUID, tournamentId: Long, link: ProductLink, - ): PersistedTournamentItem { + ): Long { rejectIfWontFit(userId, tournamentId, 1) - // 공유 정체성(#825 활성화) — 이미 아는 링크 모양이면 기존 item 에 붙는다. 중복 검사도 정체성(itemId) 기준으로 - // 올라가, 같은 상품을 다른 링크 모양(단축 vs 정식)으로 담는 중복까지 잡는다. 처음 보는 모양은 raw link 비교(기존)로 남긴다. - val shared = itemSharingService.resolveExistingItem(link) - val existingByItemId = existingTournamentItemIdByItemId(tournamentId) - // 처음 보는 링크 모양의 중복은 raw link 비교(기존 방식)로 잡는다. 정체성 기준 검사는 attach 뒤에서. - duplicatedByRawLink(existingByItemId, link)?.let { - throw AlreadyRegisteredException.tournamentItem(TournamentErrorCode.DUPLICATE_TOURNAMENT_ITEM, it) - } - shared?.let { sharedItem -> - // attach 메타(reused·refreshNeeded)는 위시 등록 응답부터 노출한다(#853) — 토너먼트 응답 노출은 클라 요구가 생기면. - val attachment = itemSharingService.resolveAttachment(sharedItem.getId(), link, requestedBy = userId) - // 정체성 중복 검사·반환 itemId 는 실제로 붙은 attachment.item 기준 — 병합 재시도 경합에선 별칭으로 - // 찾은 shared(loser)와 다르고(승자로 재해석), 이 기준이어야 반환 itemId 와 snapshot 소속이 일치한다. - existingByItemId[attachment.item.getId()]?.let { - throw AlreadyRegisteredException.tournamentItem(TournamentErrorCode.DUPLICATE_TOURNAMENT_ITEM, it) - } - val tournamentItem = - tournamentItemRepository.save( - TournamentItem( - tournamentId = tournamentId, - userId = userId, - snapshotId = attachment.snapshot.getId(), - ), - ) - eventPublisher.publishEvent(TournamentItemAdded(tournamentId = tournamentId, actorId = userId)) - return PersistedTournamentItem( - itemId = attachment.item.getId(), - snapshotId = attachment.snapshot.getId(), - tournamentItemId = tournamentItem.getId(), - ) - } - val persisted = persistNew(Item(link), userId, tournamentId) + rejectIfAdded(tournamentId, link) + val tournamentItemId = persistPending(userId, tournamentId, ParseInput.Link(link)) eventPublisher.publishEvent(TournamentItemAdded(tournamentId = tournamentId, actorId = userId)) - return persisted + return tournamentItemId } - @Transactional - fun rejectIfWontFit( + // 요청 시점에 같은 상품인지 모르면 받고, 확정 때 합친다(ItemSlotPort.fill) + private fun rejectIfAdded( + tournamentId: Long, + link: ProductLink, + ) { + val duplicated = entryOfSameItem(tournamentId, link) ?: entryRequestedWith(tournamentId, link) + duplicated?.let { throw AlreadyRegisteredException.tournamentItem(TournamentErrorCode.DUPLICATE_TOURNAMENT_ITEM, it) } + } + + private fun entryOfSameItem( + tournamentId: Long, + link: ProductLink, + ): Long? { + val canonical = NormalizedInput.of(ParseInput.Link(link))?.asCanonical() ?: return null + return tournamentItemRepository.findIdByTournamentIdAndItemCanonical(tournamentId, canonical) + } + + // 단축링크처럼 정체 URL 과 다른 URL 로 다시 올리는 경우는 요청 이력으로 본다 + private fun entryRequestedWith( + tournamentId: Long, + link: ProductLink, + ): Long? { + val normalized = NormalizedInput.of(ParseInput.Link(link)) ?: return null + return tournamentItemRepository.findIdByTournamentIdAndNormalizedInput(tournamentId, normalized) + } + + private fun rejectIfWontFit( userId: UUID, tournamentId: Long, incomingCount: Int, @@ -139,48 +93,35 @@ class TournamentItemPersistenceService( } // 토너먼트 행 잠금이 첫 문장이어야 한다. 같은 토너먼트로 오는 confirm 이 여기서 줄을 서므로, 뒤이은 사전 필터가 - // 앞선 confirm 이 커밋한 item 을 정확히 본다. 일반 읽기가 먼저 오면 REPEATABLE READ 스냅샷이 잠금 전에 고정된다. + // 앞선 confirm 이 커밋한 요청을 정확히 본다. 일반 읽기가 먼저 오면 REPEATABLE READ 스냅샷이 잠금 전에 고정된다. @Transactional fun registerImages( - imageKeys: List, + imageKeys: List, userId: UUID, tournamentId: Long, - ): List { + ): List { validateAddable(lockTournament(tournamentId), userId, tournamentId) - val registered = itemRepository.findBySourceImageKeys(imageKeys).mapNotNull { it.sourceImageKey }.toSet() - val newKeys = imageKeys - registered + val newKeys = imageKeys.distinct() - tournamentItemRepository.findRequestedImageKeys(tournamentId, imageKeys) if (newKeys.isEmpty()) return emptyList() if (!hasRoomFor(tournamentId, newKeys.size)) throw TournamentException.tooManyTournamentItems() - val persisted = newKeys.map { persistNew(Item(sourceImageKey = it), userId, tournamentId) } + val persisted = newKeys.map { persistPending(userId, tournamentId, ParseInput.Image(it)) } eventPublisher.publishEvent(TournamentItemAdded(tournamentId = tournamentId, actorId = userId)) return persisted } - // 새 정체성을 세우는 절차. 링크·이미지 두 경로가 이 한 벌을 쓴다. - private fun persistNew( - item: Item, + private fun persistPending( userId: UUID, tournamentId: Long, - ): PersistedTournamentItem { - val saved = itemRepository.save(item) - itemIdentityRecorder.recordRegistrationAlias(saved) - val snapshot = parsingEnqueuer.enqueue(saved.getId(), requestedBy = userId) - val tournamentItem = - tournamentItemRepository.save( - TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId()), - ) - return PersistedTournamentItem( - itemId = saved.getId(), - snapshotId = snapshot.getId(), - tournamentItemId = tournamentItem.getId(), - ) + input: ParseInput, + ): Long { + val tournamentItem = tournamentItemRepository.save(TournamentItem(tournamentId = tournamentId, userId = userId, itemId = null)) + parsingEnqueuer.enqueue(tournamentItem.slot, ParsePurpose.REGISTER, input) + return tournamentItem.getId() } - // 수기 수정 영속화(#825 결정 4) — 기존 행을 고치지 않고 MANUAL 새 버전을 쌓아 출전 pin 을 옮긴다(repinSnapshot). - // S3 업로드(외부 호출)는 호출부가 트랜잭션 바깥에서 끝내고, 여기선 권한·소유 검증 + 새 버전 적재 + pin 이동만 - // 짧은 트랜잭션으로 묶는다. 상태 제한이 없다 — 진행 중이던 파싱은 자기 행에서 계속돼 완료 시 이력으로 남는다. + // 잠금이 트랜잭션의 첫 문장이어야 한다. 일반 읽기가 먼저 오면 REPEATABLE READ 스냅샷이 잠금 전에 고정돼 그 사이 파싱 성공이 확정한 아이템의 버전을 못 본다 @Transactional - fun manualEdit( + fun applyUserInput( userId: UUID, tournamentId: Long, tournamentItemId: Long, @@ -189,41 +130,17 @@ class TournamentItemPersistenceService( imageUrl: String?, currency: String?, ) { - val tournament = - tournamentRepository.findTournamentById(tournamentId) - ?: throw TournamentException.notFoundTournament() - // 클론은 원본 아이템을 이어받을 뿐 소유 행이 없다 — 수정 시 원본을 건드리므로 막는다(#977, 추가 금지 032 와 같은 결). - // 잔재 클론 id 로만 닿는 가드라 닿을 때마다 센다(#1188). 0 으로 머물면 4-2단계에서 가드째 지운다. - rejectCloneId(tournament) { TournamentException.clonedTournamentCannotModifyItems() } - if (!tournament.isPending()) throw TournamentException.notPendingTournament() - tournamentUserRepository.findByTournamentIdAndUserId(tournamentId, userId) - ?: throw TournamentException.forbiddenTournament() - // 행 락 — 동시 수기 수정 두 건이 같은 pin 을 읽고 나중 커밋이 먼저 만든 MANUAL 버전을 덮는(유령 버전) 경합을 직렬화한다. - val tournamentItem = - tournamentItemRepository.findByIdForUpdate(tournamentItemId) - ?: throw TournamentException.notFoundTournamentItem() - if (tournamentItem.tournamentId != tournamentId) throw TournamentException.notFoundTournamentItem() - if (tournamentItem.userId != userId) throw TournamentException.forbiddenTournament() - val base = editBasisOf(tournamentItem) - val manual = - itemSnapshotRepository.save( - ItemSnapshot.manual( - base = base, - name = name, - price = price, - imageUrl = imageUrl, - currency = currency, - createdBy = userId, - ), - ) - tournamentItem.repinSnapshot(manual.getId()) + val locked = tournamentItemRepository.findByIdForUpdate(tournamentItemId) + val tournament = verifyEditable(userId, tournamentId) + val tournamentItem = locked ?: throw TournamentException.notFoundTournamentItem() + verifyEditor(tournamentItem, tournamentId, userId) + val values = itemCardService.cardOf(tournamentItem.cardQuery(tournament)).userInputValues(UserInput(name = name, price = price, imageUrl = imageUrl, currency = currency)) + userInputRecorder.record(tournamentItem.slot, tournamentItem.itemId, values, createdBy = userId) } - // 업로드 전 사전 검증(던지기 전용) — S3 orphan 방지가 유일한 존재 이유라 호출부가 이미지 있을 때만 부른다. - // 권한·상태 검증과 병합 base 선택(editBasisOf)을 manualEdit 과 같은 코드로 타므로 dry-run 이 실제 저장과 - // 갈라질 수 없다. 락 밖 조회라 최종 판정은 manualEdit(락 안)이 다시 한다. + // 이미지를 올리기 전에 거절해 S3 에 고아 객체가 남지 않게 한다. 최종 판정은 applyUserInput 이 락 안에서 다시 한다 @Transactional(readOnly = true) - fun validateManualEdit( + fun validateUserInput( userId: UUID, tournamentId: Long, tournamentItemId: Long, @@ -231,34 +148,29 @@ class TournamentItemPersistenceService( price: Int?, currency: String?, ) { - val tournament = - tournamentRepository.findTournamentById(tournamentId) - ?: throw TournamentException.notFoundTournament() - rejectCloneId(tournament) { TournamentException.clonedTournamentCannotModifyItems() } - if (!tournament.isPending()) throw TournamentException.notPendingTournament() - val tournamentItem = - tournamentItemRepository.findById(tournamentItemId) - ?: throw TournamentException.notFoundTournamentItem() - if (tournamentItem.tournamentId != tournamentId) throw TournamentException.notFoundTournamentItem() - if (tournamentItem.userId != userId) throw TournamentException.forbiddenTournament() - ItemSnapshot.manual( - base = editBasisOf(tournamentItem), - name = name, - price = price, - imageUrl = PRE_UPLOAD_VALIDATION_IMAGE_URL, - currency = currency, - createdBy = userId, - ) + val tournament = verifyEditable(userId, tournamentId) + val tournamentItem = tournamentItemRepository.findById(tournamentItemId) ?: throw TournamentException.notFoundTournamentItem() + verifyEditor(tournamentItem, tournamentId, userId) + itemCardService.cardOf(tournamentItem.cardQuery(tournament)).userInputValues(UserInput.beforeImageUpload(name, price, currency)) + } + + private fun verifyEditable( + userId: UUID, + tournamentId: Long, + ): Tournament { + val tournament = tournamentRepository.findTournamentById(tournamentId) ?: throw TournamentException.notFoundTournament() + tournament.checkItemsModifiable(meterRegistry) + requireParticipant(tournamentId, userId) + return tournament } - // base 는 "이 카드가 보여주던 값" 이다. 수정은 PENDING 전용(호출부 가드)이고 PENDING 카드는 pin 이 아니라 - // 표시값(#858, 최신 기계 READY 우선 파생)을 그리므로, pin 을 base 로 쓰면 화면과 다른 값 위에 병합돼 - // 화면에 가격이 떠 있는데 "가격이 필요하다"로 튕길 수 있다. 시작 후 격리는 start 의 repin 이 진다. - private fun editBasisOf(tournamentItem: TournamentItem): ItemSnapshot { - val pointer = - itemSnapshotRepository.findById(tournamentItem.snapshotId) - ?: error("snapshot 없음 — snapshotId=${tournamentItem.snapshotId}") - return itemDisplayService.resolveDisplay(DisplayCard.waitingOn(pointer, owner = tournamentItem.userId)) + private fun verifyEditor( + tournamentItem: TournamentItem, + tournamentId: Long, + userId: UUID, + ) { + if (tournamentItem.tournamentId != tournamentId) throw TournamentException.notFoundTournamentItem() + if (tournamentItem.userId != userId) throw TournamentException.forbiddenTournament() } // 이미지 업로드(외부 호출) 전에 권한·상태·복제를 미리 검증해 거부될 요청이 S3 에 orphan raw 를 남기지 않게 한다. @@ -283,21 +195,15 @@ class TournamentItemPersistenceService( userId: UUID, tournamentId: Long, ) { - if (!tournament.isPending()) throw TournamentException.notPendingTournament() - rejectCloneId(tournament) { TournamentException.clonedTournamentCannotAddItems() } - tournamentUserRepository.findByTournamentIdAndUserId(tournamentId, userId) - ?: throw TournamentException.forbiddenTournament() + tournament.checkItemsAddable(meterRegistry) + requireParticipant(tournamentId, userId) } - // 잔재 클론 id 로 온 아이템 쓰기를 막고, 닿은 횟수를 센다(#1188). 클론 행은 CLONE 제거(#1027) 뒤 옛 URL 을 잇는 - // 껍데기로만 남아 있어, 이 가드는 옛 클론 id 를 들고 온 요청에만 닿는다. - private fun rejectCloneId( - tournament: Tournament, - exception: () -> TournamentException, + private fun requireParticipant( + tournamentId: Long, + userId: UUID, ) { - tournament.sourceTournamentId ?: return - CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) - throw exception() + tournamentUserRepository.findByTournamentIdAndUserId(tournamentId, userId) ?: throw TournamentException.forbiddenTournament() } private fun hasRoomFor( @@ -305,6 +211,3 @@ class TournamentItemPersistenceService( incomingCount: Int, ): Boolean = tournamentItemRepository.countByTournamentId(tournamentId) + incomingCount <= TOURNAMENT_MAX_ITEM_COUNT } - -// 수기 수정 사전 검증(dry-run)에서 업로드 예정 이미지 자리를 메우는 자리표시 값 — 저장되지 않는다. -private const val PRE_UPLOAD_VALIDATION_IMAGE_URL = "https://validation.invalid/pre-upload.png" diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 68904a603..4c721fa50 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -3,20 +3,24 @@ package com.depromeet.piki.tournament.service import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.image.domain.UploadFormat import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.service.ItemRegistrar +import com.depromeet.piki.item.service.UniqueConflictRetry import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository +import com.depromeet.piki.tournament.service.dto.AddTournamentItemsFromWish import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.util.UUID @Service class TournamentItemService( + private val tournamentService: TournamentService, private val tournamentItemPersistenceService: TournamentItemPersistenceService, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, @@ -25,12 +29,13 @@ class TournamentItemService( private val itemQuotaGuard: ItemQuotaGuard, private val itemRegistrar: ItemRegistrar, ) { - // 아이템 등록 비용은 요청자가 아니라 **토너먼트 오너**의 몫에서 깎는다(#339). 참여자에는 게스트가 섞이는데 - // 게스트 계정은 무한 발급되므로 요청자 기준으로 세면 계정을 갈아타며 한도를 리셋할 수 있다. 오너는 반드시 - // 회원이라(토너먼트 생성이 회원 전용) 그 우회가 성립하지 않는다. - // - // tournament 를 다시 조회하는 것은 verifyCanAddItems 와 중복이지만, 오너를 함께 돌려주도록 그 시그니처를 - // 바꾸면 호출부 전부가 쓰지도 않는 값을 받게 된다. 조회 1회 비용을 택했다. + // 파싱 중이던 후보가 같은 상품으로 먼저 확정되면 유니크가 거부한다. 다시 실행하면 그 후보가 보여 409 다 + fun addItemsFromWish( + userId: UUID, + command: AddTournamentItemsFromWish, + ): List = UniqueConflictRetry.once("tournament=${command.tournamentId}") { tournamentService.addItemsFromWish(userId, command) } + + // 등록 한도는 회원인 토너먼트 오너 몫에서 깎는다(#339). 요청자 기준이면 무한 발급되는 게스트로 갈아타며 한도를 리셋할 수 있다 private fun ownerIdOf(tournamentId: Long): UUID { val tournament = tournamentRepository.findTournamentById(tournamentId) @@ -52,9 +57,7 @@ class TournamentItemService( val link = ProductLink.parse(url) tournamentItemPersistenceService.rejectIfAlreadyAdded(tournamentId, link) itemRegistrar.accept(link, ownerIdOf(tournamentId)) - // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). - val persisted = tournamentItemPersistenceService.persistLinkItem(userId, tournamentId, link) - return persisted.tournamentItemId + return tournamentItemPersistenceService.persistLinkItem(userId, tournamentId, link) } // 발급 단계에선 tournament_item 을 만들지 않아 정원 최종 판정은 confirm 으로 미룬다. @@ -66,25 +69,21 @@ class TournamentItemService( if (formats.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw TournamentException.invalidImageCount() tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) itemQuotaGuard.consume(ownerIdOf(tournamentId), formats.size, ItemErrorCode.QUOTA_EXCEEDED) - return imagePresignService.presignRawUploads(formats) + return imagePresignService.presignProductUploads(formats) } fun confirmImageRegistration( userId: UUID, tournamentId: Long, - imageKeys: List, + imageKeys: List, ): List { if (imageKeys.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw TournamentException.invalidImageCount() tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) imagePresignService.verifyUploaded(imageKeys) - return tournamentItemPersistenceService - .registerImages(imageKeys, userId, tournamentId) - .map { it.tournamentItemId } + return tournamentItemPersistenceService.registerImages(imageKeys, userId, tournamentId) } - // recoverWishItem 과 동일한 패턴(#825 결정 4) — 수기 수정은 상태 무관 허용이며 MANUAL 새 버전 + pin 이동으로 - // 영속화한다(manualEdit). 이미지 형식 검증 후 S3 업로드는 트랜잭션 밖에서, 권한 검증·적재는 manualEdit 에 위임한다. - // S3 업로드 전 병합 결과 필수 필드를 사전 확인해 orphan 업로드를 방지한다(최종 판정은 manualEdit). + // S3 업로드가 트랜잭션 안에 들어가지 않게 영속화만 applyUserInput 에 맡긴다 fun updateItem( userId: UUID, tournamentId: Long, @@ -95,17 +94,17 @@ class TournamentItemService( image: MultipartFile?, ) { val productImage = image?.let { ProductImage.of(it.bytes, it.contentType) } - // 업로드 전 사전 검증(orphan 방지)은 이미지가 있을 때만 — 그게 dry-run 의 유일한 존재 이유라, 업로드가 - // 없는 수정은 manualEdit(락 안)의 최종 판정 하나로 충분하다(예외·응답 동일, 쿼리만 줄어든다). productImage?.let { tournamentItemPersistenceService - .validateManualEdit(userId, tournamentId, tournamentItemId, name, price, currency) + .validateUserInput(userId, tournamentId, tournamentItemId, name, price, currency) } val imageUrl = productImage?.let { imageStorage.upload(it.bytes, "tournament-items/${UUID.randomUUID()}.${it.extension}", it.mimeType) } - tournamentItemPersistenceService.manualEdit(userId, tournamentId, tournamentItemId, name, price, imageUrl, currency) + UniqueConflictRetry.once("tournamentItem=$tournamentItemId") { + tournamentItemPersistenceService.applyUserInput(userId, tournamentId, tournamentItemId, name, price, imageUrl, currency) + } } companion object { diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemSlotPort.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemSlotPort.kt new file mode 100644 index 000000000..bb430f10d --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemSlotPort.kt @@ -0,0 +1,42 @@ +package com.depromeet.piki.tournament.service + +import com.depromeet.piki.item.domain.ItemSlotType +import com.depromeet.piki.item.service.ItemSlotFill +import com.depromeet.piki.item.service.ItemSlotPort +import com.depromeet.piki.item.service.ItemSlotState +import com.depromeet.piki.tournament.domain.TournamentItem +import com.depromeet.piki.tournament.repository.TournamentItemRepository +import org.springframework.stereotype.Component + +@Component +class TournamentItemSlotPort( + private val tournamentItemRepository: TournamentItemRepository, +) : ItemSlotPort { + override val type: ItemSlotType = ItemSlotType.TOURNAMENT_ITEM + + override fun find(id: Long): ItemSlotState = + tournamentItemRepository.findByIdIncludingDeletedForUpdate(id)?.let { ItemSlotState.Present(owner = it.userId, itemId = it.itemId) } ?: ItemSlotState.Gone + + override fun fill( + id: Long, + itemId: Long, + ): ItemSlotFill { + val tournamentItem = tournamentItemRepository.findByIdIncludingDeletedForUpdate(id) ?: error("토너먼트 아이템 $id 이 없다") + val holder = + holderOf(tournamentItem, itemId) ?: run { + tournamentItem.fill(itemId) + return ItemSlotFill.Filled + } + tournamentItem.softDelete() + return ItemSlotFill.AlreadyHeld(holder.slot, tournamentItem.userId, holder.userId) + } + + private fun holderOf( + tournamentItem: TournamentItem, + itemId: Long, + ): TournamentItem? { + tournamentItem.deletedAt?.let { return null } + tournamentItem.itemId?.let { return null } + return tournamentItemRepository.findByTournamentIdAndItemId(tournamentItem.tournamentId, itemId) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index 456a31ca5..62551de07 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -1,11 +1,10 @@ package com.depromeet.piki.tournament.service +import com.depromeet.piki.item.domain.CardQuery import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.item.repository.ItemRepository -import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.item.service.DisplayCard -import com.depromeet.piki.item.service.ItemDisplayService +import com.depromeet.piki.item.service.ItemCard +import com.depromeet.piki.item.service.ItemCardService import com.depromeet.piki.tournament.domain.RoundBracket import com.depromeet.piki.tournament.domain.Tournament import com.depromeet.piki.tournament.domain.TournamentHistory @@ -33,16 +32,17 @@ import com.depromeet.piki.tournament.service.dto.PlayLinkInfo import com.depromeet.piki.tournament.service.dto.RankedItem import com.depromeet.piki.tournament.service.dto.RecordMatch import com.depromeet.piki.tournament.service.dto.RecordMatchResult +import com.depromeet.piki.tournament.service.dto.StartResult import com.depromeet.piki.tournament.service.dto.TournamentDetail import com.depromeet.piki.tournament.service.dto.TournamentInvitePreview import com.depromeet.piki.tournament.service.dto.TournamentItemDetail -import com.depromeet.piki.tournament.service.dto.StartResult import com.depromeet.piki.tournament.service.dto.TournamentStartResult import com.depromeet.piki.tournament.service.dto.TournamentSummary import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.UserException import com.depromeet.piki.user.repository.UserRepository import com.depromeet.piki.user.service.DefaultProfileImages +import com.depromeet.piki.wishlist.repository.WishMemoRepository import com.depromeet.piki.wishlist.repository.WishRepository import io.micrometer.core.instrument.MeterRegistry import org.springframework.context.ApplicationEventPublisher @@ -58,9 +58,9 @@ class TournamentService( private val tournamentItemRepository: TournamentItemRepository, private val userRepository: UserRepository, private val itemRepository: ItemRepository, - private val itemSnapshotRepository: ItemSnapshotRepository, - private val itemDisplayService: ItemDisplayService, + private val itemCardService: ItemCardService, private val wishRepository: WishRepository, + private val wishMemoRepository: WishMemoRepository, private val defaultProfileImages: DefaultProfileImages, private val eventPublisher: ApplicationEventPublisher, private val meterRegistry: MeterRegistry, @@ -209,24 +209,18 @@ class TournamentService( val tournament = tournamentRepository.findTournamentByIdForUpdate(command.tournamentId) ?: throw TournamentException.notFoundTournament() - if (!tournament.isPending()) throw TournamentException.notPendingTournament() - if (!tournament.isRoot()) { - CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) - throw TournamentException.clonedTournamentCannotAddItems() - } + tournament.checkItemsAddable(meterRegistry) tournamentUserRepository.findByTournamentIdAndUserId(command.tournamentId, userId) ?: throw TournamentException.forbiddenTournament() - // 토너먼트에 이미 출전한 item 들 — tournament_item 의 고정 snapshot 에서 itemId 를 모은다(snapshot 단일 출처). val existingTournamentItems = tournamentItemRepository.findAllByTournamentId(command.tournamentId) - val existingItemIds = - itemSnapshotRepository.findByIds(existingTournamentItems.map { it.snapshotId }).map { it.itemId }.toSet() + val existingItemIds = existingTournamentItems.mapNotNull { it.itemId }.toSet() // 요청 내 중복 확인 — wishCount 는 unique itemId 기준이라 먼저 걸러야 정확하다 val requestedItemIds = command.itemIds.toSet() if (requestedItemIds.size != command.itemIds.size) throw TournamentException.duplicateTournamentItem() - val wishCount = wishRepository.countByItemIdsAndUserId(command.itemIds, userId) - if (wishCount < command.itemIds.size) throw TournamentException.itemNotInWishlist() + val wishes = wishRepository.findByItemIdsAndUserId(command.itemIds, userId) + if (!wishes.mapNotNull { it.itemId }.containsAll(requestedItemIds)) throw TournamentException.itemNotInWishlist() if (requestedItemIds.any { it in existingItemIds }) throw TournamentException.duplicateTournamentItem() - if (existingItemIds.size + command.itemIds.size > TOURNAMENT_MAX_ITEM_COUNT) { + if (existingTournamentItems.size + command.itemIds.size > TOURNAMENT_MAX_ITEM_COUNT) { throw TournamentException.tooManyTournamentItems() } val foundItems = itemRepository.findByIds(command.itemIds) @@ -235,36 +229,16 @@ class TournamentService( .map { it.getId() } .toSet() if (command.itemIds.any { it !in foundItemIds }) throw TournamentException.notFoundItems() - // 출전 시점에 위시가 기다리는 행을 tournament_item 에 고정한다 — 이후 위시 갱신과 무관하게 그 버전을 본다. - // 키는 위시의 상품이다(행의 상품이 아니라) — 두 참조가 어긋난 드문 행이 있어도 키 공간이 하나라 아래 커버리지 검사가 계약(409)으로 거른다. - val wishes = wishRepository.findByItemIdsAndUserId(command.itemIds, userId) - val snapshotById = itemSnapshotRepository.findByIds(wishes.map { it.waitingSnapshotId }).associateBy { it.getId() } - val activeSnapshotByItemId = - wishes.mapNotNull { wish -> snapshotById[wish.waitingSnapshotId]?.let { wish.itemId to it } }.toMap() - if (!activeSnapshotByItemId.keys.containsAll(command.itemIds)) throw TournamentException.itemNotReady() - // 판정은 포인터가 아니라 카드에 뜨는 값으로 한다 — 남이 같은 링크를 담아 추출이 성공하면 내 포인터가 - // 미완성·실패로 남아 있어도 카드엔 그 성공값이 뜬다(displayOf). 포인터로 판정하면 화면엔 값이 다 있는데 - // "채운 뒤 담아 주세요" 가 나가고, 같은 아이템으로 시작은 되는 어긋남이 생긴다. - // 박제는 포인터 그대로다 — 겨루는 값 확정은 start 의 몫이고(#858), 대기실 표시도 파생으로 움직인다. + // 판정은 카드에 뜨는 값으로 한다(#858). 위시 카드는 아이템이 있어 파싱 중·실패가 없고 미완성만 걸린다 requireEntryEligible( - itemDisplayService.resolveDisplay(activeSnapshotByItemId.values.map { DisplayCard.waitingOn(it, owner = userId) }).values, + itemCardService.cardsOf(wishes.map { it.cardQuery() }).values, TournamentException::itemIncomplete, TournamentException::itemNotReady, ) val savedItemIds = tournamentItemRepository - .saveAll( - command.itemIds.map { itemId -> - val snapshot = - activeSnapshotByItemId[itemId] - ?: error("wish 의 활성 snapshot 없음 — itemId=$itemId, userId=$userId") - TournamentItem( - tournamentId = command.tournamentId, - userId = userId, - snapshotId = snapshot.getId(), - ) - }, - ).map { it.getId() } + .saveAll(command.itemIds.map { TournamentItem(tournamentId = command.tournamentId, userId = userId, itemId = it) }) + .map { it.getId() } // 여러 개를 한 번에 추가해도 "아이템이 추가됐다"는 사실은 1건이라 이벤트도 1회만 발행한다. eventPublisher.publishEvent(TournamentItemAdded(tournamentId = command.tournamentId, actorId = userId)) return savedItemIds @@ -303,32 +277,18 @@ class TournamentService( if (tournamentItems.size !in TOURNAMENT_MIN_ITEM_COUNT..TOURNAMENT_MAX_ITEM_COUNT) { throw TournamentException.invalidItemCount() } - val snapshotById = snapshotsOf(tournamentItems) - // start = "겨루는 값 확정" 순간(#857). 대기실까지는 표시값이 파생(최신 기계 READY 우선)으로 움직이므로, - // 그 파생 결과를 여기서 포인터에 박제(repin)해 "겨룬 값 = 진행·완료 화면 값 = 히스토리 값" 을 고정한다. - // 시작 후 화면·히스토리는 파생 없이 포인터를 그대로 읽는다(당시를 보는 것이 확정). - val cardOf = { tournamentItem: TournamentItem -> DisplayCard.waitingOn(tournamentItem.requireSnapshot(snapshotById), owner = tournamentItem.userId) } - val displayByCard = itemDisplayService.resolveDisplay(tournamentItems.map(cardOf)) - val pinnedByTournamentItemId = - tournamentItems.associate { tournamentItem -> - val display = displayByCard.getValue(cardOf(tournamentItem)) - if (display.getId() != tournamentItem.snapshotId) tournamentItem.repinSnapshot(display.getId()) - tournamentItem.getId() to display - } - // item 정체성은 snapshot.itemId 단일 출처다 — 고정 snapshot 에서 itemId 를 모아 item 존재를 검증한다. - val itemById = - itemRepository - .findByIds(pinnedByTournamentItemId.values.map { it.itemId }) - .associate { it.getId() to it } - if (pinnedByTournamentItemId.values.any { it.itemId !in itemById }) throw TournamentException.notFoundItems() + // 아이템 행을 잠가 버전 생성과 직렬화한다. 이 뒤로 생기는 버전은 시작 시각보다 늦다 + val itemIds = tournamentItems.mapNotNull { it.itemId } + if (itemRepository.findByIdsForUpdate(itemIds).size != itemIds.distinct().size) throw TournamentException.notFoundItems() + // 잠금을 기다리는 동안 커밋된 버전도 시작 시각 기준 값에 들어가므로 판정도 그 버전까지 본다 + val cardById = lockedCardsOf(tournament, tournamentItems) requireEntryEligible( - pinnedByTournamentItemId.values, + cardById.values, TournamentException::itemIncompleteToStart, TournamentException::itemNotReadyToStart, ) for (tournamentItem in tournamentItems) { - pinnedByTournamentItemId.getValue(tournamentItem.getId()).price - ?: throw TournamentException.itemPriceRequired() + tournamentItem.requireValue(cardById).price ?: throw TournamentException.itemPriceRequired() } tournament.start() // #1027: 정의 상태(tournament.start)와 함께 주최자 참여 행도 플레이 시작으로 전이한다 — 주최자는 시작 즉시 @@ -336,21 +296,7 @@ class TournamentService( owner.startPlaying() // 시작이 커밋된 뒤에만 참가자에게 전달되도록 트랜잭션 안에서 발행한다 (롤백 시 미발행). eventPublisher.publishEvent(TournamentStarted(tournamentId = tournamentId, actorId = userId)) - return StartResult( - tournamentId = tournamentId, - items = - tournamentItems - .map { item -> - val snapshot = pinnedByTournamentItemId.getValue(item.getId()) - TournamentStartResult( - tournamentItemId = item.getId(), - name = snapshot.name, - price = snapshot.price, - currency = snapshot.currency, - imageUrl = snapshot.imageUrl, - ) - }.sortedWith(compareBy({ it.price }, { it.tournamentItemId })), - ) + return startResultOf(tournamentId, tournamentItems, cardById) } // #1027: 멤버·게스트의 "시작" 은 클론 생성이 아니라 자기 ROOT 참여 행을 플레이 시작으로 전이하는 것이다. @@ -372,13 +318,20 @@ class TournamentService( tournamentUserRepository.save(callerTU) } - val snapshotById = snapshotsOf(effectiveItems) - return StartResult( - tournamentId = root.getId(), + return startResultOf(root.getId(), effectiveItems, cardsOf(root, effectiveItems)) + } + + private fun startResultOf( + tournamentId: Long, + tournamentItems: List, + cardById: Map, + ): StartResult = + StartResult( + tournamentId = tournamentId, items = - effectiveItems + tournamentItems .map { item -> - val snapshot = item.requireSnapshot(snapshotById) + val snapshot = item.requireValue(cardById) TournamentStartResult( tournamentItemId = item.getId(), name = snapshot.name, @@ -388,7 +341,6 @@ class TournamentService( ) }.sortedWith(compareBy({ it.price }, { it.tournamentItemId })), ) - } @Transactional(readOnly = true) fun getTournamentById( @@ -432,7 +384,6 @@ class TournamentService( // 대기실/시작대기 응답(#1027). ownerStarted=false 면 일반 대기실(구성중), true 면 주최자가 이미 시작해 // 이 참여자만 아직 자기 플레이를 시작하지 않은 상태 — 클라가 "지금 시작하세요" UI 를 분기한다. - // 대기실은 표시값 파생(#857) — 최신 기계 READY 우선, 수기는 자기 맥락에서만. 시작되면 start 가 파생 결과를 박제한다. private fun buildPending( tournament: Tournament, isOwner: Boolean, @@ -440,7 +391,7 @@ class TournamentService( viewerId: UUID, ): TournamentDetail.Pending { val tournamentItems = tournamentItemsOf(tournament) - val snapshotById = displayedSnapshotsOf(tournamentItems) + val cardById = cardsOf(tournament, tournamentItems) val tournamentUsers = tournamentUserRepository.findByTournamentId(tournament.getId()) val itemCountByUserId = tournamentItems.groupingBy { it.userId }.eachCount() return TournamentDetail.Pending( @@ -448,7 +399,7 @@ class TournamentService( name = tournament.name, inviteCode = tournament.inviteCode, inviteExpiresAt = tournament.inviteExpiresAt, - items = tournamentItems.map { toItemDetail(it, snapshotById) }, + items = tournamentItems.map { toItemDetail(it, cardById) }, participants = toParticipantDetails( tournamentUsers, tournament.ownerTournamentUserId, @@ -520,8 +471,8 @@ class TournamentService( ?.let { TournamentDetail.HistoryEntry.from(it) } val allTournamentItems = tournamentItemsOf(tournament) val currentRound = computeExpectedRound(allTournamentItems.size, histories) - // 브래킷 파생이 라운드 시작 시점 집합(= remainingItems 의 상위집합)을 쓰므로 전체 아이템의 snapshot 을 잡는다. - val snapshotById = snapshotsOf(allTournamentItems) + // 브래킷 파생이 라운드 시작 시점 집합(= remainingItems 의 상위집합)을 쓰므로 전체 아이템의 값을 잡는다. + val cardById = cardsOf(tournament, allTournamentItems) // 단일 패스: 탈락 아이템 + 현재 라운드 대결 완료 아이템 동시 수집 val eliminatedItemIds = mutableSetOf() val foughtInCurrentRoundIds = mutableSetOf() @@ -536,9 +487,9 @@ class TournamentService( val remainingItems = allTournamentItems .filter { item -> item.getId() !in eliminatedItemIds && item.getId() !in foughtInCurrentRoundIds } - .map { toItemDetail(it, snapshotById) } + .map { toItemDetail(it, cardById) } .sortedWith(compareBy({ it.price }, { it.tournamentItemId })) - val bracket = deriveBracket(allTournamentItems, snapshotById, histories, currentRound, currentUser.getId()) + val bracket = deriveBracket(allTournamentItems, cardById, histories, currentRound, currentUser.getId()) return TournamentDetail.InProgress( tournamentId = tournament.getId(), name = tournament.name, @@ -548,7 +499,7 @@ class TournamentService( currentMatch = bracket .firstUnplayed(playedPairsIn(histories, currentRound)) - ?.let { toMatchDetail(it, allTournamentItems, snapshotById) }, + ?.let { toMatchDetail(it, allTournamentItems, cardById) }, isOwner = isOwner, isRoot = true, sourceTournamentId = null, @@ -572,30 +523,18 @@ class TournamentService( tournamentItemRepository.findById(tournamentItemId) ?: throw TournamentException.notFoundTournamentItem() if (tournamentItem.tournamentId != tournament.getId()) throw TournamentException.notFoundTournamentItem() - // 표시값: 대기실(PENDING)은 파생(#857), 시작 후는 start 가 박제한 포인터 그대로(겨룬 값 고정). - // sourceUrl(상품 링크)은 그 snapshot 의 item(정체성)에서 읽는다. - val pointer = tournamentItem.requireSnapshot(snapshotsOf(listOf(tournamentItem))) - val snapshot = - if (tournament.isPending()) { - itemDisplayService.resolveDisplay(DisplayCard.waitingOn(pointer, owner = tournamentItem.userId)) - } else { - pointer - } - val item = itemRepository.findById(snapshot.itemId) - ?: throw TournamentException.notFoundTournamentItem() - // 이 상품이 요청자 본인의 위시에 담겨 있으면 그 위시의 개인 메모를 함께 내린다(#906). 조회를 요청자 - // 소유 wish 로 한정하므로 남의 메모는 구조적으로 내려갈 수 없다. 게스트·미담음·삭제된 위시는 조회에 안 잡힌다. - val memo = wishRepository.findByItemIdsAndUserId(listOf(item.getId()), userId).firstOrNull()?.memo + val card = itemCardService.cardOf(tournamentItem.cardQuery(tournament)) + val itemId = tournamentItem.itemId return TournamentItemDetail( tournamentItemId = tournamentItem.getId(), - itemId = item.getId(), - sourceUrl = item.link?.toString(), - name = snapshot.name, - imageUrl = snapshot.imageUrl, - price = snapshot.price, - currency = snapshot.currency, - status = snapshot.status, - memo = memo, + itemId = itemId, + sourceUrl = card.sourceLink?.toString(), + name = card.display?.name, + imageUrl = card.display?.imageUrl, + price = card.display?.price, + currency = card.display?.currency, + status = card.status, + memo = itemId?.let { wishMemoOf(it, userId) }, ) } @@ -633,7 +572,7 @@ class TournamentService( // 썸네일도 남은 토너먼트에 대해서만 조회한다 (잘릴 것의 아이템은 안 읽음). #1027: 클론이 사라져 모든 가시 // 토너먼트가 자기 tournament_item 을 가진 ROOT 이므로 자기 id 로 바로 조회한다. - val thumbnailsByTournamentId = thumbnailUrlsByTournamentId(limited.map { it.getId() }) + val thumbnailsByTournamentId = thumbnailUrlsByTournamentId(limited) // "함께 담은 N"(#1062) — 그 토너먼트의 참여자 수. 참여자 프로필을 겹쳐 보여주던 자리를 숫자로 바꾼 것이라 // 모집단도 그대로 참여자다. 클론이 사라져(#1027) 카드가 곧 그 토너먼트라, ROOT 로 되짚을 필요 없이 자기 행만 센다. @@ -668,28 +607,22 @@ class TournamentService( } } - // 토너먼트별 대표 썸네일(최근 등록 아이템 중 이미지 있는 것 최대 2장) 배치 조립. - // 기존 배치 조회 2회(tournament_items → item_snapshots)만 쓰고 N+1 을 만들지 않는다. - private fun thumbnailUrlsByTournamentId(tournamentIds: List): Map> { - if (tournamentIds.isEmpty()) return emptyMap() - val items = tournamentItemRepository.findAllByTournamentIds(tournamentIds) + // 토너먼트별 대표 썸네일(최근 등록 아이템 중 이미지 있는 것 최대 2장). 카드가 READY 인 값의 이미지만 쓴다 + private fun thumbnailUrlsByTournamentId(tournaments: List): Map> { + if (tournaments.isEmpty()) return emptyMap() + val items = tournamentItemRepository.findAllByTournamentIds(tournaments.map { it.getId() }) if (items.isEmpty()) return emptyMap() - // READY 스냅샷의 이미지만 후보로 삼는다 — FAILED/PROCESSING 의 stale 이미지가 카드에 노출되지 않게 상태로 거른다. - val readyImageUrlBySnapshotId = - itemSnapshotRepository - .findByIds(items.map { it.snapshotId }) - .associate { snapshot -> - snapshot.getId() to - snapshot.imageUrl?.takeIf { snapshot.status == ItemStatus.READY } - } + val tournamentById = tournaments.associateBy { it.getId() } + val cardById = cardsOf(items) { tournamentById.getValue(it.tournamentId) } return items .groupBy { it.tournamentId } .mapValues { (_, tournamentItems) -> TournamentThumbnails.select( tournamentItems.map { + val card = it.cardIn(cardById) TournamentThumbnails.Candidate( recency = it.getId(), - imageUrl = readyImageUrlBySnapshotId[it.snapshotId], + imageUrl = card.display?.imageUrl?.takeIf { card.isReady() }, ) }, ) @@ -734,7 +667,7 @@ class TournamentService( rootId, tournamentUser.getId(), ) - val snapshotById = snapshotsOf(allTournamentItems) + val cardById = cardsOf(tournament, allTournamentItems) // 멱등(#683): 같은 조합이 이미 기록됐으면 재전송·뒤로가기로 인한 재시도다. // 이미 기록된 매치의 패자는 아래 탈락 집합에 들어 있으므로, 탈락 검사보다 먼저 판정해야 @@ -771,7 +704,7 @@ class TournamentService( nextMatch = nextMatchOf( allTournamentItems, - snapshotById, + cardById, histories, recorded.currentRound, tournamentUser.getId(), @@ -794,7 +727,7 @@ class TournamentService( // 브래킷 무결성(#683): 소속·미탈락·라운드만 보던 기존 검증은 클라가 임의 조합([0]vs[3])을 보내도 통과했다. // 서버가 파생한 페어 집합에 없는 조합은 거부한다. 단 진행 순서는 검증하지 않는다 — // 라운드 내 매치는 서로 독립이라 순서가 최종 결과를 바꾸지 않고, 강제하면 열린 탭·재전송에서 오탐 400 만 는다. - val bracket = deriveBracket(allTournamentItems, snapshotById, histories, expectedRound, tournamentUser.getId()) + val bracket = deriveBracket(allTournamentItems, cardById, histories, expectedRound, tournamentUser.getId()) if (!bracket.contains(command.firstTournamentItemId, command.secondTournamentItemId)) { throw TournamentException.invalidMatchPair() } @@ -815,7 +748,7 @@ class TournamentService( nextMatch = bracket .firstUnplayed(playedPairsIn(histories + newHistory, command.currentRound)) - ?.let { toMatchDetail(it, allTournamentItems, snapshotById) }, + ?.let { toMatchDetail(it, allTournamentItems, cardById) }, completed = null, ) } @@ -890,14 +823,14 @@ class TournamentService( tournamentItemRepository .findByIds(rankedPairs.map { it.first }) .associateBy { it.getId() } - val snapshotById = snapshotsOf(tournamentItemById.values) + val cardById = cardsOf(tournament, tournamentItemById.values) return TournamentDetail.Completed( tournamentId = tournament.getId(), name = tournament.name, result = rankedPairs.map { (tournamentItemId, rank) -> val tournamentItem = tournamentItemById.getValue(tournamentItemId) - val snapshot = tournamentItem.requireSnapshot(snapshotById) + val snapshot = tournamentItem.requireValue(cardById) RankedItem( rank = rank, tournamentItemId = tournamentItemId, @@ -1217,7 +1150,7 @@ class TournamentService( allHistories.map { it.firstTournamentItemId } + allHistories.map { it.secondTournamentItemId } val tItemById = tournamentItemRepository.findByIds(allTournamentItemIds).associateBy { it.getId() } - val snapshotById = snapshotsOf(tItemById.values) + val cardById = cardsOf(tournament, tItemById.values) // 각 참여자의 히스토리는 tournamentUserId 로 분리한다(모두 rootId 소속) — O(1) 조회. val historiesByTuId = allHistories.groupBy { it.tournamentUserId } @@ -1236,10 +1169,10 @@ class TournamentService( for ((tournamentItemId, rank) in ranked) { // tItem 누락은 삭제된 출전 아이템이 history 에 남은 정상 경우라 건너뛴다. 그러나 tItem 이 살아있으면 - // snapshot 은 불변식상 반드시 있어야 한다 — 없으면 continue 로 삼키지 않고 fail-fast 로 터뜨려, 부분 집계된 + // 값은 불변식상 반드시 있어야 한다 — 없으면 continue 로 삼키지 않고 fail-fast 로 터뜨려, 부분 집계된 // 랭킹이 200 으로 새어 나가는 것을 막는다. val tItem = tItemById[tournamentItemId] ?: continue - val snapshot = tItem.requireSnapshot(snapshotById) + val snapshot = tItem.requireValue(cardById) // 우승 아이템(rank==1)을 고른 참여자만 집계 — 참여자마다 같은 아이템의 rank 가 다를 수 있으므로 // RankKey 로 묶으면 누락이 생긴다. itemId 기준으로 1위 선택자만 모은다. if (rank == 1) { @@ -1295,12 +1228,7 @@ class TournamentService( val tournament = tournamentRepository.findTournamentById(tournamentId) ?: throw TournamentException.notFoundTournament() - // 클론은 원본 아이템을 이어받을 뿐 소유 행이 없다 — 삭제 시 원본을 건드리므로 막는다(#977, 추가 금지 032 와 같은 결). - tournament.sourceTournamentId?.let { - CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) - throw TournamentException.clonedTournamentCannotModifyItems() - } - if (!tournament.isPending()) throw TournamentException.notPendingTournament() + tournament.checkItemsModifiable(meterRegistry) val tournamentItem = tournamentItemRepository.findById(tournamentItemId) ?: throw TournamentException.notFoundTournamentItem() @@ -1319,13 +1247,12 @@ class TournamentService( if (deleted == 0) throw TournamentException.notPendingTournament() // 삭제로 출전 목록이 바뀌었음을 다른 참가자에게 알린다(폴링 대체) — 추가(TournamentItemAdded)와 대칭. - // tournamentItemId·snapshotId 를 함께 실어 알림 도메인이 어느 아이템인지·상품명을 끌어내게 한다 - // (tournament_item 은 방금 soft delete 돼 핸들러가 역조회로 못 닿지만, snapshot 은 살아 있다). + // tournament_item 은 방금 soft delete 돼 핸들러가 역조회로 못 닿아 보이던 버전을 실어 상품명을 읽게 한다 eventPublisher.publishEvent( TournamentItemDeleted( tournamentId = tournamentId, tournamentItemId = tournamentItemId, - snapshotId = tournamentItem.snapshotId, + snapshotId = itemCardService.cardOf(tournamentItem.cardQuery(tournament)).display?.getId(), actorId = userId, ), ) @@ -1333,18 +1260,18 @@ class TournamentService( private fun toItemDetail( tournamentItem: TournamentItem, - snapshotById: Map, + cardById: Map, ): TournamentDetail.ItemDetail { - val snapshot = tournamentItem.requireSnapshot(snapshotById) + val card = tournamentItem.cardIn(cardById) return TournamentDetail.ItemDetail( tournamentItemId = tournamentItem.getId(), - itemId = snapshot.itemId, + itemId = card.itemId, userId = tournamentItem.userId, - name = snapshot.name, - price = snapshot.price, - currency = snapshot.currency, - imageUrl = snapshot.imageUrl, - status = snapshot.status, + name = card.display?.name, + price = card.display?.price, + currency = card.display?.currency, + imageUrl = card.display?.imageUrl, + status = card.status, ) } @@ -1355,7 +1282,7 @@ class TournamentService( // 현재 라운드에서 진 아이템은(라운드 시작 시점엔 살아 있었으므로) 그대로 남긴다. private fun deriveBracket( allTournamentItems: List, - snapshotById: Map, + cardById: Map, histories: List, currentRound: Int, tournamentUserId: Long, @@ -1371,21 +1298,21 @@ class TournamentService( val entries = allTournamentItems .filterNot { it.getId() in eliminatedBeforeRound } - .map { RoundBracket.Entry(it.getId(), it.requireSnapshot(snapshotById).price) } + .map { RoundBracket.Entry(it.getId(), it.requireValue(cardById).price) } return RoundBracket.of(entries, tournamentUserId, currentRound) } // 그 라운드에서 아직 안 치른 첫 매치. 라운드가 다 끝났으면 null. private fun nextMatchOf( allTournamentItems: List, - snapshotById: Map, + cardById: Map, histories: List, round: Int, tournamentUserId: Long, ): TournamentDetail.MatchDetail? = - deriveBracket(allTournamentItems, snapshotById, histories, round, tournamentUserId) + deriveBracket(allTournamentItems, cardById, histories, round, tournamentUserId) .firstUnplayed(playedPairsIn(histories, round)) - ?.let { toMatchDetail(it, allTournamentItems, snapshotById) } + ?.let { toMatchDetail(it, allTournamentItems, cardById) } // 그 라운드에서 이미 치른 매치들. 진행 순서는 검증하지 않으므로 "치렀는지" 만 본다. private fun playedPairsIn( @@ -1399,7 +1326,7 @@ class TournamentService( private fun toMatchDetail( pair: RoundBracket.MatchPair, tournamentItems: List, - snapshotById: Map, + cardById: Map, ): TournamentDetail.MatchDetail { val itemById = tournamentItems.associateBy { it.getId() } @@ -1407,7 +1334,7 @@ class TournamentService( fun detailOf(tournamentItemId: Long) = toItemDetail( itemById[tournamentItemId] ?: error("브래킷 페어 아이템 없음 — tournamentItemId=$tournamentItemId"), - snapshotById, + cardById, ) return TournamentDetail.MatchDetail(first = detailOf(pair.first), second = detailOf(pair.second)) } @@ -1418,44 +1345,56 @@ class TournamentService( tournamentItemRepository.findAllByTournamentId(tournament.getId()) /** - * 출전 가능 판정의 단일 출처. 담기와 시작이 같은 질문("이 아이템들로 겨룰 수 있나")에 답하므로 한 벌만 둔다 — - * 두 벌이던 동안 담기만 포인터를, 시작은 표시값을 봐서 같은 아이템이 담기는 거부되고 시작은 되는 어긋남이 - * 있었다. 갈라질 수 있는 구조 자체를 없앤다. + * 출전 가능 판정의 단일 출처. 담기와 시작이 같은 질문("이 아이템들로 겨룰 수 있나")에 답하므로 한 벌만 둔다. * * 미완성을 먼저 보는 이유: 파싱 중은 기다리면 풀리지만 미완성은 사용자가 빈 값을 채워야 풀린다. 둘이 섞였을 * 땐 행동이 필요한 쪽을 알려야 하고, 아이템별 루프에 맡기면 앞선 파싱 중이 뒤쪽 미완성을 가린다. - * - * @param snapshots 판정 대상. 두 경로 모두 **표시값**을 넘긴다(포인터가 아니다). */ private fun requireEntryEligible( - snapshots: Collection, + cards: Collection, incomplete: () -> TournamentException, notReady: () -> TournamentException, ) { - if (snapshots.any { it.isIncomplete() }) throw incomplete() - if (snapshots.any { !it.isReady() }) throw notReady() + if (cards.any { it.isIncomplete() }) throw incomplete() + if (cards.any { !it.isReady() }) throw notReady() } - // tournament_item 들이 고정한 snapshot 을 한 번에 조회해 id→snapshot 맵으로. 표시값 조회의 메모리 조인 재료다. - private fun snapshotsOf(tournamentItems: Collection): Map = - itemSnapshotRepository - .findByIds(tournamentItems.map { it.snapshotId }) - .associateBy { it.getId() } - - // 대기실(PENDING) 표시용(#857) — 키는 포인터 snapshot id 를 유지하되 값을 파생 표시 버전으로 치환한다. - // requireSnapshot(포인터 id 조회)을 쓰는 기존 조립 코드가 무수정으로 표시 버전을 읽게 된다. - private fun displayedSnapshotsOf(tournamentItems: Collection): Map { - val pointers = snapshotsOf(tournamentItems) - // 카드 주인은 출전시킨 사람 — 그 사람의 맥락(수기)과 카드가 기다리는 행(pin)의 진행 중만 그 카드에 보인다. - val cardOf = { tournamentItem: TournamentItem -> DisplayCard.waitingOn(tournamentItem.requireSnapshot(pointers), owner = tournamentItem.userId) } - val displayByCard = itemDisplayService.resolveDisplay(tournamentItems.map(cardOf)) - return tournamentItems.associate { it.snapshotId to displayByCard.getValue(cardOf(it)) } + private fun cardsOf( + tournament: Tournament, + tournamentItems: Collection, + ): Map = cardsOf(tournamentItems) { tournament } + + private fun lockedCardsOf( + tournament: Tournament, + tournamentItems: Collection, + ): Map = cardsOf(tournamentItems, itemCardService::lockedCardsOf) { tournament } + + // 키는 tournament_item id. 시작 전엔 지금 보이는 값, 시작 후엔 시작 시각 기준 값이다 + private fun cardsOf( + tournamentItems: Collection, + load: (Collection) -> Map = itemCardService::cardsOf, + tournamentOf: (TournamentItem) -> Tournament, + ): Map { + val queryById = tournamentItems.associate { it.getId() to it.cardQuery(tournamentOf(it)) } + val cards = load(queryById.values) + return queryById.mapValues { (_, query) -> cards.getValue(query) } } - // 고정 snapshot 은 출전 시점에 반드시 박힌다. 없으면 영속화 경로가 깨진 코드 버그다(전환 후 신규 출전부터 보장). - private fun TournamentItem.requireSnapshot(snapshotById: Map): ItemSnapshot = - snapshotById[snapshotId] - ?: error("snapshot 없음 — tournamentItemId=${getId()}, snapshotId=$snapshotId") + private fun TournamentItem.cardIn(cardById: Map): ItemCard = + cardById[getId()] ?: error("카드 없음 — tournamentItemId=${getId()}") + + // 시작한 토너먼트의 출전은 모두 READY 라 값이 있다. 없으면 시작 게이트가 깨진 코드 버그다 + private fun TournamentItem.requireValue(cardById: Map): ItemSnapshot = + cardIn(cardById).display ?: error("출전 값 없음 — tournamentItemId=${getId()}") + + // 요청자 소유 위시로만 찾아 남의 메모는 내려갈 수 없다 + private fun wishMemoOf( + itemId: Long, + userId: UUID, + ): String? { + val wish = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).firstOrNull() ?: return null + return wishMemoRepository.findCurrentByWishId(wish.getId())?.content + } private fun computeRanking(histories: List): List> { val finalMatch = @@ -1545,3 +1484,19 @@ internal fun Tournament.checkJoinable(inviteCode: String?) { inviteCode?.let { if (this.inviteCode != it) throw TournamentException.invalidInviteCode() } } +// 옛 클론 id 가 아직 오는지 재려고 클론 가드에 걸린 요청을 센다(CloneRedirectMetrics) +internal fun Tournament.checkItemsAddable(meterRegistry: MeterRegistry) { + if (!isPending()) throw TournamentException.notPendingTournament() + if (isRoot()) return + CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) + throw TournamentException.clonedTournamentCannotAddItems() +} + +// 클론은 원본 아이템을 이어받을 뿐 소유 행이 없어 수정·삭제가 원본을 건드린다(#977) +internal fun Tournament.checkItemsModifiable(meterRegistry: MeterRegistry) { + if (!isRoot()) { + CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) + throw TournamentException.clonedTournamentCannotModifyItems() + } + if (!isPending()) throw TournamentException.notPendingTournament() +} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentThumbnails.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentThumbnails.kt index e83e939be..604643d62 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentThumbnails.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentThumbnails.kt @@ -5,7 +5,7 @@ package com.depromeet.piki.tournament.service object TournamentThumbnails { const val MAX = 2 - // recency 가 클수록 최근 등록. imageUrl 은 아직 파싱 안 됐거나(PENDING/PROCESSING) 실패한(FAILED) 스냅샷이면 null. + // recency 가 클수록 최근 등록. imageUrl 은 READY 가 아닌 카드면 null. data class Candidate( val recency: Long, val imageUrl: String?, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/PersistedTournamentItem.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/PersistedTournamentItem.kt deleted file mode 100644 index 2c6cf93cb..000000000 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/PersistedTournamentItem.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.depromeet.piki.tournament.service.dto - -// PK 셋을 한 Long 으로 뭉뚱그리면 호출부가 엉뚱한 행을 전이시킴. 응답·삭제는 tournament_item PK, 파싱 전이는 snapshot PK -data class PersistedTournamentItem( - val itemId: Long, - val snapshotId: Long, - val tournamentItemId: Long, -) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt index d3d55477f..21e13de4f 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt @@ -51,7 +51,7 @@ sealed class TournamentDetail { data class ItemDetail( val tournamentItemId: Long, - val itemId: Long, + val itemId: Long?, val userId: UUID, val name: String?, val price: Int?, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentItemDetail.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentItemDetail.kt index bc42069be..c96a9894a 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentItemDetail.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentItemDetail.kt @@ -4,7 +4,7 @@ import com.depromeet.piki.item.domain.ItemStatus data class TournamentItemDetail( val tournamentItemId: Long, - val itemId: Long, + val itemId: Long?, val sourceUrl: String?, val name: String?, val imageUrl: String?, diff --git a/src/main/kotlin/com/depromeet/piki/user/controller/UserApi.kt b/src/main/kotlin/com/depromeet/piki/user/controller/UserApi.kt index 4c77c7713..f6e20f9f2 100644 --- a/src/main/kotlin/com/depromeet/piki/user/controller/UserApi.kt +++ b/src/main/kotlin/com/depromeet/piki/user/controller/UserApi.kt @@ -82,10 +82,10 @@ interface UserApi { "1. 이 API 로 `contentType` · `contentLength`(바이트 수) 를 보내 `uploadUrl` · `imageKey` 를 받는다.\n" + "2. `uploadUrl` 에 이미지를 PUT 한다 (`Content-Type` 헤더는 응답의 `contentType` 과, `Content-Length` 는 " + "요청의 `contentLength` 와 같아야 한다 — 둘 다 서명에 박혀 있어 다르면 S3 가 거부).\n" + - "3. `PATCH /users/me` 에 `imageKey` 를 실어 확정한다. 서버가 그 원본을 읽어 검증하고 최종 경로에 저장한다.\n\n" + + "3. `PATCH /users/me` 에 `imageKey` 를 실어 확정한다. 서버가 그 객체를 읽어 형식을 검증하고 그 URL 을 프로필로 삼는다.\n\n" + "- **MEMBER 전용** — 게스트는 발급 단계에서 403 으로 막는다 (올릴 기회 자체를 주지 않는다).\n" + "- 허용 형식: `image/png` · `image/jpeg` · `image/webp` · `image/heic` · `image/heif`. 한 장의 상한은 5MB 다.\n" + - "- 발급된 URL 은 5분 뒤 만료된다. 확정되지 않은 원본은 하루 뒤 자동 삭제된다.", + "- 발급된 URL 은 5분 뒤 만료된다. `imageKey` 는 내 계정 전용이라 다른 회원의 key 로는 확정할 수 없다.", ) @ApiResponses( value = [ @@ -177,12 +177,12 @@ interface UserApi { "내 정보(`nickname` · 프로필 이미지)를 한 요청(JSON)으로 **부분 수정**한다. " + "들어온 필드만 갱신하며, 둘 다 보내면 한 트랜잭션에 묶여 함께 반영되고, 아무 필드도 안 보내면 변화 없이 200 으로 통과한다.\n\n" + "**이미지는 이 요청에 바이트로 싣지 않는다.** `POST /users/me/profile-image` 로 발급받은 URL 에 클라가 " + - "S3 로 직접 올린 뒤, 그 `imageKey` 만 여기에 보낸다. 서버는 그 key 로 원본을 읽어 형식을 검증하고 최종 경로에 저장한다.\n\n" + + "S3 로 직접 올린 뒤, 그 `imageKey` 만 여기에 보낸다. 서버는 그 객체를 읽어 형식을 검증하고 그 URL 로 `profileImage` 를 바꾼다.\n\n" + "**필드별 권한·동작**\n\n" + "| 필드 | 권한 | 동작 |\n" + "|---|---|---|\n" + "| `nickname` | GUEST·MEMBER | 닉네임 변경 |\n" + - "| `imageKey` | **MEMBER 전용** | 업로드된 원본 검증 → S3 저장 → 그 URL 로 `profileImage` 갱신 |\n\n" + + "| `imageKey` | **MEMBER 전용** | 올린 객체의 형식 검증 뒤 그 URL 로 `profileImage` 갱신 |\n\n" + "- GUEST 가 `imageKey` 를 담아 호출하면 **403** 으로 거부한다 (닉네임 동반 여부와 무관하게 요청 전체 거부).\n" + "- 이미지 허용 형식: `png` / `jpeg` / `webp` / `heic` / `heif` (그 외는 발급 단계에서 400).", ) @@ -258,7 +258,7 @@ interface UserApi { responseCode = "502", description = "외부 의존성 실패 (이미지 저장소(S3) — 재시도 가능)\n\n" + - "- 업로드 상태 확인 실패 (STORAGE-003) · 원본 읽기 실패 (STORAGE-005) · 저장 실패 (STORAGE-001)", + "- 업로드 상태 확인 실패 (STORAGE-003) · 올린 이미지 읽기 실패 (STORAGE-005)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, diff --git a/src/main/kotlin/com/depromeet/piki/user/controller/UserApiExamples.kt b/src/main/kotlin/com/depromeet/piki/user/controller/UserApiExamples.kt index 54493770b..346e7ac76 100644 --- a/src/main/kotlin/com/depromeet/piki/user/controller/UserApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/user/controller/UserApiExamples.kt @@ -131,8 +131,7 @@ class UserApiExamples( // 실제 응답은 UserException.notFound() → USER-001 이므로 예외에서 직접 example 을 만들어 code·detail 을 실제와 일치시킨다. add(UserException.notFound(), name = "유저 없음 (JWT 유효하나 DB에 없음)") add(ImageStorageException.existsCheckFailed(), name = "이미지 저장소(S3) 업로드 상태 확인 실패") - add(ImageStorageException.downloadFailed(), name = "이미지 저장소(S3) 원본 읽기 실패") - add(ImageStorageException.uploadFailed(), name = "이미지 저장소(S3) 저장 실패") + add(ImageStorageException.downloadFailed(), name = "이미지 저장소(S3) 올린 이미지 읽기 실패") } handlerMethod.binds(UserController::withdraw) -> @@ -176,9 +175,7 @@ class UserApiExamples( operation } - // 발급 example 의 raw key — 실제 발급 형식(items/raw/{UUID}.{ext})과 같은 모양으로 둔다. - - private val SAMPLE_IMAGE_KEY = "items/raw/550e8400-e29b-41d4-a716-446655440000.png" + private val SAMPLE_IMAGE_KEY = "profiles/7c9e6679-7425-40de-944b-e07fc1f90ae7/550e8400-e29b-41d4-a716-446655440000.png" private fun sampleUser(): UserResponse = diff --git a/src/main/kotlin/com/depromeet/piki/user/controller/dto/UserUpdateRequest.kt b/src/main/kotlin/com/depromeet/piki/user/controller/dto/UserUpdateRequest.kt index e0a90a8ed..5ac898193 100644 --- a/src/main/kotlin/com/depromeet/piki/user/controller/dto/UserUpdateRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/user/controller/dto/UserUpdateRequest.kt @@ -14,7 +14,7 @@ data class UserUpdateRequest( // 형식·존재·실제 내용 검증은 서버가 이 key 로 raw 를 읽어 확정 단계에서 수행한다. @field:Schema( description = "업로드를 마친 프로필 이미지의 key (선택). POST /users/me/profile-image 로 발급받은 값을 그대로 보낸다", - example = "items/raw/550e8400-e29b-41d4-a716-446655440000.png", + example = "profiles/7c9e6679-7425-40de-944b-e07fc1f90ae7/550e8400-e29b-41d4-a716-446655440000.png", nullable = true, ) val imageKey: String? = null, diff --git a/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt b/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt index 01b32c669..969c79776 100644 --- a/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt +++ b/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.user.service import com.depromeet.piki.common.storage.ImageStorage +import com.depromeet.piki.image.domain.ProfileImageKey import com.depromeet.piki.image.domain.UploadSize import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload @@ -12,16 +13,13 @@ import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID -// 내 정보 수정(PATCH /me) 오케스트레이션. 이미지는 클라가 presigned URL 로 S3 에 직접 올리고(서버를 안 거친다), -// 확정 요청은 그 raw key 만 싣는다. 서버는 raw 를 읽어 형식을 검증한 뒤 최종 경로에 저장한다. +// 내 정보 수정(PATCH /me) 오케스트레이션. 이미지는 클라가 presigned URL 로 S3 의 profiles/{userId}/ 에 직접 올리고, +// 확정 요청은 그 key 만 싣는다. 서버는 그 객체를 읽어 형식을 검증한 뒤 그 URL 을 프로필로 삼는다. // // 서버가 바이트를 읽는 이유: ProfileImageFile 의 매직바이트 교차검증(선언 MIME 과 실제 내용 일치)은 바이트를 // 봐야만 가능하다. 그럼에도 multipart 보다 나은 건, 느린 회선의 업로드를 우리 톰캣 스레드가 아니라 // 클라 <-> S3 구간이 감당하고 우리는 리전 내에서 짧게 한 번 읽기 때문이다. // -// 최종 경로를 서버가 정하는 이유: 확정본은 profiles/{userId}/ 아래여야 탈퇴 cascade 의 prefix 파기가 닿는다 -// (WithdrawalService). raw 에 그대로 두면 탈퇴 후에도 얼굴 사진이 남는다. -// // 영속화는 UserService.updateProfile(@Transactional) 의 짧은 트랜잭션에 위임한다 // (## 트랜잭션 경계 — 외부 호출은 트랜잭션 밖, self-invocation 회피를 위해 별도 빈으로 분리). @Service @@ -40,7 +38,8 @@ class ProfileUpdateService( contentLength: Long?, ): PresignedRawUpload { requireMemberForProfileImage(userId) - return imagePresignService.presignRawUpload( + return imagePresignService.presignProfileUpload( + owner = userId, extension = ProfileImageFile.extensionForMimeType(contentType), contentType = contentType, size = UploadSize.of(contentLength), @@ -57,26 +56,15 @@ class ProfileUpdateService( // 권한을 내려받기·검증보다 먼저 본다 — 게스트의 요청은 내용과 무관하게 403 으로 끊는다 // (authorization before payload processing). 발급 단계에서 이미 막지만, 확정도 스스로 방어한다. requireMemberForProfileImage(userId) - // 우리가 발급한 key 형식인지 + 실제로 올라왔는지. 아니면 400/502 로 여기서 끝난다. - imagePresignService.verifyUploaded(listOf(imageKey)) - // 발급 때 우리가 붙인 확장자로 "클라가 선언했던" MIME 을 되찾아, 실제 바이트와 일치하는지 교차검증한다. - val declaredMimeType = ProfileImageFile.mimeTypeForExtension(imagePresignService.extensionOf(imageKey)) - val profileImage = ProfileImageFile.of(imageStorage.download(imageKey), declaredMimeType) - - val key = "$PROFILE_PREFIX$userId/${UUID.randomUUID()}.${profileImage.extension}" - // 업로드부터 영속화까지를 한 묶음으로 보고, 어느 단계에서 떨어지든 이 key 를 회수한다. 영속화가 떨어지면 - // 방금 올린 객체가 아무도 안 가리키는 orphan 으로 남고(닉네임 중복 409 가 흔한 트리거), 활성 확인과 영속화 - // 사이에 탈퇴가 커밋되면 탈퇴 cascade 의 prefix 파기가 이미 지나간 뒤라 프로필 사진(얼굴 등 PII)이 계속 남는다. - // upload 가 던진 경우도 포함한다 — 응답 유실·timeout 이면 S3 에는 객체가 올라갔을 수 있다. key 는 우리가 - // 만든 값이라 업로드 성공 여부와 무관하게 삭제를 걸 수 있고, 객체가 없으면 no-op 이라 안전하다. - return runCatching { - val url = imageStorage.upload(profileImage.bytes, key, profileImage.mimeType) // 트랜잭션 밖, 실패 시 502 - log.info("프로필 이미지 업로드 완료: userId={}, key={}", userId, key) - userService.updateProfile(userId, nickname, url) - }.onFailure { deleteQuietly(key) } + val key = ProfileImageKey.parse(imageKey, owner = userId) + imagePresignService.verifyUploaded(listOf(key)) + val bytes = imageStorage.download(key.value) + // 발급 때 우리가 붙인 확장자로 클라가 선언했던 MIME 을 되찾아 실제 바이트와 맞춘다. 어긋나면 이미지가 아닌 파일이 공개 버킷에 남지 않게 지운다 + runCatching { ProfileImageFile.of(bytes, ProfileImageFile.mimeTypeForExtension(key.extension)) } + .onFailure { deleteQuietly(key.value) } .getOrThrow() - // 확정본이 자리를 잡은 뒤에만 raw 를 회수한다. best-effort 라 실패하면 raw 가 남는다 - .also { deleteQuietly(imageKey) } + // 저장이 실패해도 객체는 남긴다. 같은 키로 다시 확정할 수 있어야 하고 탈퇴 때 접두째 지워진다 + return userService.updateProfile(userId, nickname, imageStorage.urlOf(key.value)) } // 프로필 이미지 변경은 MEMBER 전용 — 게스트는 PII 를 갖지 않는다. @@ -85,15 +73,9 @@ class ProfileUpdateService( if (user.identityType != IdentityType.MEMBER) throw UserException.guestCannotUpdateProfileImage() } - // 보상 삭제는 best-effort — 회수 자체가 실패해도 원래 예외(409 등)를 가리지 않도록 삼키고 로그만 남긴다. - // delete 는 객체가 없어도 no-op(멱등)이라 언제 불러도 안전하다. + // 회수 실패가 원래 예외(400)를 가리지 않게 삼킨다 private fun deleteQuietly(key: String) { runCatching { imageStorage.delete(key) } - .onFailure { e -> log.warn("프로필 이미지 {} 회수 실패(orphan 잔존, lifecycle 대상): {}", key, e.message) } - } - - companion object { - // 탈퇴 cascade 가 통째로 파기하는 prefix(WithdrawalService 의 deleteByPrefix 와 같은 값이어야 한다). - const val PROFILE_PREFIX = "profiles/" + .onFailure { e -> log.warn("프로필 이미지 {} 회수 실패(탈퇴 때 접두째 지워짐): {}", key, e.message) } } } diff --git a/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalPersistenceService.kt index 57804253b..54041be5e 100644 --- a/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalPersistenceService.kt @@ -5,6 +5,7 @@ import com.depromeet.piki.notification.repository.NotificationRepository import com.depromeet.piki.user.domain.UserException import com.depromeet.piki.user.repository.UserDetailRepository import com.depromeet.piki.user.repository.UserRepository +import com.depromeet.piki.wishlist.repository.WishMemoRepository import com.depromeet.piki.wishlist.repository.WishRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -20,6 +21,7 @@ class WithdrawalPersistenceService( private val userDetailRepository: UserDetailRepository, private val userDeviceRepository: UserDeviceRepository, private val wishRepository: WishRepository, + private val wishMemoRepository: WishMemoRepository, private val notificationRepository: NotificationRepository, private val defaultProfileImages: DefaultProfileImages, ) { @@ -60,6 +62,7 @@ class WithdrawalPersistenceService( // 4. wishes 하드삭제 — 개인 데이터 즉시 파기(PIPA "지체없이 파기"). 위시는 다른 데이터가 참조하지 않아 // 즉시 지워도 무결성 문제가 없다(공유 참조 보존은 tombstone users 행이 담당). + wishMemoRepository.hardDeleteAllByUserId(userId) val wishes = wishRepository.hardDeleteAllByUserId(userId) // 5. notifications 하드삭제 — 수신자 알림 즉시 파기. diff --git a/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalService.kt b/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalService.kt index f245b0a2b..77b4d4c4c 100644 --- a/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalService.kt +++ b/src/main/kotlin/com/depromeet/piki/user/service/WithdrawalService.kt @@ -3,6 +3,7 @@ package com.depromeet.piki.user.service import com.depromeet.piki.auth.infrastructure.redis.RefreshTokenStore import com.depromeet.piki.auth.infrastructure.redis.WithdrawnTokenStore import com.depromeet.piki.common.storage.ImageStorage +import com.depromeet.piki.image.domain.ProfileImageKey import com.depromeet.piki.notification.sse.LocalSseDelivery import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.UserException @@ -63,7 +64,7 @@ class WithdrawalService( // 4. S3 프로필 이미지 파기 — best-effort, 트랜잭션 밖. tombstone 이 users.profile_image 를 기본 아바타로 // 덮어 DB 포인터는 이미 정리됐고, 여기선 S3 객체 자체(얼굴 등 PII)를 prefix 통째로 삭제한다(PIPA·Apple 5.1.1(v)). // 객체가 없으면 no-op 이라 항상 호출해도 안전. 실패해도 탈퇴 자체는 성공 처리하고 후속 정리 대상으로 로그만 남긴다. - runCatching { imageStorage.deleteByPrefix("${ProfileUpdateService.PROFILE_PREFIX}$userId/") } + runCatching { imageStorage.deleteByPrefix(ProfileImageKey.prefixOf(userId)) } .onFailure { e -> log.warn("탈퇴 S3 프로필 정리 실패(후속 정리 대상) userId={}", userId, e) } log.info("회원 탈퇴 완료 userId={}", userId) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt index 547172d8b..3f7e86d6a 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -5,6 +5,7 @@ import com.depromeet.piki.image.controller.dto.ConfirmImageUploadRequest import com.depromeet.piki.image.controller.dto.PresignedImageUploadRequest import com.depromeet.piki.image.controller.dto.PresignedImageUploadResponse import com.depromeet.piki.metrics.registration.ExternalEntry +import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.wishlist.controller.dto.WishDetailResponse import com.depromeet.piki.wishlist.controller.dto.WishItemResponse import com.depromeet.piki.wishlist.controller.dto.WishlistRegisterRequest @@ -22,9 +23,6 @@ import org.springframework.http.MediaType import org.springframework.web.multipart.MultipartFile import java.util.UUID -// 아이템 등록 한도(#339) 응답 설명. 위시 등록 계열 4개 엔드포인트가 같은 문구를 쓰므로 한 곳에 둔다. -// 세는 단위가 요청 수가 아니라 item 수라는 점, 그리고 몫이 위시 전용이 아니라 계정 전체 몫이라는 점이 -// 클라이언트 계약의 핵심이라 명시한다. private const val RATE_LIMIT_DESCRIPTION = "아이템 등록 한도 초과 (code: ITEM-006). 몫은 위시 전용이 아니라 계정 하나의 몫이라, 이 사용자가 소유한 " + "토너먼트에 아이템이 추가된 양(참여 게스트가 넣은 것 포함)도 같은 몫에서 빠진다. 한도는 요청 수가 아니라 " + @@ -32,7 +30,6 @@ private const val RATE_LIMIT_DESCRIPTION = "남은 몫이 있으면 그보다 큰 요청도 통과하므로(마지막 한 번은 성공) 이 응답은 몫을 이미 다 쓴 뒤부터 나온다. " + "Retry-After 헤더에 한도가 풀리기까지 남은 시간(초)이 실린다." -// 전역 가용량 소진(#927) 응답 설명. 429 와 원인이 다르다 — 요청자가 자기 몫을 다 쓴 것이 아니라 서비스가 꽉 찼다. private const val CAPACITY_DESCRIPTION = "서비스 전체의 시간당 처리 가용량 소진 (code: COMMON-SERVER-BUSY). 요청자가 자기 몫을 다 쓴 429 와 달리, " + "서비스가 감당하기로 정한 총량이 찬 상태라 같은 시각 모든 사용자에게 동일하게 나간다. 정상 운영에서는 " + @@ -44,23 +41,18 @@ interface WishlistApi { @Operation( summary = "위시리스트 등록 (URL)", description = """ - 상품 페이지 URL 을 받아 위시리스트에 등록한다. 메타데이터(이름/가격/이미지) 추출은 외부 LLM 호출이라 - 오래 걸리므로 동기로 기다리지 않는다. 등록 즉시 item.status=PENDING 인 항목을 201 로 반환하고, - 실제 파싱은 백그라운드 디스패처가 PENDING 을 집어 PROCESSING 으로 전이한 뒤 READY(완료) 또는 FAILED(파싱 실패) 로 전이한다. - 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 status 변화(PENDING→PROCESSING→READY/FAILED, 완료·실패 알림)를 통보받고 위시리스트를 재조회해 확인한다. URL 형식 오류는 등록 전에 400 으로 거른다. + 상품 페이지 URL 을 받아 위시리스트에 등록한다. 메타데이터(이름/가격/이미지) 추출은 + 오래 걸리므로 동기로 기다리지 않는다. 등록 즉시 item.status=PARSING(item.id 는 null) 인 항목을 201 로 반환하고, + 백그라운드 파싱이 성공하면 상품이 확정돼 READY 또는 INCOMPLETE, 실패하면 FAILED 가 된다. + 확정된 상품을 내 다른 위시가 이미 갖고 있으면 이 위시는 그 위시에 합쳐져 사라지고(메모는 옮겨진다) ITEM_PARSING_MERGED 알림이 온다. + 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 알림을 받고 위시리스트를 재조회해 확인한다. URL 형식 오류는 등록 전에 400 으로 거른다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "201", - description = "위시리스트 등록 완료 — 세 모양이 있다. " + - "(1) 새 파싱 접수: item.status=PENDING, reused=false. 파싱은 백그라운드. " + - "(2) 진행 중 파싱 합류: 같은 상품의 파싱이 이미 돌고 있어 그 결과를 함께 기다린다. " + - "item.status=PENDING 또는 PROCESSING, reused=false. " + - "(3) 기존 값 재사용: 다른 등록이 만든 완성 값(캐시)에 파싱 없이 붙어 즉시 item.status=READY, reused=true. " + - "그 값이 낡았으면(서버 기준 24시간) refreshNeeded=true — 클라가 \"새로운 정보로 가져올까요?\" 를 물어 " + - "사용자가 원할 때 새로고침 API 로 재추출한다", + description = "위시리스트 등록 접수 (item.status=PARSING, item.id=null, 파싱은 백그라운드)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -71,7 +63,7 @@ interface WishlistApi { ApiResponse( responseCode = "400", description = - "잘못된 요청 (URL 이 비어 있음 · 2048자 초과 — code: COMMON-INVALID-INPUT · " + + "잘못된 요청 (URL 이 비어 있음 · ${ProductLink.MAX_LENGTH}자 초과 — code: COMMON-INVALID-INPUT · " + "유효한 URL 형식이 아님 — code: LINK-001 · https 외 스킴 — code: LINK-002 · " + "지원하지 않는 쇼핑몰 — code: LINK-003)", content = [ @@ -105,7 +97,8 @@ interface WishlistApi { responseCode = "409", description = "이미 위시리스트에 등록된 상품 (같은 상품을 다시 담음 — code: WISH-009, " + - "`data.wishId` 에 이미 담긴 그 위시의 id 가 실린다) · " + + "`data.wishId` 에 이미 담긴 그 위시의 id 가 실린다. 확정된 내 위시의 상품과 주소가 같거나, " + + "같은 링크로 담은 내 위시가 있으면 파싱 중·실패 상태여도 해당한다) · " + "탈퇴한 계정 (JWT 는 아직 유효하나 계정이 탈퇴 상태 — code: USER-003)", content = [ Content( @@ -172,9 +165,11 @@ interface WishlistApi { cursor 페이지네이션: 직전 응답의 pageResponse.nextCursor 를 다음 요청 cursor 로 그대로 전달한다. 마지막 페이지면 nextCursor 는 null, hasNext 는 false. size 는 미지정 시 20, 1~50 범위를 벗어나면 양 끝으로 보정된다. - 각 항목의 item.status 로 파싱 상태(PENDING/PROCESSING/READY/INCOMPLETE/FAILED)를 구분한다 — - 등록 직후 PENDING·PROCESSING 인 항목은 SSE(`/api/v1/notifications/subscribe`)로 READY/INCOMPLETE/FAILED 전이를 통보받고 이 조회로 확인한다. - INCOMPLETE 는 추출이 일부 필드만 채운 상태다 — 빈 필드를 수기 수정(PATCH)으로 채우면 READY 가 된다. + 각 항목의 item.status 로 상태(PARSING/FAILED/READY/INCOMPLETE)를 구분한다 — + PARSING 인 항목은 SSE(`/api/v1/notifications/subscribe`)로 결과를 통보받고 이 조회로 확인한다. + 같은 상품의 다른 위시에 합쳐진 항목은 목록에서 사라진다(ITEM_PARSING_MERGED). + item.refreshing=true 면 새로고침 중이고, 값과 status 는 새로고침 전 것이다. + INCOMPLETE 는 추출이 일부 필드만 채운 상태다 — 빈 필드를 사용자 입력(PATCH)으로 채우면 READY 가 된다. """, ) @ApiResponses( @@ -246,13 +241,13 @@ interface WishlistApi { 상세 화면은 이 API 하나로 그린다. 본인 위시만 조회할 수 있다. - item: 화면에 그대로 표시하는 값. 목록 조회의 item 과 같은 규칙으로 파생돼 두 화면에서 같게 보인다. - 값이 비어 있으면 status 로 갈린다 (PENDING·PROCESSING 은 대기, FAILED 는 수기 입력 유도이며 새로고침이 거부된다). + 값이 비어 있으면 status 로 갈린다 (PARSING 은 대기, FAILED 는 사용자 입력 유도이며 새로고침이 거부된다). 상태 전이 통보는 SSE(`/api/v1/notifications/subscribe`)로 받는다. - priceHistory: 이 상품의 가격 기록을 최신순 최대 50건. 서버 추출값과 사용자 입력값이 함께 들어가고 - source·editedByMe 로 구분한다. + userInput·editedByMe 로 구분한다. - **지금 보이는 값은 항상 item 에서 읽는다.** priceHistory 는 표시값 선택 규칙을 타지 않아 첫 항목이 item 과 다를 수 있다. - memo: 본인만 보는 개인 메모(최대 100자). 같은 상품을 담은 다른 사용자와 공유되지 않으며, - 상세 응답에만 내려간다(목록 응답에는 없다). 수정은 수기 수정 API(PATCH)의 memo 필드로 한다. + 상세 응답에만 내려간다(목록 응답에는 없다). 수정은 사용자 입력 API(PATCH)의 memo 필드로 한다. """, ) @ApiResponses( @@ -315,26 +310,36 @@ interface WishlistApi { ): ApiResponseBody @Operation( - summary = "위시 항목 수기 수정", + summary = "위시 항목 사용자 입력", description = """ - 위시 항목의 상품 정보를 사용자가 직접 수정한다(multipart/form-data). 상태 제한이 없다 — - 추출 실패(FAILED) 복구뿐 아니라 완료(READY)·진행 중(PENDING·PROCESSING) 항목도 언제든 수정할 수 있다. + 위시 항목의 상품 정보를 사용자가 직접 입력한다(multipart/form-data). 텍스트(이름·현재가·통화)는 form 필드로, 이미지는 image 파트로 받는다 — 이미지는 URL 이 아니라 파일로만 받아 서버가 가공 없이 그대로 S3 에 올려 대표 이미지로 쓴다. - 수정은 기존 버전을 고치지 않고 **수기(MANUAL) 새 버전**으로 쌓인다 — 들어온 값은 현재 버전 값 위에 병합되고, - 이 위시의 표시가 그 버전으로 바뀐다. 진행 중이던 파싱은 계속돼 완료 시 이력으로 남는다. - 본인 위시만 수정 가능하며, item 을 직접 노출하지 않고 위시 소유 단위로 권한을 검증한다. + 입력은 기존 버전을 고치지 않고 **사용자 입력 새 버전**으로 쌓이며, 이 위시의 표시값이 그 버전으로 바뀐다. + 본인 위시만 수정할 수 있다. + + **응답의 wish.id 가 요청한 wishId 와 다를 수 있다.** FAILED 위시에 입력해 정해진 상품을 내 다른 위시가 이미 갖고 있으면, + 요청한 위시는 그 위시에 합쳐져 사라지고(메모는 옮겨진다) 응답에는 남은 위시가 실린다. 클라이언트는 요청한 카드를 지우고 + 응답의 wish.id 로 화면을 갱신한다. + + **보내지 않은 필드는 지금 보이는 값을 이어 쓴다.** 합친 결과에 이름·가격·이미지가 모두 있어야 하고, + 하나라도 빠지면 400 이다. 그래서 item.status 에 따라 보내야 하는 필드가 다르다. + - READY: 바꿀 필드만 + - INCOMPLETE: 바꿀 필드와 비어 있는 필드 + - FAILED: 이름·가격·이미지 전부 (이어 쓸 값이 없다) + - PARSING 이거나 item.refreshing=true: 받지 않는다 (409, code: ITEM-008). 결과가 오기 전에 입력하면 + 뒤늦게 끝난 파싱과 엇갈린다. 클라이언트는 이 동안 입력 화면을 막는다고 전제하고, 409 는 그 사이 상태가 바뀐 경합을 막는다. **memo(개인 메모, 최대 100자)** 도 이 API 로 수정한다 — 미전송(null)이면 미변경, 빈 문자열이면 삭제. - 메모는 상품 정보와 달리 버전으로 쌓이지 않는 위시 개인 필드라, **memo 만 보내면 새 버전을 만들지 않고** - 메모만 바뀐다(이때 병합 필수값 검증도 없다). 메모는 상세 조회 응답에서만 확인한다. + 메모는 상품 정보가 아니라 위시 개인 필드라, **memo 만 보내면 상품의 새 버전을 만들지 않고** + 메모만 바뀐다(이때는 필수값 검증도, 파싱 중 거부도 없다). 메모는 상세 조회 응답에서만 확인한다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "수기 수정 성공 — 수기(MANUAL) 새 버전이 이 위시의 표시 버전이 됨 (memo 만 수정한 경우 버전은 그대로)", + description = "입력 성공 — 사용자 입력 새 버전이 이 위시의 표시값이 됨 (memo 만 보낸 경우 버전은 그대로). 다른 위시에 합쳐지면 남은 위시를 반환", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -345,8 +350,8 @@ interface WishlistApi { ApiResponse( responseCode = "400", description = - "잘못된 요청 (보정 후에도 상품명 없음 — code: ITEM-003 · 가격 없이 READY 전환 — code: ITEM-004 · " + - "이미지 없이 READY 전환 — code: ITEM-005 · price 음수 · name/currency/memo 길이 초과 · " + + "잘못된 요청 (바꿀 값이 하나도 없음(메모도 없음) — code: ITEM-009 · 합친 뒤에도 상품명 없음 — code: ITEM-003 · 합친 뒤에도 가격 없음 — code: ITEM-004 · " + + "합친 뒤에도 이미지 없음 — code: ITEM-005 · price 음수 · name/currency/memo 길이 초과 · " + "빈 이미지 — code: PRODUCTIMAGE-001 · 이미지 Content-Type 없음 — code: PRODUCTIMAGE-002 · " + "지원하지 않는 이미지 형식(png/jpeg/webp/heic/heif만 허용) — code: PRODUCTIMAGE-003)", content = [ @@ -388,7 +393,7 @@ interface WishlistApi { ), ApiResponse( responseCode = "409", - description = "탈퇴한 계정 (code: USER-003)", + description = "파싱 중이거나 새로고침 중인 항목에 상품 정보 입력 — code: ITEM-008 · 탈퇴한 계정 — code: USER-003", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -418,21 +423,21 @@ interface WishlistApi { @Operation( summary = "위시 항목 새로고침 (링크 재추출)", description = """ - 위시 항목의 상품 정보를 원본 링크로 다시 추출해 최신(가격·이미지 등)으로 새로고침한다. 추출은 외부 LLM 호출이라 - 동기로 기다리지 않는다 — 새 추출 버전(item.status=PENDING)을 즉시 활성으로 띄워 200 으로 반환하고, 백그라운드 디스패처가 - 집어 PROCESSING→READY(완료)/FAILED(실패) 로 전이한다. 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 결과를 통보받는데, 등록과 달리 + 위시 항목의 상품 정보를 원본 링크로 다시 추출해 최신(가격·이미지 등)으로 새로고침한다. 추출은 오래 걸려 + 동기로 기다리지 않는다 — item.refreshing=true 로 200 을 반환하고, 그동안 값과 status 는 새로고침 전 것을 유지한다. + 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 결과를 통보받는데, 등록과 달리 완료·실패는 `ITEM_REFRESH_COMPLETED` / `ITEM_REFRESH_FAILED` 로 온다(#1036). 일부 필드만 채워진 경우는 등록과 같은 `ITEM_PARSING_INCOMPLETE` 다. - 이미 새로고침이 진행 중(PENDING·PROCESSING)이면 새 추출을 만들지 않고 현재 진행 상태를 그대로 반환한다(멱등). - 새로고침은 성공(READY) 항목의 재추출 전용이다. 추출에 실패(FAILED)한 항목은 새로고침 대신 보정으로 복구한다(409). - 링크가 없는 항목(이미지로 등록한 위시)은 재추출 입력이 없어 새로고침할 수 없다(400). 본인 위시만 가능하다. - 옛 추출 버전은 보존돼, 이 위시를 토너먼트에 출전시켜 둔 경우 출전 시점 정보가 새로고침에 영향받지 않는다. + 이미 새로고침이 진행 중이거나 등록 파싱이 아직 끝나지 않은 항목(PARSING)이면 새 추출을 만들지 않고 현재 상태를 그대로 반환한다(멱등). + 새로고침은 상품이 확정된 항목(READY·INCOMPLETE)의 재추출 전용이다. 추출에 실패(FAILED)한 항목은 새로고침 대신 사용자 입력(PATCH)으로 복구한다(409). + 이미지로 등록해 상품이 확정된 항목은 재추출할 링크가 없어 새로고침할 수 없다(400). 본인 위시만 가능하다. + 이 상품을 토너먼트에 출전시켜 둔 경우 시작 전 출전 카드에는 새 값이 보이고, 시작한 토너먼트의 출전 값은 시작 시각 기준이라 바뀌지 않는다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "새로고침 접수 (새 추출 버전 item.status=PENDING, 파싱은 백그라운드) — 이미 진행 중이면 현재 진행 상태를 그대로 반환(멱등)", + description = "새로고침 접수 (item.refreshing=true, 파싱은 백그라운드) — 이미 진행 중이거나 등록 파싱 중(PARSING)이면 현재 상태를 그대로 반환(멱등)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -442,7 +447,7 @@ interface WishlistApi { ), ApiResponse( responseCode = "400", - description = "링크가 없는 항목(이미지로 등록한 위시)은 재추출 입력이 없어 새로고침할 수 없음", + description = "이미지로 등록해 상품이 확정된 항목은 재추출할 링크가 없어 새로고침할 수 없음 (code: ITEM-007). 실패한 이미지 항목은 409", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -482,7 +487,7 @@ interface WishlistApi { ), ApiResponse( responseCode = "409", - description = "추출 실패(FAILED) 항목은 새로고침 대상이 아님 (보정으로 복구) · 새로고침은 성공(READY) 항목 전용 · 탈퇴한 계정(code: USER-003)", + description = "추출 실패(FAILED) 항목은 새로고침 대상이 아님 (사용자 입력으로 복구) · 새로고침은 READY·INCOMPLETE 항목 전용 · 탈퇴한 계정(code: USER-003)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -665,8 +670,8 @@ interface WishlistApi { 원본 바이트가 서버를 경유하지 않아 서버 대역·메모리를 쓰지 않는다. content-type 과 contentLength 는 서명에 묶인다 — 클라는 각 uploadUrl 로 같은 Content-Type·Content-Length 로만 PUT 할 수 있고(S3 가 강제), imageKey 들을 2단계(/images/confirm)로 되돌려준다. 한 장의 상한은 5MB 다. - 발급 시점에는 Wish·Item 을 만들지 않는다(확정 단계에서 생성). - 확정이 오지 않은 업로드는 등록되지 않고 S3 lifecycle 이 지운다. + 발급 시점에는 위시를 만들지 않는다. 위시는 확정 단계에서 생기고, 상품은 그 뒤 파싱 성공이나 사용자 입력으로 정해진다. + 확정이 오지 않은 업로드는 등록되지 않는다. """, ) @ApiResponses( @@ -778,17 +783,18 @@ interface WishlistApi { @Operation( summary = "위시리스트 이미지 등록 v2 - 업로드 확정", description = """ - 이미지 등록의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PENDING 위시로 즉시 등록하고 목록을 반환한다. - key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 URL 등록과 같은 작업 큐에 적재하며, 이후 추출(Gemini Vision)·전이(READY/FAILED) 흐름도 같다. + 이미지 등록의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PARSING 위시로 즉시 등록하고 목록을 반환한다. + key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 URL 등록과 같은 작업 큐에 적재하며, 이후 추출·전이(READY/INCOMPLETE/FAILED) 흐름도 같다. 결과 모양(WishItemResponse)은 URL 등록과 동일하다 — 다만 URL 이 없어 sourceUrl 이 null 이며, - 추출 결과는 SSE(`/api/v1/notifications/subscribe`)로 통보받아 재조회하고, 추출 실패(FAILED) 항목은 보정 API(PATCH)로 복구한다. + 추출 결과는 SSE(`/api/v1/notifications/subscribe`)로 통보받아 재조회하고, 추출 실패(FAILED) 항목은 사용자 입력 API(PATCH)로 복구한다. + 남아 있는 위시로 이미 확정한 imageKey 는 다시 등록하지 않고 응답에서도 빠진다. 그래서 요청보다 적은 항목이나 빈 목록이 올 수 있다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "201", - description = "이미지 등록 접수 — 각 항목이 PENDING 상태로 생성되고 비동기 파싱이 시작된다", + description = "이미지 등록 접수 — 각 항목이 PARSING 상태로 생성되고 비동기 파싱이 시작된다", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt index 8f6788b37..161ffc7b8 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -1,6 +1,5 @@ package com.depromeet.piki.wishlist.controller -import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.common.exception.AlreadyRegisteredException import com.depromeet.piki.common.exception.CommonErrorCode import com.depromeet.piki.common.openapi.OpenApiObjectMapper @@ -10,12 +9,13 @@ import com.depromeet.piki.common.ratelimit.ItemQuotaException import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.response.PageResponse import com.depromeet.piki.common.storage.ImageStorageException +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.image.controller.dto.PresignedImageUpload import com.depromeet.piki.image.controller.dto.PresignedImageUploadResponse import com.depromeet.piki.image.domain.ImageUploadException import com.depromeet.piki.image.domain.ProductImageException +import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.domain.ItemException -import com.depromeet.piki.item.domain.ItemSnapshotSource import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.product.domain.ProductLinkException import com.depromeet.piki.user.domain.UserException @@ -45,20 +45,15 @@ class WishlistApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.CREATED, - name = "등록 접수 (파싱 대기 — PENDING)", - payload = ApiResponseBody.created(pendingRegistrationEntry), - ) - add( - status = HttpStatus.CREATED, - name = "기존 값 재사용 (캐시 — reused=true, 낡은 값이라 새로 가져오기 권고)", - payload = ApiResponseBody.created(reusedRegistrationEntry), + name = "등록 접수 (파싱 중 — PARSING)", + payload = ApiResponseBody.created(pendingSampleEntry), ) add(ProductLinkException.invalidFormat(urlFormatCause), name = "유효하지 않은 URL 형식") add(ProductLinkException.unsupportedScheme(), name = "https 외 스킴") add(ProductLinkException.unsupportedPlatform(), name = "지원하지 않는 쇼핑몰 (차단 목록은 백오피스 도메인 접근 정책 기준)") add( AlreadyRegisteredException.wish(WishErrorCode.ALREADY_EXISTS, EXAMPLE_WISH_ID), - name = "이미 위시리스트에 등록된 상품 (공유 정체성 기준)", + name = "이미 위시리스트에 등록된 상품", ) unauthorized() add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") @@ -71,10 +66,10 @@ class WishlistApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.OK, - name = "조회 성공 (대기·담는 중 + 완성 혼재, 마지막 페이지)", + name = "조회 성공 (파싱 중 + 완성 혼재, 마지막 페이지)", payload = ApiResponseBody.ok( - data = listOf(pendingSampleEntry, processingSampleEntry, sampleEntry), + data = listOf(pendingSampleEntry, sampleEntry), pageResponse = PageResponse(nextCursor = null, hasNext = false), ), ) @@ -113,7 +108,7 @@ class WishlistApiExamples( add( status = HttpStatus.OK, name = "본인이 직접 고친 항목 (입력값이 이력 맨 앞)", - payload = ApiResponseBody.ok(manualEditedDetailSample), + payload = ApiResponseBody.ok(userInputDetailSample), ) add( status = HttpStatus.OK, @@ -131,8 +126,8 @@ class WishlistApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.OK, - name = "FAILED 보정 성공 (READY 로 복구)", - payload = ApiResponseBody.ok(sampleEntry), + name = "FAILED 항목에 사용자 입력 (READY 로 복구)", + payload = ApiResponseBody.ok(userInputEntry), ) add( status = HttpStatus.OK, @@ -158,9 +153,11 @@ class WishlistApiExamples( detail = WishlistUpdateRequest.MEMO_MAX_MESSAGE, ), ) - add(ItemException.nameRequiredForReady(), name = "병합 후에도 상품명 없음 (빈 항목에 일부 필드만 수정)") - add(ItemException.priceRequiredForReady(), name = "병합 후에도 가격 없음") - add(ItemException.imageRequiredForReady(), name = "병합 후에도 이미지 없음") + add(ItemException.emptyUserInput(), name = "바꿀 값이 하나도 없음") + add(ItemException.nameRequiredForReady(), name = "합친 뒤에도 상품명 없음 (FAILED 항목에 일부 필드만 입력)") + add(ItemException.priceRequiredForReady(), name = "합친 뒤에도 가격 없음") + add(ItemException.imageRequiredForReady(), name = "합친 뒤에도 이미지 없음") + add(ItemException.parsingInProgress(), name = "파싱 중이거나 새로고침 중인 항목") // ProductImage.of 의 형식 검증 3종 — S3 업로드 전에 동기로 거른다. add(ProductImageException.emptyImage(), name = "빈 이미지 파일") add(ProductImageException.unknownType(), name = "이미지 형식을 확인할 수 없음") @@ -177,16 +174,16 @@ class WishlistApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.OK, - name = "새로고침 접수 (새 추출 버전 — PENDING)", - payload = ApiResponseBody.ok(pendingSampleEntry), + name = "새로고침 접수 (refreshing=true, 값과 status 는 새로고침 전 것)", + payload = ApiResponseBody.ok(refreshingSampleEntry), ) add( status = HttpStatus.OK, name = "이미 새로고침 진행 중 (멱등 — 현재 진행 상태)", - payload = ApiResponseBody.ok(processingSampleEntry), + payload = ApiResponseBody.ok(refreshingSampleEntry), ) - add(WishException.notRefreshable(), name = "링크 없는 항목(이미지 등록) — 새로고침 불가") - add(WishException.failedNotRefreshable(), name = "추출 실패(FAILED) 항목 — 보정으로 복구") + add(ItemException.notRefreshable(), name = "이미지로 등록한 항목은 새로고침 불가") + add(WishException.failedNotRefreshable(), name = "추출 실패(FAILED) 항목 — 사용자 입력으로 복구") add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") add(WishException.forbiddenWishItems(), name = "본인 위시 아님") @@ -247,7 +244,7 @@ class WishlistApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.CREATED, - name = "이미지 등록 접수 (PENDING, 다건)", + name = "이미지 등록 접수 (PARSING, 다건)", payload = ApiResponseBody.created(imagePendingEntries), ) add(WishException.invalidImageCount(), name = "이미지 개수 위반 (1~5개 아님)") @@ -273,8 +270,8 @@ class WishlistApiExamples( // 전역 가용량 소진(#927). 요청자의 몫과 무관하게 서비스 전체가 찬 상태라 503 이고, code 도 도메인이 아닌 공통이다. private val capacityExceeded = ItemQuotaException.capacityExceeded(900) - // 상세 조회 — 지금 보이는 값(item)과 그 상품의 가격 기록. 서버 추출값 사이에 타인이 넣은 수기(89,000원)가 섞여 있어, - // source·editedByMe 로 구분해 그리는 예시다. + // 상세 조회 — 지금 보이는 값(item)과 그 상품의 가격 기록. 서버 추출값 사이에 타인이 넣은 값(89,000원)이 섞여 있어, + // userInput·editedByMe 로 구분해 그리는 예시다. private val wishDetailSample = WishDetailResponse( wish = @@ -287,7 +284,9 @@ class WishlistApiExamples( WishItemResponse.ItemView( id = 512, status = ItemStatus.READY, - source = ItemSnapshotSource.SERVER, + refreshing = false, + method = ExtractionMethod.STRUCTURED, + userInput = false, name = "에어 조던 1 미드", price = 109_000, currency = "KRW", @@ -301,39 +300,44 @@ class WishlistApiExamples( WishDetailResponse.PriceHistoryEntry( price = 109_000, extractedAt = LocalDateTime.of(2026, 6, 18, 10, 0, 0), - source = ItemSnapshotSource.SERVER, + method = ExtractionMethod.STRUCTURED, + userInput = false, editedByMe = null, ), // 같은 상품을 담은 다른 사용자가 넣은 값 — 어떤 조건에서 본 가격인지 알 수 없어 구분해 다룬다. WishDetailResponse.PriceHistoryEntry( price = 89_000, extractedAt = LocalDateTime.of(2026, 6, 10, 9, 0, 0), - source = ItemSnapshotSource.MANUAL, + method = null, + userInput = true, editedByMe = false, ), WishDetailResponse.PriceHistoryEntry( price = 115_000, extractedAt = LocalDateTime.of(2026, 6, 2, 10, 0, 0), - source = ItemSnapshotSource.SERVER_LLM, + method = ExtractionMethod.LLM, + userInput = false, editedByMe = null, ), WishDetailResponse.PriceHistoryEntry( price = 119_000, extractedAt = LocalDateTime.of(2026, 5, 21, 10, 0, 0), - source = ItemSnapshotSource.SERVER, + method = ExtractionMethod.STRUCTURED, + userInput = false, editedByMe = null, ), ), ) // 본인이 직접 고친 항목 — 표시값(99,000원)은 본인 입력값이고 이력 맨 앞에도 온다. - // 반면 타인 수기(89,000원)는 이력에 남아 있어도 표시값이 되지 않는다(맥락 스코프) — 그래서 이력의 순서만으로 + // 반면 타인이 넣은 값(89,000원)은 이력에 남아 있어도 표시값이 되지 않는다(맥락 스코프) — 그래서 이력의 순서만으로 // 표시값을 유추할 수 없고, 지금 보이는 값은 item 에서 읽어야 한다. - private val manualEditedDetailSample = + private val userInputDetailSample = wishDetailSample.copy( item = wishDetailSample.item.copy( - source = ItemSnapshotSource.MANUAL, + method = null, + userInput = true, price = 99_000, ), priceHistory = @@ -341,14 +345,15 @@ class WishlistApiExamples( WishDetailResponse.PriceHistoryEntry( price = 99_000, extractedAt = LocalDateTime.of(2026, 6, 20, 14, 30, 0), - source = ItemSnapshotSource.MANUAL, + method = null, + userInput = true, editedByMe = true, ), ) + wishDetailSample.priceHistory, ) // 아직 추출에 한 번도 성공하지 못한 상품 — 추출 진행 중이라 표시값도 비어 있고 이력도 없다. - // 클라는 status 로 "기다린다(PENDING·PROCESSING)" 와 "그만 기다린다(FAILED)" 를 가른다. + // 클라는 status 로 "기다린다(PARSING)" 와 "그만 기다린다(FAILED)" 를 가른다. private val emptyHistoryDetailSample = WishDetailResponse( wish = @@ -359,9 +364,11 @@ class WishlistApiExamples( memo = null, item = WishItemResponse.ItemView( - id = 515, - status = ItemStatus.PROCESSING, - source = null, + id = null, + status = ItemStatus.PARSING, + refreshing = false, + method = null, + userInput = null, name = null, price = null, currency = null, @@ -384,7 +391,9 @@ class WishlistApiExamples( WishItemResponse.ItemView( id = 512, status = ItemStatus.READY, - source = ItemSnapshotSource.SERVER, + refreshing = false, + method = ExtractionMethod.STRUCTURED, + userInput = false, name = "에어 조던 1 미드", price = 119_000, currency = "KRW", @@ -395,7 +404,7 @@ class WishlistApiExamples( ), ) - // URL 등록 직후 항목 (PENDING) — link 만 있고 name·가격·이미지는 비어 있다. 디스패처가 집어 PROCESSING 으로 전이한다. + // URL 등록 직후 항목. 상품이 확정되기 전이라 id 와 값이 비어 있고 sourceUrl 은 요청한 URL 이다 private val pendingSampleEntry = WishItemResponse( wish = @@ -405,9 +414,11 @@ class WishlistApiExamples( ), item = WishItemResponse.ItemView( - id = 515, - status = ItemStatus.PENDING, - source = null, + id = null, + status = ItemStatus.PARSING, + refreshing = false, + method = null, + userInput = null, name = null, price = null, currency = null, @@ -417,38 +428,12 @@ class WishlistApiExamples( ), ) - // URL 등록 응답 전용(#853) — 등록 경로는 attach 메타(reused·refreshNeeded)가 non-null 로 내려간다. - // 새 파싱을 만든 등록: 캐시에 붙지 않았으므로 둘 다 false. + private val refreshingSampleEntry = sampleEntry.copy(item = sampleEntry.item.copy(refreshing = true)) + // 우승 칩이 붙는 항목(#1236) — 두 판에서 이긴 상품이라 최신 우승이 앞에 온다. 그 토너먼트를 지우면 id 가 빠진다. private val winnerSampleEntry = sampleEntry.copy(winnerTournamentIds = listOf(1402, 1397)) - private val pendingRegistrationEntry = pendingSampleEntry.copy(reused = false, refreshNeeded = false) - - // 파싱 없이 다른 등록의 완성 값(캐시)에 붙은 등록 — 값이 갱신 권고 임계(24h)보다 낡아 refreshNeeded=true. - // 클라는 "새로운 정보로 가져올까요?" 를 물어 사용자가 원하면 새로고침 API 를 호출한다. - private val reusedRegistrationEntry = sampleEntry.copy(reused = true, refreshNeeded = true) - - // 파싱 진행 중 항목 (PROCESSING) — 디스패처가 집어 추출 중인 상태. 목록·단건 조회에서 등장한다. - private val processingSampleEntry = - WishItemResponse( - wish = - WishItemResponse.WishView( - id = 1026, - createdAt = LocalDateTime.of(2026, 5, 21, 10, 10, 0), - ), - item = - WishItemResponse.ItemView( - id = 514, - status = ItemStatus.PROCESSING, - source = null, - name = null, - price = null, - currency = null, - imageUrl = null, - sourceUrl = "https://www.example-shop.com/products/67890", - sourcePlatform = "example-shop", - ), - ) + private val userInputEntry = sampleEntry.copy(item = sampleEntry.item.copy(method = null, userInput = true)) // 이미지 등록 v2 presigned 발급 응답 샘플 — 클라는 각 uploadUrl 로 contentType 을 Content-Type 헤더에 실어 S3 에 직접 PUT 한 뒤 // imageKey 들을 confirm 으로 되돌려준다. uploadUrl 의 서명 쿼리스트링은 예시라 실제 값이 아니다. @@ -473,7 +458,7 @@ class WishlistApiExamples( ), ) - // 이미지 등록 직후 항목들 — link 도 없이 imageKey 로 작업 큐 적재된 PENDING(sourceUrl=null). 디스패처가 집어 PROCESSING→READY/FAILED 로 전이한다. + // 이미지 등록 직후 항목들. 링크가 없어 sourceUrl 도 비어 있다 private val imagePendingEntries = listOf( WishItemResponse( @@ -484,9 +469,11 @@ class WishlistApiExamples( ), item = WishItemResponse.ItemView( - id = 513, - status = ItemStatus.PENDING, - source = null, + id = null, + status = ItemStatus.PARSING, + refreshing = false, + method = null, + userInput = null, name = null, price = null, currency = null, @@ -503,9 +490,11 @@ class WishlistApiExamples( ), item = WishItemResponse.ItemView( - id = 515, - status = ItemStatus.PENDING, - source = null, + id = null, + status = ItemStatus.PARSING, + refreshing = false, + method = null, + userInput = null, name = null, price = null, currency = null, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt index d75a474a3..cd1afc4ab 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt @@ -11,9 +11,17 @@ import com.depromeet.piki.wishlist.controller.dto.WishDetailResponse import com.depromeet.piki.wishlist.controller.dto.WishItemResponse import com.depromeet.piki.wishlist.controller.dto.WishlistRegisterRequest import com.depromeet.piki.wishlist.controller.dto.WishlistUpdateRequest +import com.depromeet.piki.wishlist.domain.WishCursor import com.depromeet.piki.wishlist.domain.WishDeleteIds +import com.depromeet.piki.wishlist.domain.WishlistSize import com.depromeet.piki.wishlist.service.WishlistService -import com.depromeet.piki.wishlist.service.dto.WishWithItem +import com.depromeet.piki.wishlist.service.dto.ConfirmWishImageRegistrationCommand +import com.depromeet.piki.wishlist.service.dto.DeleteWishCommand +import com.depromeet.piki.wishlist.service.dto.DeleteWishesCommand +import com.depromeet.piki.wishlist.service.dto.GetWishQuery +import com.depromeet.piki.wishlist.service.dto.GetWishlistQuery +import com.depromeet.piki.wishlist.service.dto.PresignWishImageUploadsCommand +import com.depromeet.piki.wishlist.service.dto.RefreshWishItemCommand import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -40,8 +48,6 @@ class WishlistController( private val wishlistService: WishlistService, private val wishExternalEntryRecorder: WishExternalEntryRecorder, ) : WishlistApi { - private fun toResponse(result: WishWithItem): WishItemResponse = WishItemResponse.from(result) - @PostMapping @ResponseStatus(HttpStatus.CREATED) override fun registerFromUrl( @@ -49,11 +55,9 @@ class WishlistController( @Valid @RequestBody request: WishlistRegisterRequest, @RequestHeader(name = ExternalEntry.HEADER, required = false) rawEntryPoint: String?, ): ApiResponseBody { - val result = wishlistService.registerFromUrl(rawUrl = request.url, userId = userId) + val result = wishlistService.registerFromUrl(request.toCommand(userId)) wishExternalEntryRecorder.record(wishId = result.wish.getId(), rawEntryPoint = rawEntryPoint) - return ApiResponseBody.created( - WishItemResponse.fromRegistration(result), - ) + return ApiResponseBody.created(WishItemResponse.from(result)) } @PostMapping("/images/presigned") @@ -61,7 +65,7 @@ class WishlistController( @AuthenticationPrincipal userId: UUID, @RequestBody request: PresignedImageUploadRequest, ): ApiResponseBody { - val uploads = wishlistService.presignImageUploads(formats = request.toUploadFormats(), userId = userId) + val uploads = wishlistService.presignImageUploads(PresignWishImageUploadsCommand(userId = userId, uploadFormats = request.toUploadFormats())) return ApiResponseBody.ok(PresignedImageUploadResponse.from(uploads)) } @@ -71,8 +75,8 @@ class WishlistController( @AuthenticationPrincipal userId: UUID, @RequestBody request: ConfirmImageUploadRequest, ): ApiResponseBody> { - val results = wishlistService.confirmImageRegistration(imageKeys = request.imageKeys, userId = userId) - return ApiResponseBody.created(results.map { toResponse(it) }) + val results = wishlistService.confirmImageRegistration(ConfirmWishImageRegistrationCommand(userId = userId, imageKeys = request.toImageKeys())) + return ApiResponseBody.created(results.map(WishItemResponse::from)) } @GetMapping @@ -81,8 +85,8 @@ class WishlistController( @RequestParam(required = false) cursor: String?, @RequestParam(required = false) size: Int?, ): ApiResponseBody> { - val page = wishlistService.getWishlist(userId = userId, rawCursor = cursor, rawSize = size) - val data = page.entries.map { toResponse(it) } + val page = wishlistService.getWishlist(GetWishlistQuery(userId = userId, cursor = WishCursor.parse(cursor), size = WishlistSize.of(size))) + val data = page.entries.map(WishItemResponse::from) return ApiResponseBody.ok( data = data, pageResponse = PageResponse(nextCursor = page.nextCursor, hasNext = page.hasNext), @@ -94,7 +98,7 @@ class WishlistController( @AuthenticationPrincipal userId: UUID, @PathVariable wishId: Long, ): ApiResponseBody { - val result = wishlistService.getWish(userId = userId, wishId = wishId) + val result = wishlistService.getWish(GetWishQuery(userId = userId, wishId = wishId)) return ApiResponseBody.ok( WishDetailResponse.from(result, requesterId = userId), ) @@ -107,17 +111,8 @@ class WishlistController( @Valid @ModelAttribute request: WishlistUpdateRequest, @RequestPart("image", required = false) image: MultipartFile?, ): ApiResponseBody { - val result = - wishlistService.recoverWishItem( - userId = userId, - wishId = wishId, - name = request.name, - price = request.price, - currency = request.currency, - image = image, - memo = request.memo, - ) - return ApiResponseBody.ok(toResponse(result)) + val result = wishlistService.recoverWishItem(request.toCommand(userId, wishId, image)) + return ApiResponseBody.ok(WishItemResponse.from(result)) } @PostMapping("/{wishId}/refresh") @@ -125,8 +120,8 @@ class WishlistController( @AuthenticationPrincipal userId: UUID, @PathVariable wishId: Long, ): ApiResponseBody { - val result = wishlistService.refreshWishItem(userId = userId, wishId = wishId) - return ApiResponseBody.ok(toResponse(result)) + val result = wishlistService.refreshWishItem(RefreshWishItemCommand(userId = userId, wishId = wishId)) + return ApiResponseBody.ok(WishItemResponse.from(result)) } @DeleteMapping("/{wishId}") @@ -134,20 +129,17 @@ class WishlistController( @AuthenticationPrincipal userId: UUID, @PathVariable wishId: Long, ): ApiResponseBody { - wishlistService.deleteWish(userId = userId, wishId = wishId) + wishlistService.deleteWish(DeleteWishCommand(userId = userId, wishId = wishId)) return ApiResponseBody.ok() } - // 다중 삭제는 의미상 DELETE 지만, DELETE + body 는 중간자(게이트웨이·LB·CDN)가 body 를 스트립/거절할 수 있어 - // (RFC 9110 은 DELETE body 의미를 정의하지 않음) id 목록을 query param(?ids=1,2,3)으로 받는다. - // 누락 시 required=false + orEmpty 로 WishDeleteIds 검증(400)에 닿게 한다 — required=true 면 누락이 - // MissingServletRequestParameterException → 캐치올 500 으로 새기 때문이다. + // DELETE body 는 게이트웨이·CDN 이 버릴 수 있어 query 로 받는다. required=true 면 누락이 캐치올 500 으로 샌다 @DeleteMapping override fun deleteWishes( @AuthenticationPrincipal userId: UUID, @RequestParam(name = "ids", required = false) ids: List?, ): ApiResponseBody { - wishlistService.deleteWishes(userId = userId, wishIds = WishDeleteIds.of(ids.orEmpty())) + wishlistService.deleteWishes(DeleteWishesCommand(userId = userId, wishDeleteIds = WishDeleteIds.of(ids.orEmpty()))) return ApiResponseBody.ok() } } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt index 6ed0dec89..3d474e0dd 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt @@ -1,8 +1,8 @@ package com.depromeet.piki.wishlist.controller.dto +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource -import com.depromeet.piki.wishlist.service.dto.WishDetail +import com.depromeet.piki.wishlist.service.dto.WishDetailResult import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime import java.util.UUID @@ -22,15 +22,15 @@ data class WishDetailResponse( ) val item: WishItemResponse.ItemView, @field:Schema( - description = "가격 이력 — 이 상품의 가격 기록을 최신순으로 최대 50건. 서버가 추출한 값(SERVER·SERVER_LLM)과 " + - "사용자가 직접 입력한 값(MANUAL)을 함께 담으며, 수기는 **같은 상품을 담은 다른 사용자가 넣은 것도 포함**한다 " + - "(누가 넣었는지는 editedByMe 로만 구분되고 편집자 식별자는 내리지 않는다). 출처 기록 도입 전 버전만 빠진다. " + - "source 는 신뢰도 등급이 아니라 맥락 표시다 — 서버 추출값도 완전하지 않고(SERVER_LLM 은 재추출 시 값이 달라질 수 있다), " + - "수기는 사용자가 페이지를 직접 보고 적은 값이다. 다만 서로 다른 값을 재고 있을 수 있어(페이지 표시가 vs 실구매가) " + + description = "가격 이력 — 이 상품의 가격 기록을 최신순으로 최대 50건. 서버가 추출한 값과 " + + "사용자가 직접 입력한 값을 함께 담으며, 사용자 입력은 **같은 상품을 담은 다른 사용자가 넣은 것도 포함**한다 " + + "(누가 넣었는지는 editedByMe 로만 구분되고 편집자 식별자는 내리지 않는다). 이름·가격·이미지 중 하나라도 빈 버전과 출처 기록 도입 전 버전은 빠진다. " + + "method·userInput 은 신뢰도 등급이 아니라 맥락 표시다. 서버 추출값도 완전하지 않고(LLM 은 재추출 시 값이 달라질 수 있다), " + + "사용자 입력은 사용자가 페이지를 직접 보고 적은 값이다. 다만 서로 다른 값을 재고 있을 수 있어(페이지 표시가 vs 실구매가) " + "구분해 보여주기를 권한다. " + - "item 과는 별개의 축이다 — item 은 맥락 스코프(내 수기 존중, 타인 수기 무시)를 거친 표시값이고 이력은 그 필터를 " + + "item 과는 별개의 축이다 — item 은 맥락 스코프(내 입력 존중, 타인 입력 무시)를 거친 표시값이고 이력은 그 필터를 " + "타지 않으므로, 첫 항목이 지금 보이는 값과 다를 수 있다. " + - "가격 기록이 하나도 없으면 빈 배열이다(그때는 item.status 로 대기와 실패를 가른다).", + "이름·가격·이미지가 모두 있는 버전만 기록으로 친다. 그런 버전이 하나도 없으면 빈 배열이다(PARSING·FAILED 는 늘 빈 배열).", ) val priceHistory: List, @field:Schema( @@ -40,17 +40,16 @@ data class WishDetailResponse( val winnerTournamentIds: List = emptyList(), ) { companion object { - // requesterId 는 editedByMe(수기 버전을 내가 넣었는지) 파생용 — 편집자 식별자(UUID)는 응답에 노출하지 않는다. fun from( - detail: WishDetail, + detail: WishDetailResult, requesterId: UUID, ): WishDetailResponse = WishDetailResponse( - wish = WishItemResponse.WishView.from(detail.wish), - memo = detail.wish.memo, - item = WishItemResponse.ItemView.from(detail.item, detail.snapshot, detail.sourcePlatform), + wish = WishItemResponse.WishView.from(detail.wishResult.wish), + memo = detail.memo, + item = WishItemResponse.ItemView.from(detail.wishResult), priceHistory = detail.history.map { PriceHistoryEntry.from(it, requesterId) }, - winnerTournamentIds = detail.winnerTournamentIds, + winnerTournamentIds = detail.wishResult.winnerTournamentIds, ) } @@ -64,48 +63,44 @@ data class WishDetailResponse( ) val extractedAt: LocalDateTime, @field:Schema( - description = "이 값의 출처 — SERVER(구조화 파서)·SERVER_LLM(LLM 추출)은 서버가 페이지에서 뽑은 값이고, " + - "MANUAL 은 사용자가 직접 입력한 값이다. " + - "신뢰도 등급이 아니라 맥락 표시다 — 서버 추출값도 완전하지 않고(SERVER_LLM 은 재추출 시 값이 달라질 수 있다), " + - "수기는 사용자가 페이지를 직접 보고 적은 값이다.", - example = "SERVER", + description = "서버가 페이지에서 값을 뽑은 경로. STRUCTURED(구조화 파서)·LLM. 사용자 입력은 null.", + example = "STRUCTURED", + nullable = true, ) - val source: ItemSnapshotSource, + val method: ExtractionMethod?, + @field:Schema(description = "사용자가 직접 입력한 값인지", example = "false") + val userInput: Boolean, @field:Schema( - description = "수기(MANUAL) 값을 요청자 본인이 넣었는지 — false 면 같은 상품을 담은 다른 사용자가 넣은 값이다. " + + description = "사용자 입력 값을 요청자 본인이 넣었는지 — false 면 같은 상품을 담은 다른 사용자가 넣은 값이다. " + "타인의 값은 어떤 조건(회원가·쿠폰 등)에서 본 것인지 알 수 없으므로 구분해 다루는 편이 좋다. " + - "서버 추출(SERVER·SERVER_LLM) 값은 null.", + "사용자 입력이 아니면 null.", example = "true", nullable = true, ) val editedByMe: Boolean?, ) { companion object { - // 조회 조건이 READY 로 한정돼 price·extractedAt 은 ItemSnapshot 의 READY 불변식(requireReadyInvariant)이, - // source 는 쿼리의 "출처 기록된 행만" 필터가 각각 보장한다 — 셋 다 항상 채워져 있다. 비어 있으면 그 불변식이나 - // 필터가 깨진 코드 버그이므로 requireNotNull(500)으로 드러낸다(정상 흐름의 클라이언트는 닿지 않는다). + // 가격 이력 쿼리가 이름·가격·이미지가 다 있는 행만 고르므로 price 가 비면 코드 버그다 fun from( snapshot: ItemSnapshot, requesterId: UUID, ): PriceHistoryEntry { val snapshotId = snapshot.getId() return PriceHistoryEntry( - price = requireNotNull(snapshot.price) { "READY snapshot $snapshotId 의 price 가 없다" }, - extractedAt = requireNotNull(snapshot.extractedAt) { "READY snapshot $snapshotId 의 extractedAt 이 없다" }, - source = requireNotNull(snapshot.source) { "가격 이력 snapshot $snapshotId 의 source 가 없다" }, + price = requireNotNull(snapshot.price) { "가격 이력 snapshot $snapshotId 의 price 가 없다" }, + extractedAt = snapshot.createdAt, + method = snapshot.method, + userInput = snapshot.userInput, editedByMe = editedByMe(snapshot, requesterId), ) } - // MANUAL 값만 "누가 넣었나" 가 의미를 갖는다. 편집자 UUID 원값은 개인 식별자라 응답에 싣지 않고 - // 본인 여부(Boolean)로만 파생한다. + // 편집자 UUID 원값은 개인 식별자라 응답에 싣지 않고 본인 여부로만 내린다 private fun editedByMe( snapshot: ItemSnapshot, requesterId: UUID, ): Boolean? { - if (!snapshot.isManual()) return null - // 수기 행의 만든 사람이 곧 편집자(#1051) — 표시값 규칙(ItemVersions)과 같은 술어로 판정한다. - snapshot.createdBy ?: return null + if (!snapshot.userInput) return null return snapshot.isOwnedBy(requesterId) } } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt index 0ba916718..fac3027f8 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt @@ -1,35 +1,18 @@ package com.depromeet.piki.wishlist.controller.dto -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.wishlist.domain.Wish -import com.depromeet.piki.wishlist.service.dto.WishWithItem +import com.depromeet.piki.wishlist.service.dto.WishResult import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime -@Schema(description = "위시 항목 — 위시 기록(wish)과 상품 스냅샷(item)의 묶음") +@Schema(description = "위시 항목 — 위시 기록(wish)과 상품(item)의 묶음") data class WishItemResponse( @field:Schema(description = "위시 기록") val wish: WishView, - @field:Schema(description = "상품 스냅샷") + @field:Schema(description = "상품") val item: ItemView, - @field:Schema( - description = "URL 등록 응답 전용 — 파싱 없이 다른 등록이 만든 완성 값(캐시)에 붙었는지. " + - "true 면 item 의 값은 이 등록이 새로 가져온 것이 아니다. 등록 외 응답(목록·수정 등)에서는 null.", - example = "true", - nullable = true, - ) - val reused: Boolean? = null, - @field:Schema( - description = "URL 등록 응답 전용 — 붙은 캐시 값이 갱신 권고 임계(서버 기준 24시간)보다 낡았는지(서버 판정). " + - "true 면 \"새로운 정보로 가져올까요?\" 를 물어 사용자가 원할 때 새로고침 API 를 호출한다. " + - "reused=true 일 때만 의미가 있으며, 등록 외 응답에서는 null.", - example = "false", - nullable = true, - ) - val refreshNeeded: Boolean? = null, @field:Schema( description = "이 상품이 우승한 내 토너먼트들 — 최신 우승 순이고, 비어 있으면 우승 이력이 없다(위시 카드의 우승 칩). " + @@ -41,23 +24,13 @@ data class WishItemResponse( ) { companion object { fun from( - result: WishWithItem, + result: WishResult, ): WishItemResponse = WishItemResponse( wish = WishView.from(result.wish), item = ItemView.from(result), winnerTournamentIds = result.winnerTournamentIds, ) - - // URL 등록 응답 전용 — 공유 attach 메타(#853)까지 싣는다. 등록만 이 오버로드를 쓰고, - // 목록·수정 등 다른 경로는 위 기본 from(플래그 null)을 유지한다. - fun fromRegistration(result: WishWithItem): WishItemResponse = - WishItemResponse( - wish = WishView.from(result.wish), - item = ItemView.from(result), - reused = result.reused, - refreshNeeded = result.refreshNeeded, - ) } @Schema(description = "위시 기록") @@ -76,73 +49,76 @@ data class WishItemResponse( } } - @Schema(description = "상품 스냅샷") + @Schema(description = "상품") data class ItemView( - @field:Schema(description = "상품 ID", example = "512") - val id: Long, + @field:Schema(description = "상품 ID. 파싱 성공이나 사용자 입력으로 상품이 정해지기 전에는 null", example = "512", nullable = true) + val id: Long?, @field:Schema( - description = "파싱 상태 — PENDING(URL 등록 접수, 파싱 대기)/PROCESSING(파싱 중)/READY(완료)/" + - "INCOMPLETE(일부만 채움, 사용자 입력 필요)/FAILED(파싱 실패). " + - "URL 등록은 PENDING 으로 시작해 디스패처가 집어 PROCESSING→READY/INCOMPLETE/FAILED 로 전이하고, 이미지 등록은 PROCESSING 으로 시작한다. " + - "PENDING·PROCESSING 동안은 name·price·imageUrl 이 비어 있다. " + - "INCOMPLETE 는 셋 중 채운 것만 있고 나머지가 비어 있다 — 수기 수정으로 빈 필드를 채우면 READY 가 된다.", + description = "상태 — PARSING(상품 확정 전 파싱 중)/FAILED(상품을 확정하지 못하고 파싱 실패)/" + + "READY(이름·가격·이미지가 모두 있음)/INCOMPLETE(일부만 있음, 사용자 입력으로 채우면 READY). " + + "PARSING·FAILED 동안은 id·name·price·imageUrl 이 비어 있다.", example = "READY", ) val status: ItemStatus, @field:Schema( - description = "지금 보이는 값의 출처 — SERVER(구조화 파서)·SERVER_LLM(LLM 추출)·MANUAL(사용자 수기 입력). " + - "MANUAL 이면 본인이 직접 입력한 값이다(타인의 수기는 내 카드의 표시값이 되지 않는다) — " + - "\"직접 입력한 값\" 배지의 근거로 쓴다. 출처 기록 도입 전에 쌓인 버전은 null(모름).", - example = "SERVER", + description = "이미 값이 있는 상품을 새로고침하는 중인지. 값과 status 는 새로고침 전 것을 그대로 보인다.", + example = "false", + ) + val refreshing: Boolean, + @field:Schema( + description = "지금 보이는 값을 서버가 뽑은 경로. STRUCTURED(구조화 파서)·LLM. 사용자 입력과 경로 기록 전 버전은 null. PARSING·FAILED 시 null.", + example = "STRUCTURED", nullable = true, ) - val source: ItemSnapshotSource?, - @field:Schema(description = "상품명 (PENDING·PROCESSING·실패 시 null)", example = "에어 조던 1 미드", nullable = true) + val method: ExtractionMethod?, + @field:Schema( + description = "지금 보이는 값이 본인이 직접 입력한 값인지(타인의 입력은 내 카드의 표시값이 되지 않는다). PARSING·FAILED 시 null.", + example = "false", + nullable = true, + ) + val userInput: Boolean?, + @field:Schema(description = "상품명 (PARSING·FAILED 시 null)", example = "에어 조던 1 미드", nullable = true) val name: String?, - @field:Schema(description = "스냅샷 시점의 현재 판매가 (PENDING·PROCESSING·실패 시 null)", example = "119000", nullable = true) + @field:Schema(description = "스냅샷 시점의 현재 판매가 (PARSING·FAILED 시 null)", example = "119000", nullable = true) val price: Int?, - @field:Schema(description = "통화 코드 (ISO 4217)", example = "KRW", nullable = true) + @field:Schema(description = "통화 코드 (ISO 4217, PARSING·FAILED 시 null)", example = "KRW", nullable = true) val currency: String?, @field:Schema( - description = "상품 대표 이미지 URL (PENDING·PROCESSING·실패 시 null)", + description = "상품 대표 이미지 URL (PARSING·FAILED 시 null)", example = "https://cdn.example.com/p/512.jpg", nullable = true, ) val imageUrl: String?, @field:Schema( - description = "원본 상품 페이지 URL (이미지 등록 항목은 URL 이 없어 null)", + description = "상품 페이지 URL. 상품이 정해지기 전에는 등록한 URL 그대로이고, 정해진 뒤에는 정규화된 상품 주소라 " + + "값이 바뀔 수 있다. 이미지 등록 항목은 URL 이 없어 null", example = "https://www.example-shop.com/products/12345", nullable = true, ) val sourceUrl: String?, @field:Schema( description = "출처 커머스몰 표시명 — 백오피스 등록값이 있으면 그 표기(예: 29CM), 없으면 URL host 에서 " + - "유도한 임시값(예: 29cm). 표시 텍스트로만 취급한다(안정 식별자 아님). 이미지 등록 항목은 URL 이 없어 null.", + "유도한 임시값(예: 29cm). 표시 텍스트로만 취급한다(안정 식별자 아님). sourceUrl 이 바뀌면 함께 바뀔 수 있다. " + + "이미지 등록 항목은 URL 이 없어 null.", example = "29CM", nullable = true, ) val sourcePlatform: String?, ) { companion object { - // 표시값(status·name·price·currency·imageUrl)은 활성 snapshot 에서, - // 정체성(id·sourceUrl=상품 링크)은 item 에서 읽는다. snapshot 은 5단계 갱신에서 새 버전으로 스왑된다. - fun from(result: WishWithItem): ItemView = from(result.item, result.snapshot, result.sourcePlatform) - - fun from( - item: Item, - snapshot: ItemSnapshot, - sourcePlatform: String?, - ): ItemView = + fun from(result: WishResult): ItemView = ItemView( - id = item.getId(), - status = snapshot.status, - source = snapshot.source, - name = snapshot.name, - price = snapshot.price, - currency = snapshot.currency, - imageUrl = snapshot.imageUrl, - sourceUrl = item.link?.toString(), - sourcePlatform = sourcePlatform, + id = result.wish.itemId, + status = result.card.status, + refreshing = result.card.refreshing, + method = result.card.display?.method, + userInput = result.card.display?.userInput, + name = result.card.display?.name, + price = result.card.display?.price, + currency = result.card.display?.currency, + imageUrl = result.card.display?.imageUrl, + sourceUrl = result.card.sourceLink?.toString(), + sourcePlatform = result.sourcePlatform, ) } } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistRegisterRequest.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistRegisterRequest.kt index 591dea206..4ac9458e5 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistRegisterRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistRegisterRequest.kt @@ -1,17 +1,22 @@ package com.depromeet.piki.wishlist.controller.dto +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size +import java.util.UUID @Schema(description = "위시리스트 등록 요청") data class WishlistRegisterRequest( @field:NotBlank(message = "링크를 입력해 주세요.") - @field:Size(max = 2048, message = "링크가 너무 길어요.") + @field:Size(max = ProductLink.MAX_LENGTH, message = "링크가 너무 길어요.") @field:Schema( description = "등록할 상품 페이지 URL", example = "https://www.example-shop.com/products/12345", requiredMode = Schema.RequiredMode.REQUIRED, ) val url: String, -) +) { + fun toCommand(userId: UUID): RegisterWishFromUrlCommand = RegisterWishFromUrlCommand(userId = userId, link = ProductLink.parse(url)) +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistUpdateRequest.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistUpdateRequest.kt index 69c20c7ac..ae6a81a52 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistUpdateRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishlistUpdateRequest.kt @@ -1,10 +1,14 @@ package com.depromeet.piki.wishlist.controller.dto +import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.wishlist.service.dto.RecoverWishItemCommand import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Min import jakarta.validation.constraints.Size +import org.springframework.web.multipart.MultipartFile +import java.util.UUID -@Schema(description = "위시 항목 수정 요청 — 들어온 필드만 갱신한다") +@Schema(description = "위시 항목 사용자 입력 요청 — 보내지 않은 필드는 지금 보이는 값을 이어 쓴다") data class WishlistUpdateRequest( @field:Schema(description = "수정할 상품명", example = "에어 조던 1 미드", nullable = true) @field:Size(max = 512, message = NAME_MAX_MESSAGE) @@ -23,8 +27,21 @@ data class WishlistUpdateRequest( @field:Size(max = 100, message = MEMO_MAX_MESSAGE) val memo: String? = null, ) { - // Bean Validation 위반 메시지의 single source. OpenAPI example(WishlistApiExamples)이 같은 상수를 참조해 - // "필드 검증 문구가 @field 와 example 두 곳에서 따로 노는" 어긋남을 컴파일 타임에 막는다. + fun toCommand( + userId: UUID, + wishId: Long, + image: MultipartFile?, + ): RecoverWishItemCommand = + RecoverWishItemCommand( + userId = userId, + wishId = wishId, + name = name, + price = price, + currency = currency, + image = image?.let { ProductImage.of(it.bytes, it.contentType) }, + memo = memo, + ) + companion object { const val NAME_MAX_MESSAGE = "상품명은 512자까지 입력할 수 있어요." const val PRICE_MIN_MESSAGE = "가격은 0원 이상으로 입력해 주세요." diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/domain/Wish.kt b/src/main/kotlin/com/depromeet/piki/wishlist/domain/Wish.kt index 31d3c73be..18bfa313a 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/domain/Wish.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/domain/Wish.kt @@ -1,69 +1,50 @@ package com.depromeet.piki.wishlist.domain import com.depromeet.piki.common.domain.LongBaseEntity +import com.depromeet.piki.item.domain.CardQuery +import com.depromeet.piki.item.domain.ItemSlot import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.Table import java.time.LocalDateTime import java.util.UUID -// 사용자가 상품(item)을 위시리스트에 담은 기록. 상품은 정체성(itemId)으로 참조하고, 화면값은 그 상품의 버전들에서 -// 계산한다(ItemVersions, #1051) — 위시는 "어느 상품인가" 와 "지금 어느 파싱을 기다리는가" 만 안다. @Entity @Table(name = "wishes") class Wish( - @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + + @Column(nullable = false, columnDefinition = "BINARY(16)") val userId: UUID, - waitingSnapshotId: Long, - @Column(name = "item_id", nullable = false) - val itemId: Long, + + itemId: Long?, + ) : LongBaseEntity() { - // 이 위시가 기다리는 행(파싱 버전). raw Long(FK 없음). "보는 값" 이 아니다 — 화면값은 ItemVersions 가 상품의 행들로 - // 계산하고, 이 값은 카드에 진행 중을 보일지(내가 시작했거나 합류한 등록·갱신)를 가르는 표식이다. 본인의 등록·새로고침· - // 합류·수기 수정으로만 움직이고 남이 건드리지 못한다. 컬럼명(snapshot_id)은 옛 이름 그대로다 — rename 은 3단계 - // 배포를 요구하는데 값의 의미가 코드에서 드러나면 충분해 미룬다. - @Column(name = "snapshot_id", nullable = false) - var waitingSnapshotId: Long = waitingSnapshotId + @Column + var itemId: Long? = itemId protected set - // 엔티티 불변식 — 0·음수는 존재할 수 없는 참조다. 정상 흐름에선 닿지 않고, 닿으면 코드 버그. init { - require(waitingSnapshotId > 0) { "waitingSnapshotId 는 양수여야 한다: $waitingSnapshotId" } - require(itemId > 0) { "itemId 는 양수여야 한다: $itemId" } + itemId?.let { require(it > 0) { "itemId 는 양수여야 한다: $it" } } } - // 개인 메모 — item·snapshot 은 여러 사용자가 공유하므로 개인 기록은 user 소유인 wish 행이 든다. - @Column(name = "memo", length = MEMO_MAX_LENGTH) - var memo: String? = null - protected set + val slot: ItemSlot get() = ItemSlot.wish(getId()) + + fun cardQuery(): CardQuery = CardQuery(slot = slot, itemId = itemId, owner = userId) - // 소유자가 아니면 거부. 도메인이 자기방어해 어느 통로로 호출되든 같은 결과를 낸다. fun verifyOwnedBy(userId: UUID) { if (this.userId != userId) throw WishException.forbiddenWishItems() } - // 빈 문자열(공백 포함)은 삭제로 정규화한다. 길이 계약은 입력 경계(@Size)가 거르고 여기는 불변식(500)이다. - fun updateMemo(rawMemo: String) { - val normalized = rawMemo.trim().ifEmpty { null } - normalized?.let { - require(it.length <= MEMO_MAX_LENGTH) { "메모는 ${MEMO_MAX_LENGTH}자 이하여야 한다: ${it.length}자" } + fun fill(itemId: Long) { + this.itemId?.let { + check(it == itemId) { "위시 ${getIdOrNull()} 는 이미 아이템 $it 로 확정됐다" } + return } - memo = normalized - } - - // 기다리는 행을 바꾼다 — 새로고침(새 PENDING 또는 진행 중 합류)·수기 수정(내 MANUAL 행). 옛 행은 이력으로 남고 - // 출전 pin 은 독립이라 토너먼트 격리를 지킨다. 같은 상품의 행이어야 한다는 보장은 호출부(서비스)가 진다. - fun waitFor(snapshotId: Long) { - require(snapshotId > 0) { "waitingSnapshotId 는 양수여야 한다: $snapshotId" } - waitingSnapshotId = snapshotId + require(itemId > 0) { "itemId 는 양수여야 한다: $itemId" } + this.itemId = itemId } - // soft delete — 행을 지우지 않고 deletedAt 으로 마킹한다. 조회는 deletedAt IS NULL 만 본다. fun delete() { deletedAt = LocalDateTime.now() } - - companion object { - const val MEMO_MAX_LENGTH = 100 - } } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt index b2798b0a4..dcdf45719 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishErrorCode.kt @@ -12,18 +12,45 @@ enum class WishErrorCode( override val category: ErrorCategory, override val message: String, ) : ErrorCode { - GUEST_CANNOT_USE_WISHLIST("WISH-001", ErrorCategory.FORBIDDEN, "위시리스트는 회원만 이용할 수 있어요."), - FORBIDDEN_WISH_ITEMS("WISH-002", ErrorCategory.FORBIDDEN, "내 위시 아이템만 볼 수 있어요."), - INVALID_CURSOR("WISH-003", ErrorCategory.INVALID_INPUT, "페이지를 불러오지 못했어요. 새로고침 해주세요."), - NOT_FOUND("WISH-004", ErrorCategory.NOT_FOUND, "이미 삭제된 아이템이에요."), - INVALID_IMAGE_COUNT("WISH-005", ErrorCategory.INVALID_INPUT, "이미지는 1~5장만 올릴 수 있어요."), - INVALID_ID_COUNT("WISH-006", ErrorCategory.INVALID_INPUT, "한 번에 최대 100개까지 삭제할 수 있어요."), - NOT_REFRESHABLE("WISH-007", ErrorCategory.INVALID_INPUT, "링크가 없는 항목은 새로고침할 수 없습니다."), + GUEST_CANNOT_USE_WISHLIST( + "WISH-001", + ErrorCategory.FORBIDDEN, + "위시리스트는 회원만 이용할 수 있어요.", + ), + FORBIDDEN_WISH_ITEMS( + "WISH-002", + ErrorCategory.FORBIDDEN, + "내 위시만 볼 수 있어요.", + ), + INVALID_CURSOR( + "WISH-003", + ErrorCategory.INVALID_INPUT, + "페이지를 불러오지 못했어요. 새로고침 해주세요.", + ), + NOT_FOUND( + "WISH-004", + ErrorCategory.NOT_FOUND, + "이미 삭제된 위시예요.", + ), + INVALID_IMAGE_COUNT( + "WISH-005", + ErrorCategory.INVALID_INPUT, + "이미지는 1~5장만 올릴 수 있어요.", + ), + INVALID_ID_COUNT( + "WISH-006", + ErrorCategory.INVALID_INPUT, + "한 번에 최대 100개까지 삭제할 수 있어요.", + ), + // WISH-007 은 ITEM-007 로 옮겨 결번 FAILED_NOT_REFRESHABLE( "WISH-008", ErrorCategory.CONFLICT, "추출에 실패한 항목은 새로고침 대신 정보를 직접 입력해 복구해 주세요.", ), - ALREADY_EXISTS("WISH-009", ErrorCategory.CONFLICT, "이미 위시리스트에 등록된 상품이에요."), - + ALREADY_EXISTS( + "WISH-009", + ErrorCategory.CONFLICT, + "이미 위시리스트에 등록된 상품이에요.", + ), } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishException.kt b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishException.kt index be6389808..9492fcb67 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishException.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishException.kt @@ -33,8 +33,6 @@ class WishException private constructor( fun invalidIdCount(): WishException = WishException(WishErrorCode.INVALID_ID_COUNT) - fun notRefreshable(): WishException = WishException(WishErrorCode.NOT_REFRESHABLE) - fun failedNotRefreshable(): WishException = WishException(WishErrorCode.FAILED_NOT_REFRESHABLE) } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishMemo.kt b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishMemo.kt new file mode 100644 index 000000000..113ca5786 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/domain/WishMemo.kt @@ -0,0 +1,43 @@ +package com.depromeet.piki.wishlist.domain + +import com.depromeet.piki.common.domain.LongBaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.time.LocalDateTime + +// 쌓이기만 한다. 현재 메모는 가장 늦게 쓴 행이고, content 가 없는 행은 지운 기록이다 +@Entity +@Table(name = "wish_memos") +class WishMemo( + + @Column(nullable = false, updatable = false) + val wishId: Long, + + @Column(length = MAX_LENGTH, updatable = false) + val content: String?, + + @Column(nullable = false, updatable = false) + val writtenAt: LocalDateTime, + +) : LongBaseEntity() { + init { + content?.let { + require(it.isNotBlank()) { "빈 메모는 content 없이 지운 기록으로 남긴다" } + require(it.length <= MAX_LENGTH) { "메모는 ${MAX_LENGTH}자 이하여야 한다: ${it.length}자" } + } + } + + fun sameContentAs(current: WishMemo?): Boolean = content == current?.content + + fun copyTo(wishId: Long): WishMemo = WishMemo(wishId = wishId, content = content, writtenAt = writtenAt) + + companion object { + const val MAX_LENGTH = 100 + + fun written( + wishId: Long, + rawMemo: String, + ): WishMemo = WishMemo(wishId = wishId, content = rawMemo.trim().ifEmpty { null }, writtenAt = LocalDateTime.now()) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishCardView.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishCardView.kt index 34c7bb147..39942b0d6 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishCardView.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishCardView.kt @@ -2,10 +2,7 @@ package com.depromeet.piki.wishlist.repository import java.util.UUID -// 한 상품을 담은 위시 카드의 (주인, 위시 id, 기다리는 행) — 해소 통지(#1028) 수신자 판정용(#1051). 알림 쪽이 이 -// 셋으로 카드의 표시값(ItemVersions)을 계산해 "이 파싱으로 카드가 채워지는 사람" 을 가른다. Spring Data interface projection. interface WishCardView { val userId: UUID val wishId: Long - val waitingSnapshotId: Long } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishJpaRepository.kt index 3db3faeee..daca59447 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishJpaRepository.kt @@ -1,5 +1,7 @@ package com.depromeet.piki.wishlist.repository +import com.depromeet.piki.item.domain.CanonicalInput +import com.depromeet.piki.item.domain.NormalizedInput import com.depromeet.piki.wishlist.domain.Wish import jakarta.persistence.LockModeType import org.springframework.data.domain.Limit @@ -11,46 +13,15 @@ import org.springframework.data.repository.query.Param import java.util.UUID interface WishJpaRepository : JpaRepository { - // 이 버전을 담은 위시의 (주인, 위시 id, 새로고침 여부). 파싱 알림의 수신자·수신자별 wishId 딥링크 역조회(#933)가 - // 한 번 읽어 나눠 쓴다 — 수신자 도출과 라우팅 해석이 같은 행을 두 번 읽지 않게 한 조회로 모았다. - // refreshed(#1036)는 위시가 버전보다 먼저 만들어졌는가 = 새로고침으로 이 버전에 도달했는가(WishOwnerView 참고). - // "이 버전을 기다리는 위시" 라 waitingSnapshotId 로 찾고, 시각 비교를 위해 버전 행을 조인한다(FK·연관관계 없음). + // 해소 통지(#1028) 후보. 어느 카드가 채워지는지는 알림 쪽이 표시값 규칙(ItemVersions)으로 가른다 @Query( - "SELECT w.userId AS userId, w.id AS wishId, " + - "CASE WHEN w.createdAt < s.createdAt THEN true ELSE false END AS refreshed " + - "FROM Wish w, ItemSnapshot s " + - "WHERE w.waitingSnapshotId = s.id AND s.id = :snapshotId AND w.deletedAt IS NULL", - ) - fun findOwnerWishIdsBySnapshotId( - @Param("snapshotId") snapshotId: Long, - ): List - - // 이 상품을 담은 위시 카드 전부(주인, 위시 id, 기다리는 행) — 해소 통지(#1028) 수신자 판정용(#1051). 어느 카드가 - // 이 파싱으로 채워지는지는 알림 쪽이 표시값 규칙(ItemVersions)으로 가른다. 상태로 좁히지 않는다 — 카드의 미완성은 - // 기다리는 행만이 아니라 주인의 다른 행(내 INCOMPLETE)에서도 올 수 있어 SQL 로 정확히 표현되지 않는다. - @Query( - "SELECT w.userId AS userId, w.id AS wishId, w.waitingSnapshotId AS waitingSnapshotId FROM Wish w " + + "SELECT w.userId AS userId, w.id AS wishId FROM Wish w " + "WHERE w.itemId = :itemId AND w.deletedAt IS NULL ORDER BY w.id ASC", ) fun findCardsByItemId( @Param("itemId") itemId: Long, ): List - fun countByIdInAndUserId( - ids: Collection, - userId: UUID, - ): Long - - // 출전 소유 체크용 — 위시가 item 을 직접 참조하므로(#1051) 조인 없이 센다. - @Query( - "SELECT COUNT(DISTINCT w.id) FROM Wish w " + - "WHERE w.itemId IN :itemIds AND w.userId = :userId AND w.deletedAt IS NULL", - ) - fun countByItemIdInAndUserIdAndDeletedAtIsNull( - @Param("itemIds") itemIds: Collection, - @Param("userId") userId: UUID, - ): Long - fun findByUserIdAndDeletedAtIsNullOrderByIdDesc( userId: UUID, limit: Limit, @@ -72,6 +43,43 @@ interface WishJpaRepository : JpaRepository { fun findByIdInAndDeletedAtIsNull(ids: Collection): List + // 파싱 결과는 지워진 위시에도 기록한다 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT w FROM Wish w WHERE w.id = :id") + fun findByIdIncludingDeletedForUpdate( + @Param("id") id: Long, + ): Wish? + + // 같은 입력으로 파싱을 요청한 적 있는 위시. 한 사용자의 위시로 먼저 좁혀져 입력 인덱스가 필요 없다 + @Query( + "SELECT w.id FROM Wish w, ItemParseOutbox o " + + "WHERE o.slotType = com.depromeet.piki.item.domain.ItemSlotType.WISH AND o.slotId = w.id " + + "AND w.userId = :userId AND w.deletedAt IS NULL AND o.normalizedInput = :value", + ) + fun findIdsByUserIdAndNormalizedInput( + @Param("userId") userId: UUID, + @Param("value") normalized: NormalizedInput, + ): List + + @Query( + "SELECT w.id FROM Wish w, Item i WHERE i.id = w.itemId AND i.canonicalUrl = :canonical " + + "AND w.userId = :userId AND w.deletedAt IS NULL AND i.deletedAt IS NULL", + ) + fun findIdsByUserIdAndItemCanonical( + @Param("userId") userId: UUID, + @Param("canonical") canonical: CanonicalInput, + ): List + + @Query( + "SELECT o.normalizedInput FROM Wish w, ItemParseOutbox o " + + "WHERE o.slotType = com.depromeet.piki.item.domain.ItemSlotType.WISH AND o.slotId = w.id " + + "AND w.userId = :userId AND w.deletedAt IS NULL AND o.normalizedInput IN :values", + ) + fun findRequestedInputsByUserId( + @Param("userId") userId: UUID, + @Param("values") values: Collection, + ): List + // 출전·메모용 — 한 유저가 이 itemId 들을 담은 wish. 위시가 item 을 직접 참조하므로(#1051) 조인이 없다. @Query( "SELECT w FROM Wish w WHERE w.itemId IN :itemIds AND w.userId = :userId AND w.deletedAt IS NULL", @@ -81,18 +89,6 @@ interface WishJpaRepository : JpaRepository { @Param("userId") userId: UUID, ): List - // 정체성 병합 추종(#1051) — 진 item 을 가리키던 위시를 이긴 item 으로. 삭제된 위시도 함께 옮긴다(참조 정합). - // native bulk 라 auditing 을 우회해 updated_at 을 직접 갱신한다(item_snapshots.reparentAll 과 같은 결). - @Modifying - @Query( - value = "UPDATE wishes SET item_id = :toItemId, updated_at = NOW(6) WHERE item_id = :fromItemId", - nativeQuery = true, - ) - fun reparentItem( - @Param("fromItemId") fromItemId: Long, - @Param("toItemId") toItemId: Long, - ): Int - // 탈퇴 cascade — 그 유저의 위시를 영구 하드삭제. 위시는 다른 데이터가 참조하지 않아 즉시 파기 가능. 멱등(없으면 0건). @Modifying @Query("DELETE FROM Wish w WHERE w.userId = :userId") diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoJpaRepository.kt new file mode 100644 index 000000000..3fd453983 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoJpaRepository.kt @@ -0,0 +1,20 @@ +package com.depromeet.piki.wishlist.repository + +import com.depromeet.piki.wishlist.domain.WishMemo +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.util.UUID + +interface WishMemoJpaRepository : JpaRepository { + fun findFirstByWishIdOrderByWrittenAtDescIdDesc(wishId: Long): WishMemo? + + fun findAllByWishId(wishId: Long): List + + @Modifying + @Query("DELETE FROM WishMemo m WHERE m.wishId IN (SELECT w.id FROM Wish w WHERE w.userId = :userId)") + fun hardDeleteAllByUserId( + @Param("userId") userId: UUID, + ): Int +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoRepository.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoRepository.kt new file mode 100644 index 000000000..6ae663b3e --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoRepository.kt @@ -0,0 +1,17 @@ +package com.depromeet.piki.wishlist.repository + +import com.depromeet.piki.wishlist.domain.WishMemo +import java.util.UUID + +interface WishMemoRepository { + fun save(memo: WishMemo): WishMemo + + fun saveAll(memos: List): List + + fun findCurrentByWishId(wishId: Long): WishMemo? + + fun findAllByWishId(wishId: Long): List + + // 위시를 지우기 전에 불러야 한다. 메모는 위시로만 찾는다 + fun hardDeleteAllByUserId(userId: UUID): Int +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoRepositoryImpl.kt new file mode 100644 index 000000000..eb26530df --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishMemoRepositoryImpl.kt @@ -0,0 +1,20 @@ +package com.depromeet.piki.wishlist.repository + +import com.depromeet.piki.wishlist.domain.WishMemo +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +class WishMemoRepositoryImpl( + private val wishMemoJpaRepository: WishMemoJpaRepository, +) : WishMemoRepository { + override fun save(memo: WishMemo): WishMemo = wishMemoJpaRepository.save(memo) + + override fun saveAll(memos: List): List = wishMemoJpaRepository.saveAll(memos) + + override fun findCurrentByWishId(wishId: Long): WishMemo? = wishMemoJpaRepository.findFirstByWishIdOrderByWrittenAtDescIdDesc(wishId) + + override fun findAllByWishId(wishId: Long): List = wishMemoJpaRepository.findAllByWishId(wishId) + + override fun hardDeleteAllByUserId(userId: UUID): Int = wishMemoJpaRepository.hardDeleteAllByUserId(userId) +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishOwnerView.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishOwnerView.kt deleted file mode 100644 index 9bc5271c8..000000000 --- a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishOwnerView.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.depromeet.piki.wishlist.repository - -import java.util.UUID - -// 이 버전(snapshot)을 담은 위시의 (주인, 위시 id, 새로고침 여부) — 파싱 알림의 수신자별 wishId 딥링크 역조회용(#933). -// Spring Data interface projection — JPQL 의 별칭(AS userId / AS wishId / AS refreshed)이 getter 에 매핑된다. -// 한 유저는 한 snapshot 을 위시로 한 번만 담으므로 userId → wishId 가 유일하다. -interface WishOwnerView { - val userId: UUID - val wishId: Long - - // 이 위시가 그 버전으로 **새로고침해** 도달했는가(#1036) = 위시가 버전보다 먼저 만들어졌는가. 위시는 늘 이미 있는 버전을 - // 가리키며 태어나므로(등록·공유 합류 모두 snapshot 저장 뒤 wish 저장) 위시가 먼저라는 것은 생성 후 포인터가 그 버전으로 - // 스왑됐다는 뜻이고, 파싱 대상 버전으로의 스왑은 새로고침(진행 중 합류 #826 포함)뿐이다. 등록/새로고침을 적는 컬럼 대신 - // 이 시각 비교(created_at 은 DATETIME(6))로 판정한다. 알려진 한계: 정체성 병합으로 옛 버전이 다른 item 으로 옮겨진 뒤 - // 그 진행 중 버전에 새로고침으로 합류하면 위시가 더 늦어 등록으로 보인다(드문 경합, 감수). - val refreshed: Boolean -} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepository.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepository.kt index 44b594b6f..9992e257d 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepository.kt @@ -1,5 +1,8 @@ package com.depromeet.piki.wishlist.repository +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.CanonicalInput +import com.depromeet.piki.item.domain.NormalizedInput import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.domain.WishCursor import java.util.UUID @@ -10,22 +13,6 @@ interface WishRepository { // 탈퇴 cascade — 그 유저의 위시를 일괄 하드삭제하고 영향 건수를 돌려준다. 멱등. fun hardDeleteAllByUserId(userId: UUID): Int - // 정체성 병합 추종(#1051) — 진 item 을 가리키던 위시의 item_id 를 이긴 item 으로 옮기고 영향 건수를 돌려준다. - fun reparentItem( - fromItemId: Long, - toItemId: Long, - ): Int - - fun countByIdsAndUserId( - ids: List, - userId: UUID, - ): Long - - fun countByItemIdsAndUserId( - itemIds: List, - userId: UUID, - ): Long - // id desc 정렬로 최대 limit 건. cursor 가 있으면 그 id 미만만(다음 페이지). 삭제된 행은 제외. fun findPage( userId: UUID, @@ -36,24 +23,37 @@ interface WishRepository { // 삭제되지 않은 단건 조회. 없으면 null. fun findById(id: Long): Wish? - // 수동 새로고침용 — 행 락으로 동시 새로고침을 직렬화한다. 삭제된 행은 제외(없으면 null). - // item↔wish 가 등록당 1:1 이라 wish 락이 곧 그 item 의 갱신 직렬화 효과를 낸다(한 item 에 진행 중 snapshot 최대 1개 보장). + // 새로고침·사용자 입력·메모를 직렬화한다. 삭제된 행은 제외(없으면 null). fun findByIdForUpdate(id: Long): Wish? // 삭제되지 않은 위시들을 id 목록으로 조회. 존재하는 것만 반환하므로(없는 id 는 빠진다) // 호출부가 반환 개수로 누락 여부를 판단한다. fun findAllByIds(ids: List): List - // 한 유저의 위시를 itemId 목록으로 조회. 토너먼트로 출전시킬 때 각 위시가 기다리는 행을 읽어 tournament_item 에 - // 고정하는 데 쓴다. itemId 는 등록당 1건이라 유저 내에서 사실상 1:1. + // 한 유저의 위시를 itemId 목록으로 조회. 살아 있는 위시 안에서 itemId 는 유저마다 하나다(유니크) fun findByItemIdsAndUserId( itemIds: List, userId: UUID, ): List - // 이 버전을 담은 위시의 (주인, 위시 id, 새로고침 여부) — 파싱 알림 수신자·라우팅 역조회. 같은 버전을 여러 유저가 담을 수 있다. - fun findOwnerWishIdsBySnapshotId(snapshotId: Long): List - - // 이 상품을 담은 위시 카드 전부(주인, 위시 id, 기다리는 행) — 해소 통지 수신자 판정(#1028·#1051). fun findCardsByItemId(itemId: Long): List + + fun findByIdIncludingDeletedForUpdate(id: Long): Wish? + + fun findIdByUserIdAndNormalizedInput( + userId: UUID, + normalized: NormalizedInput, + ): Long? + + // 이 사용자의 위시 중 정체 값이 같은 아이템을 담은 것 + fun findIdByUserIdAndItemCanonical( + userId: UUID, + canonical: CanonicalInput, + ): Long? + + // 이 사용자가 이미 요청한 이미지 키 + fun findRequestedImageKeys( + userId: UUID, + keys: Collection, + ): Set } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepositoryImpl.kt index df6da3da8..acf67e1df 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/repository/WishRepositoryImpl.kt @@ -1,5 +1,8 @@ package com.depromeet.piki.wishlist.repository +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.CanonicalInput +import com.depromeet.piki.item.domain.NormalizedInput import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.domain.WishCursor import org.springframework.data.domain.Limit @@ -14,21 +17,6 @@ class WishRepositoryImpl( override fun hardDeleteAllByUserId(userId: UUID): Int = wishJpaRepository.hardDeleteAllByUserId(userId) - override fun reparentItem( - fromItemId: Long, - toItemId: Long, - ): Int = wishJpaRepository.reparentItem(fromItemId, toItemId) - - override fun countByIdsAndUserId( - ids: List, - userId: UUID, - ): Long = wishJpaRepository.countByIdInAndUserId(ids, userId) - - override fun countByItemIdsAndUserId( - itemIds: List, - userId: UUID, - ): Long = wishJpaRepository.countByItemIdInAndUserIdAndDeletedAtIsNull(itemIds, userId) - override fun findPage( userId: UUID, cursor: WishCursor?, @@ -54,8 +42,28 @@ class WishRepositoryImpl( userId: UUID, ): List = wishJpaRepository.findByItemIdInAndUserIdAndDeletedAtIsNull(itemIds, userId) - override fun findOwnerWishIdsBySnapshotId(snapshotId: Long): List = - wishJpaRepository.findOwnerWishIdsBySnapshotId(snapshotId) - override fun findCardsByItemId(itemId: Long): List = wishJpaRepository.findCardsByItemId(itemId) + + override fun findByIdIncludingDeletedForUpdate(id: Long): Wish? = wishJpaRepository.findByIdIncludingDeletedForUpdate(id) + + override fun findIdByUserIdAndNormalizedInput( + userId: UUID, + normalized: NormalizedInput, + ): Long? = wishJpaRepository.findIdsByUserIdAndNormalizedInput(userId, normalized).firstOrNull() + + override fun findIdByUserIdAndItemCanonical( + userId: UUID, + canonical: CanonicalInput, + ): Long? = wishJpaRepository.findIdsByUserIdAndItemCanonical(userId, canonical).firstOrNull() + + override fun findRequestedImageKeys( + userId: UUID, + keys: Collection, + ): Set { + if (keys.isEmpty()) return emptySet() + return wishJpaRepository + .findRequestedInputsByUserId(userId, keys.map { it.value }) + .map(ProductImageKey::parse) + .toSet() + } } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishItemMergeListener.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishItemMergeListener.kt deleted file mode 100644 index de27babae..000000000 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishItemMergeListener.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.depromeet.piki.wishlist.service - -import com.depromeet.piki.item.event.ItemMerged -import com.depromeet.piki.wishlist.repository.WishRepository -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.transaction.event.TransactionPhase -import org.springframework.transaction.event.TransactionalEventListener - -// 정체성 병합(#825)을 위시의 item 참조가 따라가게 한다(#1051). 위시는 이제 snapshot 만이 아니라 item 도 직접 -// 참조하므로, 버전 재부모화만으로는 참조가 자동 추종되지 않는다 — 병합 트랜잭션 안(BEFORE_COMMIT)에서 진 item 을 -// 가리키던 위시를 이긴 item 으로 옮겨, 병합과 위시 갱신이 하나의 커밋으로 묶인다. -@Component -class WishItemMergeListener( - private val wishRepository: WishRepository, -) { - private val log = LoggerFactory.getLogger(javaClass) - - @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) - fun on(event: ItemMerged) { - val moved = wishRepository.reparentItem(fromItemId = event.loserItemId, toItemId = event.winnerItemId) - log.info("병합 추종 wishes.item_id loser={} winner={} moved={}", event.loserItemId, event.winnerItemId, moved) - } -} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt index 89cf788a2..723241b07 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt @@ -1,21 +1,30 @@ package com.depromeet.piki.wishlist.service import com.depromeet.piki.common.exception.AlreadyRegisteredException -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.NormalizedInput +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.UserInput import com.depromeet.piki.item.repository.ItemRepository -import com.depromeet.piki.item.repository.ItemSnapshotRepository -import com.depromeet.piki.item.service.DisplayCard -import com.depromeet.piki.item.service.ItemDisplayService -import com.depromeet.piki.item.service.ItemIdentityRecorder -import com.depromeet.piki.item.service.ItemSharingService +import com.depromeet.piki.item.service.ItemCardService +import com.depromeet.piki.item.service.ItemSlotFill import com.depromeet.piki.item.service.ParsingEnqueuer +import com.depromeet.piki.item.service.UserInputRecorder import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.user.service.UserService import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.domain.WishErrorCode import com.depromeet.piki.wishlist.domain.WishException +import com.depromeet.piki.wishlist.domain.WishMemo +import com.depromeet.piki.wishlist.repository.WishMemoRepository import com.depromeet.piki.wishlist.repository.WishRepository -import com.depromeet.piki.wishlist.service.dto.WishWithItem +import com.depromeet.piki.wishlist.service.dto.ApplyWishUserInputCommand +import com.depromeet.piki.wishlist.service.dto.ConfirmWishImageRegistrationCommand +import com.depromeet.piki.wishlist.service.dto.RecoverWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RefreshWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import com.depromeet.piki.wishlist.service.dto.UpdateWishMemoCommand +import com.depromeet.piki.wishlist.service.dto.WishResult import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -24,205 +33,131 @@ import java.util.UUID @Service class WishPersistenceService( private val wishRepository: WishRepository, + private val wishMemoRepository: WishMemoRepository, private val itemRepository: ItemRepository, - private val itemSnapshotRepository: ItemSnapshotRepository, - private val itemIdentityRecorder: ItemIdentityRecorder, - private val itemSharingService: ItemSharingService, - private val itemDisplayService: ItemDisplayService, + private val itemCardService: ItemCardService, + private val userInputRecorder: UserInputRecorder, private val parsingEnqueuer: ParsingEnqueuer, + private val userService: UserService, ) { - // 등록 전 사전 확인 — 이미 담은 상품이면 한도를 깎기 전에 409 로 끊는다(#973). - // - // 락 밖 조회라 근사치다: 병합 경합 창에서는 여기서 본 item 과 persist 가 실제로 붙는 item(승자)이 다를 수 있다. - // 정확한 판정은 persist 가 행 락 안에서 다시 하므로, 여기서 놓친 중복도 거기서 걸린다. 그 창에서만 차감이 낭비된다. + // 이미 담은 상품이면 한도를 깎기 전에 409 로 끊는다(#973) @Transactional(readOnly = true) - fun rejectIfAlreadyRegistered( - userId: UUID, - link: ProductLink, - ) { - val shared = itemSharingService.resolveExistingItem(link) ?: return - existingWishId(shared.getId(), userId)?.let { - throw AlreadyRegisteredException.wish(WishErrorCode.ALREADY_EXISTS, it) - } + fun rejectIfAlreadyRegistered(command: RegisterWishFromUrlCommand) { + rejectIfRegistered(command.userId, command.link) } - // 삭제된 위시는 조회에서 빠지므로 지웠다 다시 담는 것은 막히지 않는다. - private fun existingWishId( - itemId: Long, - userId: UUID, - ): Long? = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).firstOrNull()?.getId() + // 이미 아는 링크여도 아이템 없이 담고 새로 파싱한다. 성공하면 정체 URL 이 같은 아이템에 붙는다 + @Transactional + fun persist(command: RegisterWishFromUrlCommand): WishResult { + lockRegistrant(command.userId) + rejectIfRegistered(command.userId, command.link) + return createPending(command.userId, ParseInput.Link(command.link)) + } - // 아는 링크 모양이면 그 정체성에 붙고, 처음 보는 모양이면 새로 세운다. + // 같은 업로드를 두 번 확정하면 두 번째는 건너뛴다. 여러 장을 한 번에 확정하므로 링크처럼 거부하지 않는다 @Transactional - fun persist( + fun registerImages(command: ConfirmWishImageRegistrationCommand): List { + lockRegistrant(command.userId) + val newKeys = command.imageKeys.distinct() - wishRepository.findRequestedImageKeys(command.userId, command.imageKeys) + return newKeys.map { createPending(command.userId, ParseInput.Image(it)) } + } + + // 트랜잭션의 첫 문장이어야 한다. 일반 읽기가 먼저 오면 REPEATABLE READ 스냅샷이 잠금 전에 고정돼 앞선 등록을 못 본다 + private fun lockRegistrant(userId: UUID) { + userService.findActiveByIdForUpdate(userId) + } + + private fun createPending( userId: UUID, - link: ProductLink, - ): WishWithItem = attachToShared(userId, link) ?: createFresh(userId, Item(link)) + input: ParseInput, + ): WishResult { + val wish = wishRepository.save(Wish(userId = userId, itemId = null)) + parsingEnqueuer.enqueue(wish.slot, ParsePurpose.REGISTER, input) + return WishResult(wish = wish, card = itemCardService.cardOf(wish.cardQuery())) + } - // 이미 아는 링크 모양에 붙는 길(#825). 모르는 모양이면 null 을 돌려 새로 만드는 길로 넘긴다. - private fun attachToShared( + // 요청 시점에 같은 상품인지 모르면 받고, 확정 때 합친다(ItemSlotPort.fill) + private fun rejectIfRegistered( userId: UUID, link: ProductLink, - ): WishWithItem? { - val shared = itemSharingService.resolveExistingItem(link) ?: return null - val attachment = itemSharingService.resolveAttachment(shared.getId(), link, requestedBy = userId) - // 중복 판정의 기준은 별칭으로 찾은 shared 가 아니라 실제로 붙은 attachment.item 이다 - 병합 재시도 - // 경합에선 둘이 다르다(shared=loser, attachment.item=winner). 행 락 뒤라 이 검사도 직렬화된다. - existingWishId(attachment.item.getId(), userId)?.let { - throw AlreadyRegisteredException.wish(WishErrorCode.ALREADY_EXISTS, it) - } - val wish = - wishRepository.save( - Wish(userId = userId, waitingSnapshotId = attachment.snapshot.getId(), itemId = attachment.item.getId()), - ) - return WishWithItem( - wish = wish, - item = attachment.item, - snapshot = attachment.snapshot, - reused = attachment.reused, - refreshNeeded = attachment.refreshNeeded, - ) + ) { + val existing = wishOfSameItem(userId, link) ?: wishRequestedWith(userId, ParseInput.Link(link)) + existing?.let { throw AlreadyRegisteredException.wish(WishErrorCode.ALREADY_EXISTS, it) } } - // 처음 보는 링크를 새 정체성으로 세우는 길. snapshot 을 PENDING 으로 커밋하는 것이 곧 작업 큐 적재다. - private fun createFresh( + private fun wishOfSameItem( userId: UUID, - item: Item, - ): WishWithItem { - val saved = itemRepository.save(item) - itemIdentityRecorder.recordRegistrationAlias(saved) - val snapshot = parsingEnqueuer.enqueue(saved.getId(), requestedBy = userId) - val wish = wishRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = saved.getId())) - return WishWithItem(wish = wish, item = saved, snapshot = snapshot) + link: ProductLink, + ): Long? { + val canonical = NormalizedInput.of(ParseInput.Link(link))?.asCanonical() ?: return null + return wishRepository.findIdByUserIdAndItemCanonical(userId, canonical) } - @Transactional - fun registerImage( - imageKey: String, + // 단축링크처럼 정체 URL 과 다른 URL 로 다시 담는 경우는 요청 이력으로 본다 + private fun wishRequestedWith( userId: UUID, - ): WishWithItem = createFresh(userId, Item(sourceImageKey = imageKey)) + input: ParseInput, + ): Long? { + val normalized = NormalizedInput.of(input) ?: return null + return wishRepository.findIdByUserIdAndNormalizedInput(userId, normalized) + } - // 수기 수정 영속화(#825 결정 4) — 기존 행을 고치지 않고 MANUAL 새 버전을 쌓아 활성 포인터를 스왑한다. - // S3 업로드(외부 호출)는 호출부가 트랜잭션 바깥에서 끝낸다. 상태 제한이 없다: 기계 버전은 불변이라 어떤 상태든 - // 덮어써질 위험 자체가 없고, 진행 중이던 파싱은 자기 행에서 계속돼 완료 시 이력으로 남는다. - // wish 행 락으로 refresh 와 직렬화한다 — 둘 다 활성 포인터를 스왑하는 경로라, 락 없이는 서로의 스왑을 덮는다 - // (옛 FAILED-상태 분리 방어를 대체하는 장치). base 는 락 안에서 읽은 현재 활성 버전이다. @Transactional - fun manualEdit( - userId: UUID, - wishId: Long, - name: String?, - price: Int?, - imageUrl: String?, - currency: String?, - memo: String?, - ): WishWithItem { - val wish = wishRepository.findByIdForUpdate(wishId) ?: throw WishException.notFound() - wish.verifyOwnedBy(userId) - val base = editBasisOf(wish) - val item = itemRepository.findById(base.itemId) ?: error("item ${base.itemId} 가 없다") - val manual = - itemSnapshotRepository.save( - ItemSnapshot.manual( - base = base, - name = name, - price = price, - imageUrl = imageUrl, - currency = currency, - createdBy = userId, - ), - ) - wish.waitFor(manual.getId()) - memo?.let { wish.updateMemo(it) } - return WishWithItem(wish = wish, item = item, snapshot = manual) + fun applyUserInput(command: ApplyWishUserInputCommand): WishResult { + val wish = wishRepository.findByIdForUpdate(command.wishId) ?: throw WishException.notFound() + wish.verifyOwnedBy(command.userId) + val values = itemCardService.cardOf(wish.cardQuery()).userInputValues(command.userInput) + val fill = userInputRecorder.record(wish.slot, wish.itemId, values, createdBy = command.userId) + val survivor = survivorOf(wish, fill) + command.memo?.let { writeMemo(survivor, it) } + return WishResult(wish = survivor, card = itemCardService.cardOf(survivor.cardQuery())) } - // 업로드 전 사전 검증(던지기 전용) — S3 orphan 방지가 유일한 존재 이유라 호출부가 이미지 있을 때만 부른다. - // 병합 base 를 manualEdit 과 같은 코드(editBasisOf)로 고르므로 dry-run 과 실제 저장이 갈라질 수 없다. - // 락 밖 조회라 최종 판정은 manualEdit(락 안)이 다시 한다. + // 이미지를 올리기 전에 거절해 S3 에 고아 객체가 남지 않게 한다. 최종 판정은 applyUserInput 이 락 안에서 다시 한다 @Transactional(readOnly = true) - fun validateManualEdit( - userId: UUID, - wishId: Long, - name: String?, - price: Int?, - currency: String?, - ) { - val wish = wishRepository.findById(wishId) ?: throw WishException.notFound() - wish.verifyOwnedBy(userId) - ItemSnapshot.manual( - base = editBasisOf(wish), - name = name, - price = price, - imageUrl = PRE_UPLOAD_VALIDATION_IMAGE_URL, - currency = currency, - createdBy = userId, - ) + fun validateUserInput(command: RecoverWishItemCommand) { + val wish = wishRepository.findById(command.wishId) ?: throw WishException.notFound() + wish.verifyOwnedBy(command.userId) + itemCardService.cardOf(wish.cardQuery()).userInputValues(UserInput.beforeImageUpload(command.name, command.price, command.currency)) } - // base 는 포인터가 아니라 카드가 보여준 표시값(#858) — 포인터가 미완성이어도 카드엔 최신 기계 READY 가 - // 떠 있고, 사용자는 그 값을 보며 일부 필드만 고친다. 포인터를 base 로 쓰면 안 고친 필드가 빈 값에서 - // 병합돼, 화면에 가격이 떠 있는데 "가격이 필요하다"로 튕긴다(담기 게이트와 같은 어긋남, #1006). - private fun editBasisOf(wish: Wish): ItemSnapshot = itemDisplayService.resolveDisplay(wish.displayCard()) - - // memo 만 온 수정 — 버전(snapshot)을 쌓지 않고 wish 행만 갱신한다. 포인터를 안 바꿔도 행 락은 필요하다: - // UPDATE 가 전 컬럼을 쓰므로(dynamic update 아님), 락 없이 읽은 뒤 flush 하면 그 사이 스왑 경로(manualEdit·refresh)가 - // 커밋한 snapshotId 를 읽던 옛 값으로 되덮는다(lost update). 같은 행 락으로 스왑 경로와 직렬화한다. + // 합치기가 메모를 옮기는 동안 잠금을 기다린다. 잠그지 않으면 옮긴 뒤 사라지는 위시에 쓴 메모가 조용히 버려진다 @Transactional - fun updateMemo( - userId: UUID, - wishId: Long, - memo: String, - ): WishWithItem { - val wish = wishRepository.findByIdForUpdate(wishId) ?: throw WishException.notFound() - wish.verifyOwnedBy(userId) - wish.updateMemo(memo) - val item = itemRepository.findById(wish.itemId) ?: error("item ${wish.itemId} 가 없다") - return WishWithItem(wish = wish, item = item, snapshot = itemDisplayService.resolveDisplay(wish.displayCard())) + fun updateMemo(command: UpdateWishMemoCommand): WishResult { + val wish = wishRepository.findByIdForUpdate(command.wishId) ?: throw WishException.notFound() + wish.verifyOwnedBy(command.userId) + writeMemo(wish, command.memo) + return WishResult(wish = wish, card = itemCardService.cardOf(wish.cardQuery())) } - // 위시 item 을 원본 링크로 재추출해 최신화한다(수동 새로고침). 새 PENDING snapshot 을 작업 큐에 적재하고 - // wish 활성 포인터를 즉시 그 버전으로 스왑한다 — 디스패처가 PENDING 을 집어 추출해 READY/FAILED 로 전이한다(등록과 동일 흐름). - // 옛 snapshot 행은 유지돼 토너먼트 출전 격리를 지킨다. 외부 호출(추출)은 디스패처가 트랜잭션 밖에서 하므로 여기선 적재만 한다. - // 동시 새로고침은 wish 행 락(findByIdForUpdate)으로 직렬화하고, 이미 진행 중이면 멱등(no-op)으로 새 추출을 만들지 않는다. + // 아이템이 없는 위시는 같은 입력으로 다시 돌려도 실패하므로 사용자 입력으로 보내려고 409 로 막는다 @Transactional - fun refresh( - userId: UUID, - wishId: Long, - ): WishWithItem { - val wish = wishRepository.findByIdForUpdate(wishId) ?: throw WishException.notFound() - wish.verifyOwnedBy(userId) - // 기다리는 행·item 은 영속화 경로상 반드시 존재한다(없으면 코드 버그). - val activeSnapshot = - itemSnapshotRepository.findById(wish.waitingSnapshotId) - ?: error("wish ${wish.getId()} 의 snapshot ${wish.waitingSnapshotId} 가 없다") - val item = itemRepository.findById(wish.itemId) ?: error("wish ${wish.getId()} 의 item ${wish.itemId} 가 없다") - // link 없는 item(이미지 등록분)은 재추출 입력이 없어 새로고침 대상이 아니다(400). - item.link ?: throw WishException.notRefreshable() - // 이미 진행 중(PENDING·PROCESSING)이면 새 추출을 만들지 않고 현재 진행 상태를 그대로 반환(멱등). - if (activeSnapshot.isInProgress()) return WishWithItem(wish = wish, item = item, snapshot = activeSnapshot) - // 공유(#825) — 같은 item 의 다른 참조(다른 위시·출전)가 이미 파싱을 돌리고 있으면 새 작업 대신 그 진행에 - // 합류한다(#826). 활성 포인터를 그 버전으로 스왑해 완료 시 함께 갱신된다. - itemSnapshotRepository.findLatestInProgressByItemId(item.getId())?.let { inProgress -> - wish.waitFor(inProgress.getId()) - return WishWithItem(wish = wish, item = item, snapshot = inProgress) - } - // 판정은 표시값(#858) — 포인터가 FAILED 여도 같은 item 을 남이 담아 추출이 성공했으면 카드엔 그 값이 - // 떠 있고, item 이 추출 가능하다는 증거이므로 새로고침을 막을 이유가 없다. 표시값까지 FAILED(기계 READY - // 부재)면 재추출도 결정론적으로 재실패할 것이라 보정(recover, 수기 수정)으로 유도한다(409). - // 포인터부터 보는 단락 평가 — FAILED 가 아니면 표시값도 FAILED 일 수 없어(파생 후보가 READY 뿐) 쿼리가 무의미하다. - if (activeSnapshot.isFailed() && itemDisplayService.resolveDisplay(wish.displayCard()).isFailed()) { - throw WishException.failedNotRefreshable() - } - // 새 PENDING 버전을 작업 큐에 적재하고 활성 포인터를 즉시 스왑한다. 요청자는 새로고침한 본인(#1051). - val newSnapshot = parsingEnqueuer.enqueue(item.getId(), requestedBy = userId) - wish.waitFor(newSnapshot.getId()) - return WishWithItem(wish = wish, item = item, snapshot = newSnapshot) + fun refresh(command: RefreshWishItemCommand): WishResult { + val wish = wishRepository.findByIdForUpdate(command.wishId) ?: throw WishException.notFound() + wish.verifyOwnedBy(command.userId) + val item = wish.itemId?.let(itemRepository::findById) + val card = itemCardService.cardOf(wish.cardQuery()) + if (card.isParsing()) return WishResult(wish = wish, card = card) + item ?: throw WishException.failedNotRefreshable() + parsingEnqueuer.enqueue(wish.slot, ParsePurpose.REFRESH, item.refreshInput()) + return WishResult(wish = wish, card = itemCardService.cardOf(wish.cardQuery())) } -} -// 수기 수정 사전 검증(dry-run)에서 업로드 예정 이미지 자리를 메우는 자리표시 값 — 저장되지 않는다. -private const val PRE_UPLOAD_VALIDATION_IMAGE_URL = "https://validation.invalid/pre-upload.png" + private fun writeMemo( + wish: Wish, + rawMemo: String, + ) { + val memo = WishMemo.written(wish.getId(), rawMemo) + if (memo.sameContentAs(wishMemoRepository.findCurrentByWishId(wish.getId()))) return + wishMemoRepository.save(memo) + } -// 위시 한 장을 표시값 질의 카드로 — 상품·주인·기다리는 행(#1051). 목록·단건·수기 base·새로고침 가드가 같은 모양으로 묻는다. -internal fun Wish.displayCard(): DisplayCard = DisplayCard(itemId = itemId, owner = userId, waitingOn = waitingSnapshotId) + private fun survivorOf( + wish: Wish, + fill: ItemSlotFill, + ): Wish = + when (fill) { + ItemSlotFill.Filled -> wish + is ItemSlotFill.AlreadyHeld -> wishRepository.findById(fill.holder.id) ?: error("위시 ${fill.holder.id} 가 없다") + } +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishSlotPort.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishSlotPort.kt new file mode 100644 index 000000000..c4c10ea59 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishSlotPort.kt @@ -0,0 +1,45 @@ +package com.depromeet.piki.wishlist.service + +import com.depromeet.piki.item.domain.ItemSlotType +import com.depromeet.piki.item.service.ItemSlotFill +import com.depromeet.piki.item.service.ItemSlotPort +import com.depromeet.piki.item.service.ItemSlotState +import com.depromeet.piki.wishlist.domain.Wish +import com.depromeet.piki.wishlist.repository.WishMemoRepository +import com.depromeet.piki.wishlist.repository.WishRepository +import org.springframework.stereotype.Component + +@Component +class WishSlotPort( + private val wishRepository: WishRepository, + private val wishMemoRepository: WishMemoRepository, +) : ItemSlotPort { + override val type: ItemSlotType = ItemSlotType.WISH + + override fun find(id: Long): ItemSlotState = + wishRepository.findByIdIncludingDeletedForUpdate(id)?.let { ItemSlotState.Present(owner = it.userId, itemId = it.itemId) } ?: ItemSlotState.Gone + + override fun fill( + id: Long, + itemId: Long, + ): ItemSlotFill { + val wish = wishRepository.findByIdIncludingDeletedForUpdate(id) ?: error("위시 $id 이 없다") + val holder = + holderOf(wish, itemId) ?: run { + wish.fill(itemId) + return ItemSlotFill.Filled + } + wishMemoRepository.saveAll(wishMemoRepository.findAllByWishId(wish.getId()).map { it.copyTo(holder.getId()) }) + wish.delete() + return ItemSlotFill.AlreadyHeld(holder.slot, wish.userId, holder.userId) + } + + private fun holderOf( + wish: Wish, + itemId: Long, + ): Wish? { + wish.deletedAt?.let { return null } + wish.itemId?.let { return null } + return wishRepository.findByItemIdsAndUserId(listOf(itemId), wish.userId).firstOrNull() + } +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index da7ad9f9a..91ce432c1 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -1,36 +1,35 @@ package com.depromeet.piki.wishlist.service -import com.depromeet.piki.common.exception.skippingDuplicateKey import com.depromeet.piki.common.ratelimit.ItemQuotaGuard import com.depromeet.piki.common.storage.ImageStorage -import com.depromeet.piki.image.domain.ProductImage -import com.depromeet.piki.image.domain.UploadFormat import com.depromeet.piki.image.service.ImagePresignService import com.depromeet.piki.image.service.dto.PresignedRawUpload -import com.depromeet.piki.item.domain.Item 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.ItemCardService import com.depromeet.piki.item.service.ItemRegistrar -import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.item.service.UniqueConflictRetry import com.depromeet.piki.product.source.SourcePlatformResolver +import com.depromeet.piki.tournament.service.TournamentWinnerReader import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.service.UserService -import com.depromeet.piki.wishlist.domain.WishCursor -import com.depromeet.piki.wishlist.domain.WishDeleteIds -import com.depromeet.piki.wishlist.domain.WishErrorCode import com.depromeet.piki.wishlist.domain.WishException -import com.depromeet.piki.wishlist.domain.WishlistSize +import com.depromeet.piki.wishlist.repository.WishMemoRepository import com.depromeet.piki.wishlist.repository.WishRepository -import com.depromeet.piki.wishlist.service.dto.WishDetail -import com.depromeet.piki.wishlist.service.dto.WishWithItem -import com.depromeet.piki.wishlist.service.dto.WishlistPage +import com.depromeet.piki.wishlist.service.dto.ConfirmWishImageRegistrationCommand +import com.depromeet.piki.wishlist.service.dto.DeleteWishCommand +import com.depromeet.piki.wishlist.service.dto.DeleteWishesCommand +import com.depromeet.piki.wishlist.service.dto.GetWishQuery +import com.depromeet.piki.wishlist.service.dto.GetWishlistQuery +import com.depromeet.piki.wishlist.service.dto.PresignWishImageUploadsCommand +import com.depromeet.piki.wishlist.service.dto.RecoverWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RefreshWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import com.depromeet.piki.wishlist.service.dto.WishDetailResult +import com.depromeet.piki.wishlist.service.dto.WishResult +import com.depromeet.piki.wishlist.service.dto.WishlistResult import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.web.multipart.MultipartFile import java.util.UUID @Service @@ -40,16 +39,15 @@ class WishlistService( private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val wishRepository: WishRepository, - private val itemRepository: ItemRepository, + private val wishMemoRepository: WishMemoRepository, private val itemSnapshotRepository: ItemSnapshotRepository, - private val itemDisplayService: ItemDisplayService, + private val itemCardService: ItemCardService, 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)) + private fun WishResult.withSourcePlatform(): WishResult = copy(sourcePlatform = sourcePlatformResolver.resolve(card.sourceLink)) // TODO AOP private fun requireMember(userId: UUID) { @@ -57,69 +55,44 @@ class WishlistService( if (user.identityType != IdentityType.MEMBER) throw WishException.guestCannotUseWishlist() } - fun registerFromUrl( - rawUrl: String, - userId: UUID, - ): WishWithItem { - requireMember(userId) - val link = ProductLink.parse(rawUrl) - wishPersistenceService.rejectIfAlreadyRegistered(userId, link) - itemRegistrar.accept(link, userId) - return wishPersistenceService.persist(userId, link).withSourcePlatform() + fun registerFromUrl(command: RegisterWishFromUrlCommand): WishResult { + requireMember(command.userId) + wishPersistenceService.rejectIfAlreadyRegistered(command) + itemRegistrar.accept(command.link, command.userId) + return wishPersistenceService.persist(command).withSourcePlatform() } - fun presignImageUploads( - formats: List, - userId: UUID, - ): List { - requireMember(userId) - if (formats.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() - itemQuotaGuard.consume(userId, formats.size, ItemErrorCode.QUOTA_EXCEEDED) - return imagePresignService.presignRawUploads(formats) + fun presignImageUploads(command: PresignWishImageUploadsCommand): List { + requireMember(command.userId) + if (command.uploadFormats.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() + itemQuotaGuard.consume(command.userId, command.uploadFormats.size, ItemErrorCode.QUOTA_EXCEEDED) + return imagePresignService.presignProductUploads(command.uploadFormats) } - fun confirmImageRegistration( - imageKeys: List, - userId: UUID, - ): List { - 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) } - }.map { it.withSourcePlatform() } + fun confirmImageRegistration(command: ConfirmWishImageRegistrationCommand): List { + requireMember(command.userId) + if (command.imageKeys.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() + imagePresignService.verifyUploaded(command.imageKeys) + return wishPersistenceService.registerImages(command).map { it.withSourcePlatform() } } @Transactional(readOnly = true) - fun getWishlist( - userId: UUID, - rawCursor: String?, - rawSize: Int?, - ): WishlistPage { - requireMember(userId) - val cursor = WishCursor.parse(rawCursor) - val size = WishlistSize.of(rawSize).value - // hasNext 판단을 위해 한 건 더 조회하고, 초과분은 응답에서 잘라낸다. - val fetched = wishRepository.findPage(userId, cursor, size + 1) + fun getWishlist(query: GetWishlistQuery): WishlistResult { + requireMember(query.userId) + val size = query.size.value + val fetched = wishRepository.findPage(query.userId, query.cursor, size + 1) val hasNext = fetched.size > size val pageWishes = fetched.take(size) - // 표시값은 파생한다(#857·#1051) — 규칙은 ItemVersions 가 진다(내 맥락의 값 vs 공유 READY, 기다리는 행의 진행 중). - // 위시는 상품(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 cardByQuery = itemCardService.cardsOf(pageWishes.map { it.cardQuery() }) + // 우승 칩(#1236) — 이 페이지에 보이는 상품만 한 번에 물어 N+1 을 만들지 않는다 + val winnersByItem = tournamentWinnerReader.findWinnerTournamentIdsByItem(query.userId, pageWishes.mapNotNull { it.itemId }) val entries = pageWishes.map { wish -> - // item 은 wish 와 함께 영속화되며 병합 시 함께 옮겨진다(WishItemMergeListener). 없으면 영속화 경로가 깨진 코드 버그다. - val item = itemsById[wish.itemId] ?: error("wish ${wish.getId()} 의 item ${wish.itemId} 가 없다") - WishWithItem( + WishResult( wish = wish, - item = item, - snapshot = displayByCard.getValue(wish.displayCard()), - winnerTournamentIds = winnersByItem[wish.itemId].orEmpty(), + card = cardByQuery.getValue(wish.cardQuery()), + winnerTournamentIds = wish.itemId?.let { winnersByItem[it] }.orEmpty(), ).withSourcePlatform() } @@ -129,123 +102,56 @@ class WishlistService( ?.getId() ?.toString() .takeIf { hasNext } - return WishlistPage(entries = entries, nextCursor = nextCursor, hasNext = hasNext) + return WishlistResult(entries = entries, nextCursor = nextCursor, hasNext = hasNext) } - // wishId 로 상세 조회 — 표시값과 그 상품의 가격 이력을 함께 내려준다. 본인 위시만 볼 수 있고, - // 권한 검증은 도메인(verifyOwnedBy)에 맡긴다. findById 가 deletedAt IS NULL 만 보므로 삭제된 위시는 notFound(404). - // - // 이력을 별도 API 로 두지 않는 이유: 옛 히스토리 API 는 wish.snapshotId(포인터)를 그대로 "활성" 이라 불러 - // 표시값 파생(#857)을 타지 않았다. item 을 여러 사용자가 공유하므로 남이 새로고침하면 포인터는 옛 버전에 남고, - // 그러면 같은 위시에 대해 두 API 의 답이 어긋난다. 한 응답에서 표시값을 단일 출처로 두어 그 어긋남을 없앤다. @Transactional(readOnly = true) - fun getWish( - userId: UUID, - wishId: Long, - ): WishDetail { - requireMember(userId) - val wish = wishRepository.findById(wishId) ?: throw WishException.notFound() - wish.verifyOwnedBy(userId) - // wish 가 참조하는 item 은 반드시 존재한다(병합 시 함께 옮겨진다). 없으면 영속화 경로가 깨진 코드 버그다. - 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(), + fun getWish(query: GetWishQuery): WishDetailResult { + requireMember(query.userId) + val wish = wishRepository.findById(query.wishId) ?: throw WishException.notFound() + wish.verifyOwnedBy(query.userId) + val itemId = wish.itemId + val winnerTournamentIds = itemId?.let { tournamentWinnerReader.findWinnerTournamentIdsByItem(query.userId, listOf(it))[it] }.orEmpty() + return WishDetailResult( + wishResult = + WishResult(wish = wish, card = itemCardService.cardOf(wish.cardQuery()), winnerTournamentIds = winnerTournamentIds) + .withSourcePlatform(), + memo = wishMemoRepository.findCurrentByWishId(wish.getId())?.content, + history = itemId?.let { itemSnapshotRepository.findPriceHistoryByItemId(it, PRICE_HISTORY_LIMIT) }.orEmpty(), ) } - // 위시 item 의 수기 수정(#825 결정 4) — 상태 무관 언제든 허용하며, 기존 버전을 고치지 않고 MANUAL 새 버전을 - // 쌓아 활성 포인터를 스왑한다(편집자 기록·이력 보존). 이미지를 함께 주면 그대로 S3 에 올려 imageUrl 로 쓴다 - // (사용자가 고른 이미지를 가공 없이 대표 이미지로). 외부 호출(S3)을 트랜잭션에 넣지 않기 위해 - // 검증·소유권 사전확인·업로드를 트랜잭션 밖에서 끝내고, 영속화만 manualEdit(@Transactional, wish 행 락)에 위임한다. - fun recoverWishItem( - userId: UUID, - wishId: Long, - name: String?, - price: Int?, - currency: String?, - image: MultipartFile?, - memo: String?, - ): WishWithItem { - requireMember(userId) - // memo 만 온 요청은 버전을 쌓지 않는다 — memo 는 wish 행의 개인 필드라 snapshot 이력과 무관하다. - // item 필드가 함께 오면 아래 수기 수정 경로가 같은 트랜잭션(manualEdit)에서 memo 도 반영한다. - if (listOfNotNull(name, price, currency, image).isEmpty()) { - memo?.let { - // 응답의 item 은 updateMemo 가 행 락 안에서 표시값(#857)으로 맞춰 돌려준다. - return wishPersistenceService.updateMemo(userId = userId, wishId = wishId, memo = it).withSourcePlatform() - } - } - // 이미지 형식 검증(빈 바이트·미지원 MIME) — 외부 호출 전에 동기로 거른다(400). - val productImage = image?.let { ProductImage.of(it.bytes, it.contentType) } - // 업로드 전 사전 검증(orphan 방지)은 이미지가 있을 때만 — 그게 dry-run 의 유일한 존재 이유라, 업로드가 - // 없는 수정은 manualEdit(락 안)의 최종 판정 하나로 충분하다(예외·응답 동일, 쿼리만 줄어든다). - productImage?.let { - wishPersistenceService.validateManualEdit(userId, wishId, name, price, currency) - } - // 이미지가 있으면 S3 업로드(트랜잭션 밖). 실패 시 ImageStorageException(502). + // S3 업로드가 트랜잭션 안에 들어가지 않게 영속화만 applyUserInput 에 맡긴다 + fun recoverWishItem(command: RecoverWishItemCommand): WishResult { + requireMember(command.userId) + command.memoOnly()?.let { return wishPersistenceService.updateMemo(it).withSourcePlatform() } + command.image?.let { wishPersistenceService.validateUserInput(command) } val imageUrl = - productImage?.let { + command.image?.let { imageStorage.upload(it.bytes, "items/${UUID.randomUUID()}.${it.extension}", it.mimeType) } - return wishPersistenceService.manualEdit( - userId = userId, - wishId = wishId, - name = name, - price = price, - imageUrl = imageUrl, - currency = currency, - memo = memo, - ).withSourcePlatform() + return UniqueConflictRetry.once("wish=${command.wishId}") { wishPersistenceService.applyUserInput(command.toApplyUserInput(imageUrl)) }.withSourcePlatform() } - // 위시 item 의 상품 정보를 원본 링크로 재추출해 최신화한다(수동 새로고침). 추출(Gemini)은 디스패처가 비동기로 - // 하므로 여기엔 외부 호출이 없고, 영속화(새 PENDING 적재 + 활성 포인터 즉시 스왑)만 wishPersistenceService.refresh - // (@Transactional, wish 행 락)에 위임한다. 등록과 같은 폴링 흐름(PENDING→PROCESSING→READY/FAILED)으로 전이한다. - fun refreshWishItem( - userId: UUID, - wishId: Long, - ): WishWithItem { - requireMember(userId) - // 재추출도 파싱을 한 번 더 돌리므로 신규 등록과 같은 비용이다 — 1 로 차감한다. - // refresh 계약 검증(링크 없음·FAILED 항목 등)은 persistence 안쪽이라 여기선 앞서 깎이는데, 그 두 사유는 - // 클라가 refresh 버튼을 띄우지 않는 상태라 정상 흐름에서 반복 호출되지 않는다. - itemQuotaGuard.consume(userId, 1, ItemErrorCode.QUOTA_EXCEEDED) - return wishPersistenceService.refresh(userId = userId, wishId = wishId).withSourcePlatform() + fun refreshWishItem(command: RefreshWishItemCommand): WishResult { + requireMember(command.userId) + itemQuotaGuard.consume(command.userId, 1, ItemErrorCode.QUOTA_EXCEEDED) + return wishPersistenceService.refresh(command).withSourcePlatform() } - // 멱등 삭제: 없거나 이미 삭제됐으면 "이미 목표 상태(없음)"이므로 성공으로 본다(no-op). - // 단 존재하는 위시가 남의 것이면 소유권은 보안 경계라 403 으로 막는다. @Transactional - fun deleteWish( - userId: UUID, - wishId: Long, - ) { - requireMember(userId) - val wish = wishRepository.findById(wishId) ?: return - wish.verifyOwnedBy(userId) + fun deleteWish(command: DeleteWishCommand) { + requireMember(command.userId) + val wish = wishRepository.findById(command.wishId) ?: return + wish.verifyOwnedBy(command.userId) wish.delete() } - // 여러 위시를 한 번에 멱등 삭제한다. 없거나 이미 삭제된 id 는 조회에서 빠져 자연히 무시된다(목표 상태 달성). - // 존재하는 것 중 남의 위시가 하나라도 있으면 소유권 경계로 403, @Transactional 이라 본인 것도 함께 롤백된다. @Transactional - fun deleteWishes( - userId: UUID, - wishIds: WishDeleteIds, - ) { - requireMember(userId) - // WishDeleteIds 가 distinct·개수(1~100) 검증을 끝낸 값이라 여기선 조회·소유검증·삭제만 한다. - val wishes = wishRepository.findAllByIds(wishIds.values) - wishes.forEach { it.verifyOwnedBy(userId) } + fun deleteWishes(command: DeleteWishesCommand) { + requireMember(command.userId) + val wishes = wishRepository.findAllByIds(command.wishDeleteIds.values) + wishes.forEach { it.verifyOwnedBy(command.userId) } wishes.forEach { it.delete() } } @@ -253,10 +159,7 @@ class WishlistService( private const val MIN_IMAGE_COUNT = 1 private const val MAX_IMAGE_COUNT = 5 - // 상세 응답에 싣는 가격 이력의 상한. item 을 여러 사용자가 공유해 새로고침이 누적되는데 상세는 진입마다 - // 호출되므로, 상한이 없으면 응답이 시간에 비례해 계속 자란다. 가격 추이 용도에는 이 정도면 충분하고, - // 전체 이력이 실제로 필요해지면 그때 페이징을 갖춘 API 를 따로 만든다 (상세 응답 안에 페이징을 중첩하면 - // 방금 없앤 별도 히스토리 API 를 다시 만드는 꼴이다). + // 아이템을 여럿이 공유해 새로고침마다 이력이 쌓이므로 상한이 없으면 상세 응답이 계속 자란다 private const val PRICE_HISTORY_LIMIT = 50 } } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/ApplyWishUserInputCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/ApplyWishUserInputCommand.kt new file mode 100644 index 000000000..460ad4225 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/ApplyWishUserInputCommand.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.item.domain.UserInput +import java.util.UUID + +data class ApplyWishUserInputCommand( + val userId: UUID, + val wishId: Long, + val userInput: UserInput, + val memo: String?, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/ConfirmWishImageRegistrationCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/ConfirmWishImageRegistrationCommand.kt new file mode 100644 index 000000000..2286e512b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/ConfirmWishImageRegistrationCommand.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.image.domain.ProductImageKey +import java.util.UUID + +data class ConfirmWishImageRegistrationCommand( + val userId: UUID, + val imageKeys: List, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/DeleteWishCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/DeleteWishCommand.kt new file mode 100644 index 000000000..2b1cbff21 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/DeleteWishCommand.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.wishlist.service.dto + +import java.util.UUID + +data class DeleteWishCommand( + val userId: UUID, + val wishId: Long, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/DeleteWishesCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/DeleteWishesCommand.kt new file mode 100644 index 000000000..04b7bc06c --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/DeleteWishesCommand.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.wishlist.domain.WishDeleteIds +import java.util.UUID + +data class DeleteWishesCommand( + val userId: UUID, + val wishDeleteIds: WishDeleteIds, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/GetWishQuery.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/GetWishQuery.kt new file mode 100644 index 000000000..29a641e26 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/GetWishQuery.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.wishlist.service.dto + +import java.util.UUID + +data class GetWishQuery( + val userId: UUID, + val wishId: Long, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/GetWishlistQuery.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/GetWishlistQuery.kt new file mode 100644 index 000000000..b3ca020cd --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/GetWishlistQuery.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.wishlist.domain.WishCursor +import com.depromeet.piki.wishlist.domain.WishlistSize +import java.util.UUID + +data class GetWishlistQuery( + val userId: UUID, + val cursor: WishCursor?, + val size: WishlistSize, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/PresignWishImageUploadsCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/PresignWishImageUploadsCommand.kt new file mode 100644 index 000000000..3a0ea2f37 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/PresignWishImageUploadsCommand.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.image.domain.UploadFormat +import java.util.UUID + +data class PresignWishImageUploadsCommand( + val userId: UUID, + val uploadFormats: List, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RecoverWishItemCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RecoverWishItemCommand.kt new file mode 100644 index 000000000..e47f4c597 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RecoverWishItemCommand.kt @@ -0,0 +1,30 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.item.domain.UserInput +import java.util.UUID + +data class RecoverWishItemCommand( + val userId: UUID, + val wishId: Long, + val name: String?, + val price: Int?, + val currency: String?, + val image: ProductImage?, + val memo: String?, +) { + fun memoOnly(): UpdateWishMemoCommand? { + if (listOfNotNull(name, price, currency, image).isNotEmpty()) return null + return memo?.let { UpdateWishMemoCommand(userId = userId, wishId = wishId, memo = it) } + } + + private fun userInput(imageUrl: String?): UserInput = UserInput(name = name, price = price, imageUrl = imageUrl, currency = currency) + + fun toApplyUserInput(imageUrl: String?): ApplyWishUserInputCommand = + ApplyWishUserInputCommand( + userId = userId, + wishId = wishId, + userInput = userInput(imageUrl), + memo = memo, + ) +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RefreshWishItemCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RefreshWishItemCommand.kt new file mode 100644 index 000000000..25119ca9b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RefreshWishItemCommand.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.wishlist.service.dto + +import java.util.UUID + +data class RefreshWishItemCommand( + val userId: UUID, + val wishId: Long, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RegisterWishFromUrlCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RegisterWishFromUrlCommand.kt new file mode 100644 index 000000000..0b399817b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/RegisterWishFromUrlCommand.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.product.domain.ProductLink +import java.util.UUID + +data class RegisterWishFromUrlCommand( + val userId: UUID, + val link: ProductLink, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/UpdateWishMemoCommand.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/UpdateWishMemoCommand.kt new file mode 100644 index 000000000..1c6771364 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/UpdateWishMemoCommand.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.wishlist.service.dto + +import java.util.UUID + +data class UpdateWishMemoCommand( + val userId: UUID, + val wishId: Long, + val memo: String, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt deleted file mode 100644 index fd4a41f02..000000000 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.depromeet.piki.wishlist.service.dto - -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.wishlist.domain.Wish - -// 위시 상세 — wish 기록·상품 정체성(item)·표시 버전(snapshot)에 그 상품의 가격 이력(history)을 더한 묶음. -// 단건 조회가 이력까지 한 번에 내려주므로 클라이언트는 상세 화면에서 왕복 1회로 끝난다. -// -// history 는 그 상품의 가격 기록이다 — 출처가 남은 READY 버전(서버 추출·수기 모두, 편집자 무관)을 담고 출처 미상만 빠진다. -// snapshot 과는 서로 다른 축이다: snapshot 은 표시값 파생(#857)을 거친 "지금 화면에 보일 버전"이라 맥락 스코프(내 수기 존중, -// 타인 수기 무시)를 타는 반면, history 는 그 필터를 타지 않는다. 그래서 history 의 첫 항목이 snapshot 과 다를 수 있고 -// (타인의 수기가 가장 최신인 경우 등) 표시 버전이 진행 중·실패면 history 에 아예 없다 — 둘을 맞춰볼 대상이 아니다. -data class WishDetail( - val wish: Wish, - val item: Item, - val snapshot: ItemSnapshot, - val history: List, - val sourcePlatform: String?, - // 이 상품이 우승한 내 토너먼트들(#1236, 최신 우승 순). 목록과 같은 규칙으로 판정해 두 화면이 어긋나지 않게 한다. - val winnerTournamentIds: List = emptyList(), -) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetailResult.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetailResult.kt new file mode 100644 index 000000000..0ceab5250 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetailResult.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.item.domain.ItemSnapshot + +data class WishDetailResult( + val wishResult: WishResult, + val memo: String?, + val history: List, +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishResult.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishResult.kt new file mode 100644 index 000000000..cda362b26 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishResult.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.wishlist.service.dto + +import com.depromeet.piki.item.service.ItemCard +import com.depromeet.piki.wishlist.domain.Wish + +data class WishResult( + val wish: Wish, + val card: ItemCard, + val sourcePlatform: String? = null, + val winnerTournamentIds: List = emptyList(), +) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt deleted file mode 100644 index 446eba498..000000000 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.depromeet.piki.wishlist.service.dto - -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -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)가 링크에서 판정해 채운다. 영속화 계층은 비워 둔다. -// 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 = emptyList(), -) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishlistPage.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishlistPage.kt deleted file mode 100644 index b90d860e2..000000000 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishlistPage.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.depromeet.piki.wishlist.service.dto - -// 위시리스트 cursor 페이지네이션 한 페이지의 결과. -// nextCursor 는 다음 요청에 그대로 돌려보낼 커서(마지막 항목 wishId 문자열), 마지막 페이지면 null. -data class WishlistPage( - val entries: List, - val nextCursor: String?, - val hasNext: Boolean, -) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishlistResult.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishlistResult.kt new file mode 100644 index 000000000..d1c07dc50 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishlistResult.kt @@ -0,0 +1,7 @@ +package com.depromeet.piki.wishlist.service.dto + +data class WishlistResult( + val entries: List, + val nextCursor: String?, + val hasNext: Boolean, +) diff --git a/src/main/kotlin/db/migration/V20261019211847__update_tournament_item_added_template_to_candidate.kt b/src/main/kotlin/db/migration/V20261019211847__update_tournament_item_added_template_to_candidate.kt new file mode 100644 index 000000000..5e2b62fdc --- /dev/null +++ b/src/main/kotlin/db/migration/V20261019211847__update_tournament_item_added_template_to_candidate.kt @@ -0,0 +1,18 @@ +package db.migration + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +// 리터럴 dollar-brace 는 SQL 마이그레이션에서 Flyway placeholder 로 읽혀 JDBC 로 쓴다 +@Suppress("ClassName") +class V20261019211847__update_tournament_item_added_template_to_candidate : BaseJavaMigration() { + override fun migrate(context: Context) { + context.connection + .prepareStatement("UPDATE notification_templates SET title_template = ?, updated_at = NOW(6) WHERE type = ?") + .use { statement -> + statement.setString(1, "\${actorName}님이 토너먼트 후보를 담았어요") + statement.setString(2, "TOURNAMENT_ITEM_ADDED") + statement.executeUpdate() + } + } +} diff --git a/src/main/kotlin/db/migration/V20261019222814__seed_item_parsing_merged_template.kt b/src/main/kotlin/db/migration/V20261019222814__seed_item_parsing_merged_template.kt new file mode 100644 index 000000000..23aadd8cf --- /dev/null +++ b/src/main/kotlin/db/migration/V20261019222814__seed_item_parsing_merged_template.kt @@ -0,0 +1,19 @@ +package db.migration + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +// 리터럴 dollar-brace 는 SQL 마이그레이션에서 Flyway placeholder 로 읽혀 JDBC 로 쓴다 +@Suppress("ClassName") +class V20261019222814__seed_item_parsing_merged_template : BaseJavaMigration() { + override fun migrate(context: Context) { + context.connection + .prepareStatement("INSERT INTO notification_templates (type, title_template, body_template, updated_at) VALUES (?, ?, ?, NOW(6))") + .use { statement -> + statement.setString(1, "ITEM_PARSING_MERGED") + statement.setString(2, "\${itemName}") + statement.setString(3, "\${mergedMessage}") + statement.executeUpdate() + } + } +} diff --git a/src/main/resources/db/migration/V20260928205949__create_item_parse_outbox.sql b/src/main/resources/db/migration/V20260928205949__create_item_parse_outbox.sql index fb69b7497..adc7396d0 100644 --- a/src/main/resources/db/migration/V20260928205949__create_item_parse_outbox.sql +++ b/src/main/resources/db/migration/V20260928205949__create_item_parse_outbox.sql @@ -1,11 +1,18 @@ CREATE TABLE item_parse_outbox ( - id BIGINT NOT NULL AUTO_INCREMENT, - item_snapshot_id BIGINT NOT NULL, - status VARCHAR(16) NOT NULL, - created_at DATETIME(6) NOT NULL, - updated_at DATETIME(6) NOT NULL, - deleted_at DATETIME(6) NULL, + id BIGINT NOT NULL AUTO_INCREMENT, + slot_type VARCHAR(32) NOT NULL, + slot_id BIGINT NOT NULL, + purpose VARCHAR(16) NOT NULL, + input_type VARCHAR(16) NOT NULL, + input_value VARCHAR(768) NOT NULL, + -- 같은 슬롯의 중복 요청 판정 키. 링크는 추적 파라미터를 뗀 URL, 이미지는 키 그대로 + -- 대소문자만 다른 단축 코드가 같은 요청으로 묶이지 않게 바이트 단위로 비교한다 + normalized_input VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + status VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, PRIMARY KEY (id), - UNIQUE KEY uk_item_parse_outbox_item_snapshot_id (item_snapshot_id), + KEY idx_item_parse_outbox_slot (slot_type, slot_id), KEY idx_item_parse_outbox_status_created_at (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V20261004004615__resolve_items_on_parse_success.sql b/src/main/resources/db/migration/V20261004004615__resolve_items_on_parse_success.sql new file mode 100644 index 000000000..d773db4de --- /dev/null +++ b/src/main/resources/db/migration/V20261004004615__resolve_items_on_parse_success.sql @@ -0,0 +1,22 @@ +-- 아이템은 파싱 성공 때 확정되므로 위시·토너먼트 아이템의 아이템 칸은 확정 전 null 이다. +-- 버전을 가리키던 snapshot_id 와 버전 status 는 더 쓰지 않는다. 기존 행 백필(#1176 10단계) 전까지 지우지 않고 null 만 허용한다. +ALTER TABLE wishes + MODIFY COLUMN item_id BIGINT NULL, + MODIFY COLUMN snapshot_id BIGINT NULL; + +ALTER TABLE tournament_items + ADD COLUMN item_id BIGINT NULL, + MODIFY COLUMN snapshot_id BIGINT NULL, + ADD KEY idx_tournament_items_item_id (item_id); + +-- 기존 출전은 버전으로만 상품을 가리켰다. 새 아이템 칸을 그 버전의 아이템으로 채우지 않으면 아이템도 요청도 없는 카드가 된다 +UPDATE tournament_items t +JOIN item_snapshots s ON s.id = t.snapshot_id +SET t.item_id = s.item_id; + +-- 출전 값은 따로 고정하지 않고 시작 시각 기준으로 계산한다. +ALTER TABLE tournaments + ADD COLUMN started_at DATETIME(6) NULL; + +ALTER TABLE item_snapshots + MODIFY COLUMN status VARCHAR(16) NULL; diff --git a/src/main/resources/db/migration/V20261004060615__unique_items_canonical_url.sql b/src/main/resources/db/migration/V20261004060615__unique_items_canonical_url.sql new file mode 100644 index 000000000..0b18eb0ac --- /dev/null +++ b/src/main/resources/db/migration/V20261004060615__unique_items_canonical_url.sql @@ -0,0 +1,8 @@ +-- 정규화 URL 을 768자로 줄이고 유니크를 값에 직접 건다. 768 은 utf8mb4 인덱스 키 상한(3072B)이고 prod 실측 최대는 603자 +-- 해시 대리키는 조회 때 앱이 같은 식으로 해시를 만들어야 해 DB 식에 코드가 묶인다 +-- 대소문자만 다른 경로가 같은 상품으로 묶이지 않게 바이트 단위로 비교한다 +ALTER TABLE items DROP INDEX uq_items_canonical_hash; +ALTER TABLE items DROP COLUMN canonical_hash; +ALTER TABLE items + MODIFY COLUMN canonical_url VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + ADD UNIQUE KEY uq_items_canonical_url (canonical_url); diff --git a/src/main/resources/db/migration/V20261006010413__add_source_type_to_items.sql b/src/main/resources/db/migration/V20261006010413__add_source_type_to_items.sql new file mode 100644 index 000000000..5469123af --- /dev/null +++ b/src/main/resources/db/migration/V20261006010413__add_source_type_to_items.sql @@ -0,0 +1,8 @@ +-- 이미지 아이템도 정체 값(이미지 키)을 갖게 한다. 값이 비었는지로 링크와 이미지를 가르던 것을 종류 칸으로 옮긴다 +ALTER TABLE items ADD COLUMN source_type VARCHAR(16) NULL; + +UPDATE items +SET source_type = IF(source_image_key IS NULL, 'LINK', 'IMAGE'), + canonical_url = COALESCE(source_image_key, canonical_url); + +ALTER TABLE items MODIFY COLUMN source_type VARCHAR(16) NOT NULL; diff --git a/src/main/resources/db/migration/V20261009201031__split_item_snapshot_source_into_method_and_user_input.sql b/src/main/resources/db/migration/V20261009201031__split_item_snapshot_source_into_method_and_user_input.sql new file mode 100644 index 000000000..fbba23bdd --- /dev/null +++ b/src/main/resources/db/migration/V20261009201031__split_item_snapshot_source_into_method_and_user_input.sql @@ -0,0 +1,8 @@ +-- 한 칸에 담겼던 사실 둘을 가른다. method 는 추출 계약 enum 그대로, user_input 은 사람이 넣었는지 +ALTER TABLE item_snapshots ADD COLUMN user_input BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE item_snapshots SET user_input = TRUE, source = NULL WHERE source = 'MANUAL'; +UPDATE item_snapshots SET source = 'STRUCTURED' WHERE source = 'SERVER'; +UPDATE item_snapshots SET source = 'LLM' WHERE source = 'SERVER_LLM'; + +ALTER TABLE item_snapshots RENAME COLUMN source TO method; diff --git a/src/main/resources/db/migration/V20261009215111__fill_item_snapshots_created_by.sql b/src/main/resources/db/migration/V20261009215111__fill_item_snapshots_created_by.sql new file mode 100644 index 000000000..bbda79cc3 --- /dev/null +++ b/src/main/resources/db/migration/V20261009215111__fill_item_snapshots_created_by.sql @@ -0,0 +1,27 @@ +-- created_by 가 빈 옛 행을 채우고 NOT NULL 로 올린다. 이전 백필은 위시가 가리키는 버전만 채워 같은 아이템의 다른 버전이 남았다 +UPDATE item_snapshots s +JOIN ( + SELECT w.item_id, MIN(w.id) AS first_wish_id + FROM wishes w + WHERE w.item_id IS NOT NULL + GROUP BY w.item_id +) fw ON fw.item_id = s.item_id +JOIN wishes w ON w.id = fw.first_wish_id +SET s.created_by = w.user_id +WHERE s.created_by IS NULL; + +UPDATE item_snapshots s +JOIN ( + SELECT t.item_id, MIN(t.id) AS first_tournament_item_id + FROM tournament_items t + WHERE t.item_id IS NOT NULL + GROUP BY t.item_id +) ft ON ft.item_id = s.item_id +JOIN tournament_items t ON t.id = ft.first_tournament_item_id +SET s.created_by = t.user_id +WHERE s.created_by IS NULL; + +-- 아무 위시·출전도 가리키지 않는 아이템의 버전은 볼 수 있는 화면이 없다 +DELETE FROM item_snapshots WHERE created_by IS NULL; + +ALTER TABLE item_snapshots MODIFY COLUMN created_by BINARY(16) NOT NULL; diff --git a/src/main/resources/db/migration/V20261010175137__fill_items_canonical_url.sql b/src/main/resources/db/migration/V20261010175137__fill_items_canonical_url.sql new file mode 100644 index 000000000..f02eba652 --- /dev/null +++ b/src/main/resources/db/migration/V20261010175137__fill_items_canonical_url.sql @@ -0,0 +1,88 @@ +-- canonical_url 이 빈 옛 아이템을 채우고 NOT NULL 로 올린다. 채울 수 없는 행은 그 흔적까지 지운다 + +-- 1. 이미지 키 칸이 생기기 전의 이미지 등록 아이템. 버전이 가리키는 S3 객체 키가 정체 +UPDATE items i +JOIN ( + SELECT s.item_id, MAX(s.id) AS id + FROM item_snapshots s + WHERE s.image_url LIKE 'https://piki-images-%.amazonaws.com/%' + GROUP BY s.item_id +) latest ON latest.item_id = i.id +JOIN item_snapshots s ON s.id = latest.id +SET i.canonical_url = SUBSTRING_INDEX(SUBSTRING_INDEX(s.image_url, '.amazonaws.com/', -1), '?', 1), + i.source_type = 'IMAGE' +WHERE i.canonical_url IS NULL AND i.source_url IS NULL; + +-- 2. 같은 등록 원문으로 여러 번 만들어진 아이템은 첫 아이템으로 합친다 +-- 그 원문을 이미 정체로 가진 아이템이 있으면 그쪽에 합친다. 파싱 중 경합으로 생긴 빈 아이템에 원문을 채우면 정체 유니크에 걸린다 +-- 정체 주인은 따로 뽑아 id 로 붙인다. 원문(대소문자 무시)과 정체(바이트) 비교를 한 조인에 이으면 옵티마이저가 조건을 옮겨 묶음마다 결과가 갈린다 +CREATE TEMPORARY TABLE canonical_holders AS +SELECT b.id AS item_id, c.id AS holder +FROM items b +JOIN items c ON c.canonical_url = b.source_url +WHERE b.canonical_url IS NULL; + +CREATE TEMPORARY TABLE merged AS +SELECT a.id AS loser, COALESCE(MIN(h.holder), MIN(b.id)) AS winner +FROM items a +JOIN items b ON b.source_url = a.source_url AND b.canonical_url IS NULL +LEFT JOIN canonical_holders h ON h.item_id = b.id +WHERE a.canonical_url IS NULL AND a.source_url IS NOT NULL +GROUP BY a.id +HAVING loser <> winner; + +DROP TEMPORARY TABLE canonical_holders; + +UPDATE wishes w JOIN merged m ON m.loser = w.item_id SET w.item_id = m.winner; +UPDATE tournament_items t JOIN merged m ON m.loser = t.item_id SET t.item_id = m.winner; +UPDATE item_snapshots s JOIN merged m ON m.loser = s.item_id SET s.item_id = m.winner; + +-- 합친 뒤 한 사용자가 같은 아이템을 두 번 담은 꼴이 되면 나중 것을 지운다 +UPDATE wishes w +JOIN ( + SELECT w2.user_id, w2.item_id, MIN(w2.id) AS keep + FROM wishes w2 + JOIN merged m ON m.winner = w2.item_id + WHERE w2.deleted_at IS NULL + GROUP BY w2.user_id, w2.item_id + HAVING COUNT(*) > 1 +) d ON d.user_id = w.user_id AND d.item_id = w.item_id AND w.id <> d.keep +SET w.deleted_at = NOW(6) +WHERE w.deleted_at IS NULL; + +DELETE i FROM items i JOIN merged m ON m.loser = i.id; +DROP TEMPORARY TABLE merged; + +-- 3. 등록 원문이 정체. 정체 칸(768자)에 들어가지 않는 원문은 지금의 등록 상한도 넘어 4단계로 보낸다 +UPDATE items SET canonical_url = source_url WHERE canonical_url IS NULL AND source_url IS NOT NULL AND CHAR_LENGTH(source_url) <= 768; + +-- 4. 남은 행은 정체를 만들 재료가 없어 흔적째 지운다. 이미지 키 칸이 생기기 전 이미지 등록 중 결과 이미지도 남지 않은 것과 768자를 넘는 원문 +CREATE TEMPORARY TABLE doomed_items AS SELECT id FROM items WHERE canonical_url IS NULL; +CREATE TEMPORARY TABLE doomed_wishes AS SELECT w.id FROM wishes w JOIN doomed_items d ON d.id = w.item_id; +CREATE TEMPORARY TABLE doomed_entries AS SELECT t.id, t.tournament_id FROM tournament_items t JOIN doomed_items d ON d.id = t.item_id; +-- 우승 출전이 지워지는 토너먼트는 결과가 남을 수 없어 통째로 지운다 +CREATE TEMPORARY TABLE doomed_tournaments AS +SELECT DISTINCT h.tournament_id FROM tournament_histories h JOIN doomed_entries e ON e.id = h.selected_tournament_item_id WHERE h.current_round = 2; + +DELETE n FROM notifications n JOIN doomed_wishes w ON w.id = n.wish_id; +DELETE n FROM notifications n JOIN doomed_entries e ON e.id = n.tournament_item_id; +DELETE n FROM notifications n JOIN doomed_tournaments dt ON dt.tournament_id = n.tournament_id; +DELETE e FROM wish_external_entries e JOIN doomed_wishes w ON w.id = e.wish_id; +DELETE w FROM wishes w JOIN doomed_wishes d ON d.id = w.id; + +DELETE h FROM tournament_histories h JOIN doomed_tournaments dt ON dt.tournament_id = h.tournament_id; +DELETE h FROM tournament_histories h JOIN doomed_entries e ON e.id IN (h.first_tournament_item_id, h.second_tournament_item_id); +DELETE t FROM tournament_items t JOIN doomed_tournaments dt ON dt.tournament_id = t.tournament_id; +DELETE t FROM tournament_items t JOIN doomed_entries e ON e.id = t.id; +DELETE tu FROM tournament_users tu JOIN doomed_tournaments dt ON dt.tournament_id = tu.tournament_id; +DELETE tr FROM tournaments tr JOIN doomed_tournaments dt ON dt.tournament_id = tr.id; + +DELETE s FROM item_snapshots s JOIN doomed_items d ON d.id = s.item_id; +DELETE i FROM items i JOIN doomed_items d ON d.id = i.id; + +DROP TEMPORARY TABLE doomed_tournaments; +DROP TEMPORARY TABLE doomed_entries; +DROP TEMPORARY TABLE doomed_wishes; +DROP TEMPORARY TABLE doomed_items; + +ALTER TABLE items MODIFY COLUMN canonical_url VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; diff --git a/src/main/resources/db/migration/V20261014204931__create_wish_memos.sql b/src/main/resources/db/migration/V20261014204931__create_wish_memos.sql new file mode 100644 index 000000000..fc233da83 --- /dev/null +++ b/src/main/resources/db/migration/V20261014204931__create_wish_memos.sql @@ -0,0 +1,17 @@ +CREATE TABLE wish_memos ( + id BIGINT NOT NULL AUTO_INCREMENT, + wish_id BIGINT NOT NULL, + content VARCHAR(100) NULL, + written_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id), + KEY idx_wish_memos_wish_id_written_at (wish_id, written_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 쓴 시각이 남아 있지 않아 위시를 마지막으로 고친 시각으로 둔다 +INSERT INTO wish_memos (wish_id, content, written_at, created_at, updated_at) +SELECT id, memo, updated_at, NOW(6), NOW(6) +FROM wishes +WHERE memo IS NOT NULL AND memo <> ''; diff --git a/src/main/resources/db/migration/V20261019060410__add_live_item_unique_keys.sql b/src/main/resources/db/migration/V20261019060410__add_live_item_unique_keys.sql new file mode 100644 index 000000000..401bc9045 --- /dev/null +++ b/src/main/resources/db/migration/V20261019060410__add_live_item_unique_keys.sql @@ -0,0 +1,31 @@ +-- 같은 사용자가 같은 상품을 두 번 담은 위시는 먼저 담은 쪽에 합친다. 파싱 성공의 합치기와 같은 규칙 +CREATE TEMPORARY TABLE duplicate_wishes AS +SELECT w.id AS loser, d.keep +FROM wishes w +JOIN ( + SELECT user_id, item_id, MIN(id) AS keep + FROM wishes + WHERE deleted_at IS NULL AND item_id IS NOT NULL + GROUP BY user_id, item_id + HAVING COUNT(*) > 1 +) d ON d.user_id = w.user_id AND d.item_id = w.item_id AND w.id <> d.keep +WHERE w.deleted_at IS NULL; + +INSERT INTO wish_memos (wish_id, content, written_at, created_at, updated_at) +SELECT d.keep, m.content, m.written_at, NOW(6), NOW(6) +FROM wish_memos m +JOIN duplicate_wishes d ON d.loser = m.wish_id; + +UPDATE wishes w JOIN duplicate_wishes d ON d.loser = w.id SET w.deleted_at = NOW(6); + +DROP TEMPORARY TABLE duplicate_wishes; + +-- 지운 슬롯은 live 가 NULL 이라 유니크 검사에서 빠진다. 0 으로 두면 지운 슬롯끼리 겹친다 +ALTER TABLE wishes + ADD COLUMN live TINYINT GENERATED ALWAYS AS (IF(deleted_at IS NULL, 1, NULL)) VIRTUAL, + ADD UNIQUE KEY uq_wishes_user_id_item_id_live (user_id, item_id, live); + +-- 버전을 고정하던 옛 출전(snapshot_id 있음)은 검사에서 뺀다. 끝난 토너먼트의 대진 기록이라 겹쳐도 지울 수 없다 +ALTER TABLE tournament_items + ADD COLUMN live TINYINT GENERATED ALWAYS AS (IF(deleted_at IS NULL AND snapshot_id IS NULL, 1, NULL)) VIRTUAL, + ADD UNIQUE KEY uq_tournament_items_tournament_id_item_id_live (tournament_id, item_id, live); diff --git a/src/main/resources/db/migration/V20261020213005__fill_started_at_of_started_tournaments.sql b/src/main/resources/db/migration/V20261020213005__fill_started_at_of_started_tournaments.sql new file mode 100644 index 000000000..2443bf54b --- /dev/null +++ b/src/main/resources/db/migration/V20261020213005__fill_started_at_of_started_tournaments.sql @@ -0,0 +1,20 @@ +-- 시작 시각 칸이 생기기 전에 시작한 토너먼트의 출전 값을 고정한다. 비워 두면 새 버전이 생길 때마다 가격이 바뀌어 진행 중 대진이 어긋난다. +-- 실제 시작 시각은 남아 있지 않다. 옛 출전 값은 출전이 가리키던 버전이었으므로 그 버전들 중 가장 늦은 생성 시각으로 채운다. +-- 가리키던 버전이 없으면 그 아이템의 첫 버전 시각을 쓴다. 기준 시각까지 버전이 하나도 없는 출전이 생기면 카드를 계산할 수 없다. +UPDATE tournaments tr +JOIN ( + SELECT t.tournament_id, MAX(COALESCE(pin.created_at, earliest.created_at)) AS anchored_at + FROM tournament_items t + LEFT JOIN item_snapshots pin + ON pin.id = t.snapshot_id AND pin.item_id = t.item_id AND pin.deleted_at IS NULL + LEFT JOIN ( + SELECT item_id, MIN(created_at) AS created_at + FROM item_snapshots + WHERE deleted_at IS NULL + GROUP BY item_id + ) earliest ON earliest.item_id = t.item_id + WHERE t.deleted_at IS NULL AND t.item_id IS NOT NULL + GROUP BY t.tournament_id +) a ON a.tournament_id = tr.id +SET tr.started_at = a.anchored_at +WHERE tr.status <> 'PENDING' AND tr.started_at IS NULL AND a.anchored_at IS NOT NULL; diff --git a/src/main/resources/templates/admin/metrics-export.html b/src/main/resources/templates/admin/metrics-export.html index 6b061f083..dcb74806b 100644 --- a/src/main/resources/templates/admin/metrics-export.html +++ b/src/main/resources/templates/admin/metrics-export.html @@ -154,7 +154,7 @@

위시

파싱 전체 (성공 / 실패)0 / 0 · 0%
└ 위시 파싱 (등록+새로고침)0 / 0 · 0%
└ 토너먼트 파싱0 / 0 · 0%
-

활성 = 지금 살아있는 위시(삭제 제외, 전체 기간). 담기 = 구간 내 새로 담은 유입(삭제 무관). 파싱은 item_snapshots(구간 내 READY/FAILED)를 위시(등록+새로고침) vs 토너먼트 전용 아이템으로 분리 — user 차원이 없어 개발진 제외는 미적용.

+

활성 = 지금 살아있는 위시(삭제 제외, 전체 기간). 담기 = 구간 내 새로 담은 유입(삭제 무관). 파싱은 구간 내 끝난 요청(성공·실패)을 위시(등록+새로고침) vs 토너먼트 요청으로 분리 — user 차원이 없어 개발진 제외는 미적용.

리텐션 / DAU

diff --git a/src/main/resources/templates/admin/metrics.html b/src/main/resources/templates/admin/metrics.html index 5c04c5b94..2c63ac778 100644 --- a/src/main/resources/templates/admin/metrics.html +++ b/src/main/resources/templates/admin/metrics.html @@ -198,7 +198,7 @@

위시

파싱 전체 (성공 / 실패)0 / 0 · 0%
└ 위시 파싱 (등록+새로고침)0 / 0 · 0%
└ 토너먼트 파싱0 / 0 · 0%
-

활성 = 지금 살아있는 위시(삭제 제외, 전체 기간). 담기 = 구간 내 새로 담은 유입(삭제 무관). 파싱은 item_snapshots(구간 내 READY/FAILED)를 위시(등록+새로고침) vs 토너먼트 전용 아이템으로 분리 — user 차원이 없어 개발진 제외는 미적용.

+

활성 = 지금 살아있는 위시(삭제 제외, 전체 기간). 담기 = 구간 내 새로 담은 유입(삭제 무관). 파싱은 구간 내 끝난 요청(성공·실패)을 위시(등록+새로고침) vs 토너먼트 요청으로 분리 — user 차원이 없어 개발진 제외는 미적용.

리텐션 / DAU

diff --git a/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelIntegrationTest.kt index 3c6c95a84..7b37e1ef6 100644 --- a/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelIntegrationTest.kt @@ -1,8 +1,8 @@ package com.depromeet.piki.admin.extraction +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.service.remote.DbExtractionModelSettings import com.depromeet.piki.product.service.remote.ExtractionModelJpaRepository -import com.depromeet.piki.product.service.remote.ExtractionTarget import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubExtractionModelProbe import org.junit.jupiter.api.Test @@ -45,7 +45,7 @@ class AdminExtractionModelIntegrationTest : IntegrationTestSupport() { .apply(springSecurity()) .build() - private fun cleanUp(target: ExtractionTarget) { + private fun cleanUp(target: ParseInputType) { repository.deleteById(target.name) settings.reload() } @@ -61,12 +61,12 @@ class AdminExtractionModelIntegrationTest : IntegrationTestSupport() { .andExpect(status().is3xxRedirection) .andExpect(redirectedUrl("/admin/extraction-models?updated")) - assertEquals("gemini-probe-ok", settings.modelOf(ExtractionTarget.LINK)) + assertEquals("gemini-probe-ok", settings.modelOverrideOf(ParseInputType.LINK)) assertEquals("gemini-probe-ok", repository.findById("LINK").get().model) // 저장 전에 프로브를 거쳤다는 사실 자체가 게이트의 본질이다. - assertEquals(listOf(ExtractionTarget.LINK to "gemini-probe-ok"), probe.calls) + assertEquals(listOf(ParseInputType.LINK to "gemini-probe-ok"), probe.calls) } finally { - cleanUp(ExtractionTarget.LINK) + cleanUp(ParseInputType.LINK) } } @@ -81,9 +81,9 @@ class AdminExtractionModelIntegrationTest : IntegrationTestSupport() { .andExpect(status().isOk) assertTrue(repository.findById("IMAGE").isEmpty, "거절된 모델이 저장되면 게이트가 뚫린 것이다") - assertNull(settings.modelOf(ExtractionTarget.IMAGE)) + assertNull(settings.modelOverrideOf(ParseInputType.IMAGE)) } finally { - cleanUp(ExtractionTarget.IMAGE) + cleanUp(ParseInputType.IMAGE) } } @@ -101,7 +101,7 @@ class AdminExtractionModelIntegrationTest : IntegrationTestSupport() { assertTrue(repository.findById("LINK").isEmpty) assertTrue(probe.calls.isEmpty(), "경계에서 걸린 입력은 외부 호출을 유발하면 안 된다") } finally { - cleanUp(ExtractionTarget.LINK) + cleanUp(ParseInputType.LINK) } } @@ -113,7 +113,7 @@ class AdminExtractionModelIntegrationTest : IntegrationTestSupport() { mockMvc .perform(post("/admin/extraction-models/IMAGE").with(csrf()).param("model", "gemini-to-be-cleared")) .andExpect(status().is3xxRedirection) - assertEquals("gemini-to-be-cleared", settings.modelOf(ExtractionTarget.IMAGE)) + assertEquals("gemini-to-be-cleared", settings.modelOverrideOf(ParseInputType.IMAGE)) mockMvc .perform(post("/admin/extraction-models/IMAGE/clear").with(csrf())) @@ -121,9 +121,9 @@ class AdminExtractionModelIntegrationTest : IntegrationTestSupport() { .andExpect(redirectedUrl("/admin/extraction-models?cleared")) assertTrue(repository.findById("IMAGE").isEmpty) - assertNull(settings.modelOf(ExtractionTarget.IMAGE), "해제 후엔 요청에 모델이 실리지 않아야 한다") + assertNull(settings.modelOverrideOf(ParseInputType.IMAGE), "해제 후엔 요청에 모델이 실리지 않아야 한다") } finally { - cleanUp(ExtractionTarget.IMAGE) + cleanUp(ParseInputType.IMAGE) } } } diff --git a/src/test/kotlin/com/depromeet/piki/auth/controller/OAuthLoginIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/auth/controller/OAuthLoginIntegrationTest.kt index 2f61c4ee8..7b02a1e2e 100644 --- a/src/test/kotlin/com/depromeet/piki/auth/controller/OAuthLoginIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/auth/controller/OAuthLoginIntegrationTest.kt @@ -3,19 +3,21 @@ package com.depromeet.piki.auth.controller import com.depromeet.piki.auth.infrastructure.oauth.OAuthException import com.depromeet.piki.auth.infrastructure.oauth.OAuthProvider import com.depromeet.piki.auth.infrastructure.oauth.OAuthUserInfo -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.service.ParsingEnqueuer +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.StubOAuthClient import com.depromeet.piki.tournament.domain.Tournament import com.depromeet.piki.tournament.domain.TournamentItem import com.depromeet.piki.tournament.domain.TournamentUser import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository -import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.support.StubOAuthClient +import com.depromeet.piki.user.repository.UserDetailRepository import com.depromeet.piki.user.service.WithdrawalService import com.depromeet.piki.wishlist.domain.Wish -import com.depromeet.piki.user.repository.UserDetailRepository import com.depromeet.piki.wishlist.repository.WishRepository import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test @@ -42,6 +44,8 @@ import kotlin.test.assertNotEquals @Transactional class OAuthLoginIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var parsingEnqueuer: ParsingEnqueuer + @Autowired private lateinit var webApplicationContext: WebApplicationContext @@ -63,9 +67,6 @@ class OAuthLoginIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var wishRepository: WishRepository - @Autowired - private lateinit var itemSnapshotRepository: ItemSnapshotRepository - @Autowired private lateinit var userDetailRepository: UserDetailRepository @@ -228,9 +229,7 @@ class OAuthLoginIntegrationTest : IntegrationTestSupport() { val guest = createGuest() val guestId = UUID.fromString(guest.userId) // 게스트 상태에서 위시 1건 생성 (user_id = 게스트 id). 승격은 id 를 유지하므로 이 행이 그대로 따라와야 한다. - // wish 는 활성 snapshot 을 가리키므로(snapshotId NOT NULL) 대응 snapshot 을 먼저 시딩하고 그 id 를 넘긴다. - val snapshotId = itemSnapshotRepository.save(ItemSnapshot.pending(itemId = 1L, requestedBy = guestId)).getId() - wishRepository.save(Wish(userId = guestId, waitingSnapshotId = snapshotId, itemId = 1L)) + wishRepository.save(Wish(userId = guestId, itemId = null)) kakaoOAuthClient.fetchByAccessTokenStub = { OAuthUserInfo(OAuthProvider.KAKAO, "kakao_inherit", null) } val resultId = @@ -590,11 +589,7 @@ class OAuthLoginIntegrationTest : IntegrationTestSupport() { val guest = createGuest() val guestId = UUID.fromString(guest.userId) val tournamentId = createLegacyGuestTournament(guestId, "게스트가 만든 토너먼트") - val snapshotId = - itemSnapshotRepository - .save(ItemSnapshot.pending(itemId = 9001L, requestedBy = guestId)) - .getId() - tournamentItemRepository.save(TournamentItem(tournamentId = tournamentId, userId = guestId, snapshotId = snapshotId)) + addParsingItem(tournamentId, guestId) // 3. 게스트 토큰을 들고 같은 소셜로 로그인 → 게스트를 버리고 기존 회원으로 합류한다. val loginJson = loginWithGuestToken(guest.accessToken, body) @@ -732,16 +727,8 @@ class OAuthLoginIntegrationTest : IntegrationTestSupport() { // 담은 상품은 같은 사람이 담은 것이라 합쳐져야 한다 — 합치지 않으면 회원에게 3개만 보인다. val memberId = UUID.fromString(firstLogin.at("/data/user/id").asString()) val guestId = UUID.fromString(guest.userId) - listOf(memberId to 9101L, memberId to 9102L, memberId to 9103L, guestId to 9104L, guestId to 9105L) - .forEach { (owner, itemId) -> - val snapshotId = - itemSnapshotRepository - .save(ItemSnapshot.pending(itemId = itemId, requestedBy = owner)) - .getId() - tournamentItemRepository.save( - TournamentItem(tournamentId = sharedId, userId = owner, snapshotId = snapshotId), - ) - } + listOf(memberId, memberId, memberId, guestId, guestId) + .forEach { owner -> addParsingItem(sharedId, owner) } // 충돌과 무관한 게스트 전용 토너먼트도 하나 만든다. val guestOnlyId = createLegacyGuestTournament(guestId, "게스트만 있는 방") @@ -762,4 +749,14 @@ class OAuthLoginIntegrationTest : IntegrationTestSupport() { .perform(get("/api/v1/tournaments/$guestOnlyId").header(HttpHeaders.AUTHORIZATION, bearer(memberToken))) .andExpect(status().isOk) } + + // 아이템이 비어 있는 출전은 항상 파싱 요청과 함께 생긴다 + private fun addParsingItem( + tournamentId: Long, + owner: UUID, + ) { + val tournamentItem = tournamentItemRepository.save(TournamentItem(tournamentId = tournamentId, userId = owner, itemId = null)) + val link = ProductLink.parse("https://shop.example.com/products/${UUID.randomUUID()}") + parsingEnqueuer.enqueue(tournamentItem.slot, ParsePurpose.REGISTER, ParseInput.Link(link)) + } } diff --git a/src/test/kotlin/com/depromeet/piki/common/openapi/ErrorCodeCatalogTest.kt b/src/test/kotlin/com/depromeet/piki/common/openapi/ErrorCodeCatalogTest.kt index 56e76a6b1..142cbbfc6 100644 --- a/src/test/kotlin/com/depromeet/piki/common/openapi/ErrorCodeCatalogTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/openapi/ErrorCodeCatalogTest.kt @@ -54,10 +54,10 @@ class ErrorCodeCatalogTest { assertTrue(md.contains("### WISH"), md) assertTrue(md.contains("| WISH-001 | 403 | 위시리스트는 회원만 이용할 수 있어요. |"), md) assertTrue(md.contains("| WISH-003 | 400 | 페이지를 불러오지 못했어요. 새로고침 해주세요. |"), md) - assertTrue(md.contains("| WISH-004 | 404 | 이미 삭제된 아이템이에요. |"), md) + assertTrue(md.contains("| WISH-004 | 404 | 이미 삭제된 위시예요. |"), md) assertTrue(md.contains("| WISH-008 | 409 | 추출에 실패한 항목은 새로고침 대신 정보를 직접 입력해 복구해 주세요. |"), md) - // item 도메인 이관(#798) — 5개. 전부 FAILED 항목 보정(위시·토너먼트 아이템 공용 경로)에서 나온다. + // item 도메인 이관(#798) — 5개. 전부 FAILED 항목 사용자 입력(위시·토너먼트 아이템 공용 경로)에서 나온다. // 다른 도메인은 개수가 많아 대표만 단언하지만, item 은 5개뿐이라 전량 단언해 결번 없이 고정한다. assertTrue(md.contains("### ITEM"), md) // ITEM-001·002 는 수기 수정 상시 허용(#825 결정 4)으로 결번 — 카탈로그에서 빠졌음을 함께 고정한다. diff --git a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt index f23a27af5..3e62770bc 100644 --- a/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaIntegrationTest.kt @@ -6,14 +6,13 @@ import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.read.ListAppender import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.common.exception.CommonErrorCode -import com.depromeet.piki.item.domain.Item import com.depromeet.piki.item.domain.ItemErrorCode -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubImageStorage +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.support.presignImages import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.service.TournamentErrorCode @@ -39,7 +38,6 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper -import java.time.LocalDateTime import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -352,8 +350,7 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val ownerId = UUID.randomUUID() insertUser(ownerId, IdentityType.MEMBER) - // 등록 API 를 태우지 않고 READY 위시를 바로 만든다 — 등록분 차감을 섞지 않아야 "이동이 0" 인지가 선명하다. - // (출전은 활성 snapshot 이 READY 인 item 만 허용하므로 PENDING 인 갓 등록분으로는 이 경로를 탈 수 없다.) + // 등록분 차감을 섞지 않아야 이동이 0 인지가 선명하다 val itemId = insertReadyWish(ownerId) val (tournamentId, _) = createTournament(mockMvc, ownerId) @@ -543,22 +540,11 @@ class ItemQuotaIntegrationTest : IntegrationTestSupport() { private fun currentCount(userId: UUID): Long? = redisTemplate.opsForValue().get(RedisItemQuotaStore.USER_KEY_PREFIX + userId)?.toLong() - // 파싱이 끝난(READY) 위시 항목을 등록 API 없이 바로 만든다 — 토너먼트 출전은 활성 snapshot 이 READY 인 - // item 만 허용하므로, 등록 API 로 만든 PENDING 항목으로는 이동 경로를 탈 수 없다. itemId 를 돌려준다. + // 등록 API 를 거치지 않고 READY 위시를 만든다. 출전은 READY 아이템만 허용해 파싱 중인 갓 등록분으로는 이동 경로를 탈 수 없다 private fun insertReadyWish(userId: UUID): Long { - val item = itemJpaRepository.save(Item()) - val snapshot = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = "한도 테스트 아이템", - price = 10_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), - ) - wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) + val item = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save(itemVersion(itemId = item.getId(), name = "한도 테스트 아이템", createdBy = userId)) + wishJpaRepository.save(Wish(userId = userId, itemId = item.getId())) return item.getId() } diff --git a/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt b/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt index b122bab82..54f2242b7 100644 --- a/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt +++ b/src/test/kotlin/com/depromeet/piki/image/service/remote/HttpImageSnapshotExtractorTest.kt @@ -2,11 +2,13 @@ package com.depromeet.piki.image.service.remote import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.common.storage.S3Properties +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.item.service.ItemParsingMetrics +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.service.ProductSnapshotException import com.depromeet.piki.product.service.remote.ExtractionModelSettings -import com.depromeet.piki.product.service.remote.ExtractionTarget import com.depromeet.piki.product.service.remote.ProductExtractorException +import com.depromeet.piki.product.service.remote.RemoteExtractionContract import org.junit.jupiter.api.Test import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -19,7 +21,6 @@ import org.springframework.test.web.client.match.MockRestRequestMatchers.request import org.springframework.test.web.client.response.MockRestResponseCreators.withServerError import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess -import com.depromeet.piki.product.service.remote.RemoteExtractionContract import org.springframework.web.client.RestClient import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -31,15 +32,15 @@ import kotlin.test.assertNull // 쓰는 HttpLinkSnapshotExtractorTest 가 이미 고정하므로 여기서 반복하지 않는다. // 외부 경계(원격 HTTP)는 MockRestServiceServer 로 격리한다. class HttpImageSnapshotExtractorTest { - private val imageKey = "items/raw/0f8a1c2e.png" + private val imageKey = ProductImageKey.parse("items/raw/550e8400-e29b-41d4-a716-446655440000.png") // 지정한 축에만 값을 준다 — 이미지 추출기가 LINK 축을 읽는 회귀를 이 Fake 가 잡아야 한다 // (HttpLinkSnapshotExtractorTest 의 같은 Fake 와 대칭이다). private class FakeModelSettings( - private val axis: ExtractionTarget, + private val axis: ParseInputType, private val model: String?, ) : ExtractionModelSettings { - override fun modelOf(target: ExtractionTarget): String? = model?.takeIf { target == axis } + override fun modelOverrideOf(target: ParseInputType): String? = model?.takeIf { target == axis } } private fun extractorWith( @@ -56,7 +57,7 @@ class HttpImageSnapshotExtractorTest { return HttpImageSnapshotExtractor( builder.build(), S3Properties(bucket = "test-piki-images", publicBaseUrl = "https://img.test"), - FakeModelSettings(ExtractionTarget.IMAGE, model), + FakeModelSettings(ParseInputType.IMAGE, model), ) } @@ -69,7 +70,7 @@ class HttpImageSnapshotExtractorTest { .andExpect(method(HttpMethod.POST)) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.bucket").value("test-piki-images")) - .andExpect(jsonPath("$.key").value(imageKey)) + .andExpect(jsonPath("$.key").value(imageKey.value)) .andRespond( withSuccess( """{"name":"나이키 에어포스","imageUrl":"https://img.test/items/out.png","currentPrice":129000,"currency":"KRW"}""", @@ -80,7 +81,6 @@ class HttpImageSnapshotExtractorTest { val snapshot = extractor.extract(imageKey) - assertNull(snapshot.link, "이미지 추출엔 원본 URL 이 없다 (계약 §2 image)") assertEquals("나이키 에어포스", snapshot.name) assertEquals("https://img.test/items/out.png", snapshot.imageUrl) assertEquals(129_000, snapshot.price) @@ -104,7 +104,6 @@ class HttpImageSnapshotExtractorTest { val snapshot = extractor.extract(imageKey) - assertNull(snapshot.link) assertNull(snapshot.price, "사진에 가격이 없으면 사용자가 채운다 (INCOMPLETE)") assertEquals("핸드 워시", snapshot.name) } diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/ItemLinkTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/ItemLinkTest.kt deleted file mode 100644 index acbe0c351..000000000 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemLinkTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.depromeet.piki.item.domain - -import com.depromeet.piki.product.domain.CanonicalLink -import com.depromeet.piki.product.domain.ProductLink -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class ItemLinkTest { - @Test - fun `CanonicalLink 산출물로 만든 별칭은 유효하다`() { - val canonical = CanonicalLink.of(ProductLink.parse("https://www.musinsa.com/products/6760200")) - val link = ItemLink(url = canonical.url, urlHash = canonical.hash, itemId = 1L) - assertEquals(canonical.hash, link.urlHash) - } - - @Test - fun `hash 길이가 64자가 아니면 호출부 버그로 본다`() { - assertFailsWith { - ItemLink(url = "https://www.musinsa.com/products/1", urlHash = "short", itemId = 1L) - } - } - - @Test - fun `빈 url 은 호출부 버그로 본다`() { - assertFailsWith { - ItemLink(url = " ", urlHash = "a".repeat(64), itemId = 1L) - } - } -} diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/ItemParseOutboxTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/ItemParseOutboxTest.kt index 4e2808e83..428ecdb4b 100644 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemParseOutboxTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/domain/ItemParseOutboxTest.kt @@ -1,18 +1,63 @@ package com.depromeet.piki.item.domain +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.product.domain.ParseInputType +import com.depromeet.piki.product.domain.ProductLink import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class ItemParseOutboxTest { @Test fun `새 아웃박스는 PENDING 이다`() { - assertEquals(ItemParseOutboxStatus.PENDING, ItemParseOutbox(itemSnapshotId = 1L).status) + assertEquals(ItemParseOutboxStatus.PENDING, pending().status) + } + + @Test + fun `링크 입력은 원본 URL 과 추적 파라미터를 뺀 URL 을 든다`() { + val link = ProductLink.parse("https://shop.example.com/p/1?utm_source=x") + + val outbox = ItemParseOutbox.of(ItemSlot.wish(7L), ParsePurpose.REGISTER, ParseInput.Link(link)) + + assertEquals(ItemSlot.wish(7L), outbox.slot) + assertEquals(ParseInputType.LINK, outbox.inputType) + assertEquals("https://shop.example.com/p/1?utm_source=x", outbox.inputValue) + assertEquals("https://shop.example.com/p/1", outbox.normalizedInput?.value) + assertEquals(ParseInput.Link(link), outbox.input) + } + + @Test + fun `이미지 입력은 이미지 키가 값이자 중복 판정 키다`() { + val outbox = ItemParseOutbox.of(ItemSlot.tournamentItem(3L), ParsePurpose.REGISTER, ParseInput.Image(ProductImageKey.parse(IMAGE_KEY))) + + assertEquals(ItemSlotType.TOURNAMENT_ITEM, outbox.slotType) + assertEquals(ParseInputType.IMAGE, outbox.inputType) + assertEquals(IMAGE_KEY, outbox.inputValue) + assertEquals(IMAGE_KEY, outbox.normalizedInput?.value) + assertEquals(ParseInput.Image(ProductImageKey.parse(IMAGE_KEY)), outbox.input) + } + + @Test + fun `요청 목적은 만들 때 정해지고 새로고침 여부는 그 값으로 답한다`() { + val input = ParseInput.Link(ProductLink.parse("https://shop.example.com/p/1")) + + assertFalse(ItemParseOutbox.of(ItemSlot.wish(1L), ParsePurpose.REGISTER, input).isRefresh()) + assertTrue(ItemParseOutbox.of(ItemSlot.wish(1L), ParsePurpose.REFRESH, input).isRefresh()) + } + + @Test + fun `저장 상한을 넘는 URL 은 중복 판정 키를 남기지 않는다`() { + val link = ProductLink.parse("https://shop.example.com/p/1?q=" + "a".repeat(ProductLink.MAX_LENGTH)) + + assertNull(ItemParseOutbox.of(ItemSlot.wish(1L), ParsePurpose.REGISTER, ParseInput.Link(link)).normalizedInput) } @Test fun `PENDING 을 집으면 PROCESSING 이 된다`() { - val outbox = ItemParseOutbox(itemSnapshotId = 1L) + val outbox = pending() outbox.claim() @@ -20,7 +65,7 @@ class ItemParseOutboxTest { } @Test - fun `PROCESSING 은 성공·실패로 닫히거나 PENDING 으로 되돌아간다`() { + fun `PROCESSING 은 성공 실패로 닫히거나 PENDING 으로 되돌아간다`() { listOf( ItemParseOutbox::succeed to ItemParseOutboxStatus.SUCCEEDED, ItemParseOutbox::fail to ItemParseOutboxStatus.FAILED, @@ -34,15 +79,13 @@ class ItemParseOutboxTest { @Test fun `PENDING 이 아닌 아웃박스는 집을 수 없다`() { - val outbox = processing() - - assertFailsWith { outbox.claim() } + assertFailsWith { processing().claim() } } @Test fun `PROCESSING 이 아닌 아웃박스는 닫거나 되돌릴 수 없다`() { listOf(ItemParseOutbox::succeed, ItemParseOutbox::fail, ItemParseOutbox::requeue).forEach { transit -> - assertFailsWith { transit(ItemParseOutbox(itemSnapshotId = 1L)) } + assertFailsWith { transit(pending()) } } } @@ -53,5 +96,12 @@ class ItemParseOutboxTest { assertFailsWith { outbox.fail() } } - private fun processing(): ItemParseOutbox = ItemParseOutbox(itemSnapshotId = 1L).apply { claim() } + private fun pending(): ItemParseOutbox = + ItemParseOutbox.of(ItemSlot.wish(1L), ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse("https://shop.example.com/p/1"))) + + private fun processing(): ItemParseOutbox = pending().apply { claim() } + + companion object { + private const val IMAGE_KEY = "items/raw/550e8400-e29b-41d4-a716-446655440000.png" + } } diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotSourceTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotSourceTest.kt deleted file mode 100644 index 03edb6e07..000000000 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotSourceTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.depromeet.piki.item.domain - -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class ItemSnapshotSourceTest { - @ParameterizedTest - @CsvSource( - "STRUCTURED, SERVER", - "LLM, SERVER_LLM", - ) - fun `추출 경로 wire 문자열이 출처로 번역된다`( - wire: String, - expected: ItemSnapshotSource, - ) { - assertEquals(expected, ItemSnapshotSource.fromWireMethod(wire)) - } - - @Test - fun `모르는 값과 null 은 출처 미기록으로 둔다 - tolerant reader`() { - assertNull(ItemSnapshotSource.fromWireMethod(null)) - assertNull(ItemSnapshotSource.fromWireMethod("SOME_NEW_METHOD")) - assertNull(ItemSnapshotSource.fromWireMethod("structured")) - } -} diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotTest.kt index e8637e73e..b2d62d59a 100644 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotTest.kt @@ -1,322 +1,221 @@ package com.depromeet.piki.item.domain +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.product.service.ProductSnapshot -import java.time.LocalDateTime +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import java.util.UUID class ItemSnapshotTest { - @Test - fun `추출 필드가 모두 채워진 READY 스냅샷을 생성한다`() { - val snapshot = ItemSnapshot( - itemId = 1L, - name = "나이키 에어포스", - imageUrl = "https://img.example.com/a.png", - price = 99_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.of(2026, 6, 3, 12, 0), - ) - assertEquals(1L, snapshot.itemId) - assertEquals("나이키 에어포스", snapshot.name) - assertEquals(99_000, snapshot.price) - assertEquals(ItemStatus.READY, snapshot.status) - } - - @Test - fun `추출 전 스냅샷은 status 기본값 PENDING 이고 추출 필드가 비어 있어도 생성된다`() { - val snapshot = ItemSnapshot(itemId = 1L) - assertEquals(ItemStatus.PENDING, snapshot.status) - assertNull(snapshot.name) - assertNull(snapshot.price) - assertNull(snapshot.imageUrl) - assertNull(snapshot.currency) - assertNull(snapshot.extractedAt) - } - @Test fun `price 가 음수면 생성에 실패한다`() { - assertFailsWith { - ItemSnapshot(itemId = 1L, price = -1) - } + assertFailsWith { version(price = -1) } } @Test fun `name 이 512자를 초과하면 생성에 실패한다`() { - assertFailsWith { - ItemSnapshot(itemId = 1L, name = "가".repeat(513)) - } + assertFailsWith { version(name = "가".repeat(513)) } } @Test fun `imageUrl 이 2048자를 초과하면 생성에 실패한다`() { - assertFailsWith { - ItemSnapshot(itemId = 1L, imageUrl = "h".repeat(2049)) - } + assertFailsWith { version(imageUrl = "h".repeat(2049)) } } @Test fun `currency 가 8자를 초과하면 생성에 실패한다`() { - assertFailsWith { - ItemSnapshot(itemId = 1L, currency = "123456789") - } + assertFailsWith { version(currency = "123456789") } } @Test - fun `경계값 — name 512자·currency 8자·price 0 은 허용된다`() { - val snapshot = ItemSnapshot( - itemId = 1L, - name = "가".repeat(512), - currency = "12345678", - price = 0, - ) + fun `name 512자 currency 8자 price 0 은 경계값이라 허용된다`() { + val snapshot = version(name = "가".repeat(512), currency = "12345678", price = 0) + assertEquals(512, snapshot.name?.length) assertEquals(8, snapshot.currency?.length) assertEquals(0, snapshot.price) } @Test - fun `PENDING 스냅샷을 markExtracted 하면 추출 결과로 채워지고 READY 와 extractedAt 이 설정된다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - val status = - snapshot.markExtracted( - ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, currency = "KRW"), - ) - assertEquals(ItemStatus.READY, status) - assertEquals(ItemStatus.READY, snapshot.status) - assertEquals("나이키", snapshot.name) - assertEquals(99_000, snapshot.price) - assertNotNull(snapshot.extractedAt) - } - - @Test - fun `markExtracted 는 추출 경로를 출처로 번역해 기록한다 - 구버전 응답은 미기록`() { - val fromParser = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - fromParser.markExtracted( - ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, extractionMethod = "STRUCTURED"), - ) - assertEquals(ItemSnapshotSource.SERVER, fromParser.source) + fun `이름 가격 이미지가 모두 있으면 READY 다`() { + val snapshot = version() - val fromLlm = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - fromLlm.markExtracted( - ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, extractionMethod = "LLM"), - ) - assertEquals(ItemSnapshotSource.SERVER_LLM, fromLlm.source) - - val legacy = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - legacy.markExtracted( - ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000), - ) - assertNull(legacy.source) + assertTrue(snapshot.isReady()) + assertFalse(snapshot.isIncomplete()) } @Test - fun `markExtracted 시 name 이 없으면 INCOMPLETE 로 전이하고 얻은 값은 채워진다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - val status = snapshot.markExtracted(ProductSnapshot(price = 1_000, imageUrl = "https://img.example.com/a.png")) - assertEquals(ItemStatus.INCOMPLETE, status) - assertEquals(ItemStatus.INCOMPLETE, snapshot.status) - assertEquals(1_000, snapshot.price) - assertEquals("https://img.example.com/a.png", snapshot.imageUrl) - assertNull(snapshot.name) - assertNotNull(snapshot.extractedAt) + fun `이름 가격 이미지 중 하나라도 없으면 INCOMPLETE 다`() { + listOf( + version(name = null), + version(price = null), + version(imageUrl = null), + ).forEach { snapshot -> + assertFalse(snapshot.isReady()) + assertTrue(snapshot.isIncomplete()) + } } @Test - fun `markExtracted 시 price 가 없으면 INCOMPLETE 로 전이한다 - 사진에 가격이 없는 정상 입력이다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - val status = snapshot.markExtracted(ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png")) - assertEquals(ItemStatus.INCOMPLETE, status) - assertEquals("나이키", snapshot.name) - assertNull(snapshot.price) + fun `blank name 은 값으로 세지 않는다`() { + assertTrue(version(name = " ").isIncomplete()) } @Test - fun `markExtracted 시 imageUrl 이 없으면 INCOMPLETE 로 전이한다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - val status = snapshot.markExtracted(ProductSnapshot(name = "나이키", price = 99_000)) - assertEquals(ItemStatus.INCOMPLETE, status) - assertNull(snapshot.imageUrl) + fun `통화가 없어도 이름 가격 이미지가 있으면 READY 다`() { + assertTrue(version(currency = null).isReady()) } @Test - fun `markExtracted 가 값을 하나도 못 얻으면 FAILED 로 전이하고 extractedAt 도 남기지 않는다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - val status = snapshot.markExtracted(ProductSnapshot(currency = "KRW")) - assertEquals(ItemStatus.FAILED, status) - assertEquals(ItemStatus.FAILED, snapshot.status) - assertNull(snapshot.extractedAt) - } + fun `extracted 는 추출값과 요청자로 버전을 만든다`() { + val slot = UUID.randomUUID() - @Test - fun `markExtracted 는 blank name 을 값으로 세지 않아 나머지가 없으면 FAILED 다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - assertEquals(ItemStatus.FAILED, snapshot.markExtracted(ProductSnapshot(name = " "))) + val snapshot = + ItemSnapshot.extracted( + itemId = 1L, + extracted = ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, currency = "KRW"), + createdBy = slot, + ) + + assertEquals(1L, snapshot.itemId) + assertEquals("나이키", snapshot.name) + assertEquals(99_000, snapshot.price) + assertEquals("KRW", snapshot.currency) + assertEquals(slot, snapshot.createdBy) + assertTrue(snapshot.isReady()) + } + + @Test + fun `extracted 는 추출 경로를 그대로 들고 사용자 입력이 아니다`() { + listOf(ExtractionMethod.STRUCTURED, ExtractionMethod.LLM, null).forEach { method -> + val snapshot = + ItemSnapshot.extracted( + itemId = 1L, + extracted = ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, method = method), + createdBy = UUID.randomUUID(), + ) + assertEquals(method, snapshot.method) + assertFalse(snapshot.userInput) + } } @Test - fun `INCOMPLETE 는 READY 취급을 받지 못한다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - snapshot.markExtracted(ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png")) - assertFalse(snapshot.isReady()) + fun `extracted 는 일부 값만 있어도 INCOMPLETE 버전을 만든다`() { + val snapshot = + ItemSnapshot.extracted( + itemId = 1L, + extracted = ProductSnapshot(price = 1_000, imageUrl = "https://img.example.com/a.png"), + createdBy = UUID.randomUUID(), + ) + assertTrue(snapshot.isIncomplete()) - assertFalse(snapshot.isFailed()) - assertFalse(snapshot.isInProgress()) + assertNull(snapshot.name) + assertEquals(1_000, snapshot.price) } @Test - fun `PENDING 스냅샷을 markFailed 하면 FAILED 가 된다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - snapshot.markFailed() - assertEquals(ItemStatus.FAILED, snapshot.status) + fun `칸 길이를 넘는 추출 결과로는 버전을 만들 수 없다`() { + listOf( + ProductSnapshot(name = "가".repeat(ItemSnapshot.NAME_MAX_LENGTH + 1), price = 1_000), + ProductSnapshot(name = "상품", imageUrl = "https://cdn.example.com/" + "a".repeat(ItemSnapshot.IMAGE_URL_MAX_LENGTH)), + ).forEach { extracted -> + assertFalse(ItemSnapshot.accepts(extracted)) + assertFailsWith { + ItemSnapshot.extracted(itemId = 1L, extracted = extracted, createdBy = UUID.randomUUID()) + } + } } - @Test - fun `PENDING 이 아닌 스냅샷을 markExtracted 하면 IllegalStateException`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - snapshot.markFailed() - assertFailsWith { - snapshot.markExtracted(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) + fun `값이 하나도 없는 추출 결과로는 버전을 만들 수 없다`() { + listOf(ProductSnapshot(currency = "KRW"), ProductSnapshot(name = " ")).forEach { extracted -> + assertFalse(ItemSnapshot.accepts(extracted)) + assertFailsWith { + ItemSnapshot.extracted(itemId = 1L, extracted = extracted, createdBy = UUID.randomUUID()) + } } } @Test - fun `manual 은 base 값 위에 입력을 병합한 READY 새 버전을 만들고 base 는 그대로다`() { - val base = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - base.markExtracted(ProductSnapshot(name = "나이키", price = 99_000, imageUrl = "https://img.example.com/a.png", currency = "KRW")) - val editor = java.util.UUID.randomUUID() + fun `userInput 은 base 값 위에 입력을 합친 READY 새 버전을 만들고 base 는 그대로다`() { + val base = version(name = "나이키", price = 99_000, currency = "KRW") + val editor = UUID.randomUUID() - val manual = ItemSnapshot.manual(base = base, name = null, price = 79_000, imageUrl = null, currency = null, createdBy = editor) + val userVersion = ItemSnapshot.userInput(1L, UserInputValues.of(UserInput(name = null, price = 79_000, imageUrl = null, currency = null).filledFrom(base)), createdBy = editor) - assertEquals(ItemStatus.READY, manual.status) - assertEquals("나이키", manual.name) - assertEquals(79_000, manual.price) - assertEquals("https://img.example.com/a.png", manual.imageUrl) - assertEquals(ItemSnapshotSource.MANUAL, manual.source) - assertEquals(editor, manual.createdBy) - assertNotNull(manual.extractedAt) - // 기계 버전 불변 — 이력 보존의 핵심. + assertTrue(userVersion.isReady()) + assertEquals("나이키", userVersion.name) + assertEquals(79_000, userVersion.price) + assertEquals(base.imageUrl, userVersion.imageUrl) + assertEquals("KRW", userVersion.currency) + assertTrue(userVersion.userInput) + assertNull(userVersion.method) + assertEquals(editor, userVersion.createdBy) assertEquals(99_000, base.price) - assertEquals(ItemStatus.READY, base.status) } - // INCOMPLETE 의 완성 경로 — 추출이 못 채운 필드를 사용자가 채우면 READY 가 된다(#944). 이 시나리오가 곧 - // "채울 수 있는 만큼 채워 내려주고 나머지는 사용자가" 의 계약이라, 전이와 수기 수정이 맞물리는 지점을 고정한다. + // 추출이 못 채운 필드를 사용자가 채우면 READY 가 된다는 INCOMPLETE 의 완성 경로(#944) @Test - fun `INCOMPLETE 를 base 로 빈 필드를 채우면 READY 새 버전이 되고 base 는 그대로다`() { - val base = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - base.markExtracted(ProductSnapshot(name = "몬치치 인형", imageUrl = "https://img.example.com/a.png")) - assertEquals(ItemStatus.INCOMPLETE, base.status) + fun `INCOMPLETE base 의 빈 필드를 채우면 추출이 건진 값과 합친 READY 버전이 된다`() { + val base = version(name = "몬치치 인형", price = null) - val manual = - ItemSnapshot.manual( - base = base, - name = null, - price = 25_000, - imageUrl = null, - currency = "KRW", - createdBy = java.util.UUID.randomUUID(), - ) - - assertEquals(ItemStatus.READY, manual.status) - assertEquals("몬치치 인형", manual.name, "추출이 건진 값은 사용자가 다시 입력하지 않아도 병합된다") - assertEquals(25_000, manual.price) - assertEquals(ItemSnapshotSource.MANUAL, manual.source) - assertEquals(ItemStatus.INCOMPLETE, base.status, "기계 버전은 불변 — 이력으로 남는다") - } + val userVersion = ItemSnapshot.userInput(1L, UserInputValues.of(UserInput(name = null, price = 25_000, imageUrl = null, currency = "KRW").filledFrom(base)), createdBy = UUID.randomUUID()) - @Test - fun `INCOMPLETE base 에 채워도 여전히 빈 필드가 남으면 ItemException(400)`() { - val base = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - base.markExtracted(ProductSnapshot(imageUrl = "https://img.example.com/a.png")) - assertFailsWith { - ItemSnapshot.manual(base = base, name = "몬치치", price = null, imageUrl = null, currency = null, createdBy = java.util.UUID.randomUUID()) - } + assertTrue(userVersion.isReady()) + assertEquals("몬치치 인형", userVersion.name) + assertEquals(25_000, userVersion.price) + assertTrue(base.isIncomplete()) } @Test - fun `manual 은 상태 제한이 없다 - PENDING·FAILED base 로도 새 버전을 만든다`() { - val editor = java.util.UUID.randomUUID() - listOf( - ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()), - ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).apply { markFailed() }, - ).forEach { base -> - val manual = ItemSnapshot.manual( - base = base, - name = "수기 입력", - price = 5_000, - imageUrl = "https://img.example.com/m.png", - currency = "KRW", - createdBy = editor, + fun `base 가 없으면 입력만으로 READY 버전을 만든다`() { + val userVersion = + ItemSnapshot.userInput( + 1L, + UserInputValues.of(UserInput(name = "수기 입력", price = 5_000, imageUrl = "https://img.example.com/m.png", currency = "KRW")), + createdBy = UUID.randomUUID(), ) - assertEquals(ItemStatus.READY, manual.status) - assertEquals(ItemSnapshotSource.MANUAL, manual.source) - } - } - @Test - fun `manual 병합 후에도 name 이 비면 ItemException(400)`() { - val base = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).apply { markFailed() } - assertFailsWith { - ItemSnapshot.manual(base = base, name = null, price = 1_000, imageUrl = "https://img.example.com/a.png", currency = "KRW", createdBy = java.util.UUID.randomUUID()) - } + assertTrue(userVersion.isReady()) + assertTrue(userVersion.userInput) } @Test - fun `manual 병합 후에도 price 가 없으면 ItemException(400)`() { - val base = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).apply { markFailed() } - assertFailsWith { - ItemSnapshot.manual(base = base, name = "수기", price = null, imageUrl = "https://img.example.com/a.png", currency = "KRW", createdBy = java.util.UUID.randomUUID()) - } - } + fun `사용자 입력을 합친 뒤에도 이름 가격 이미지 중 하나가 비면 ItemException 이다`() { + val inputs = + listOf( + Triple(null, 1_000, "https://img.example.com/a.png"), + Triple("수기", null, "https://img.example.com/a.png"), + Triple("수기", 5_000, null), + ) - @Test - fun `manual 병합 후에도 imageUrl 이 없으면 ItemException(400)`() { - val base = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).apply { markFailed() } - assertFailsWith { - ItemSnapshot.manual(base = base, name = "수기", price = 5_000, imageUrl = null, currency = "KRW", createdBy = java.util.UUID.randomUUID()) + inputs.forEach { (name, price, imageUrl) -> + assertFailsWith { + UserInputValues.of(UserInput(name = name, price = price, imageUrl = imageUrl, currency = "KRW")) + } } } - @Test - fun `pending 팩토리는 PENDING 스냅샷을 만들고 isReady 는 false 다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - assertEquals(ItemStatus.PENDING, snapshot.status) - assertFalse(snapshot.isReady()) - } - - @Test - fun `isInProgress 는 PENDING 에서 true, READY·FAILED 에서 false 다`() { - // 수동 새로고침(5단계) 멱등 가드용 — 이미 진행 중이면 새 추출 버전을 만들지 않는다. - assertTrue(ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).isInProgress()) - assertFalse( - ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()) - .apply { markExtracted(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } - .isInProgress(), + private fun version( + name: String? = "나이키 에어포스", + price: Int? = 99_000, + imageUrl: String? = "https://img.example.com/a.png", + currency: String? = "KRW", + userInput: Boolean = false, + method: ExtractionMethod? = ExtractionMethod.STRUCTURED.takeUnless { userInput }, + ): ItemSnapshot = + ItemSnapshot( + itemId = 1L, + name = name, + imageUrl = imageUrl, + price = price, + currency = currency, + method = method, + userInput = userInput, + createdBy = UUID.randomUUID(), ) - assertFalse(ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).apply { markFailed() }.isInProgress()) - } - - @Test - fun `예전 큐가 남긴 PROCESSING 버전도 실패로 닫을 수 있다`() { - val snapshot = ItemSnapshot(itemId = 1L, status = ItemStatus.PROCESSING) - - snapshot.markFailed() - - assertEquals(ItemStatus.FAILED, snapshot.status) - } - - @Test - fun `끝난 버전은 다시 실패로 닫을 수 없다`() { - val snapshot = ItemSnapshot.pending(itemId = 1L, requestedBy = UUID.randomUUID()).apply { markFailed() } - - assertFailsWith { snapshot.markFailed() } - } } diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/ItemTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/ItemTest.kt deleted file mode 100644 index 0d0c6eb43..000000000 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.depromeet.piki.item.domain - -import com.depromeet.piki.product.domain.CanonicalLink -import com.depromeet.piki.product.domain.ProductLink -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull - -class ItemTest { - private fun canonical(raw: String): CanonicalLink = CanonicalLink.of(ProductLink.parse(raw)) - - @Test - fun `파싱 전 item 은 canonical 이 비어 있고 claimCanonical 로 확정된다`() { - val item = Item(link = ProductLink.parse("https://musinsa.onelink.me/PvkC/hxnauj24")) - assertNull(item.canonicalHash) - - val canonical = canonical("https://www.musinsa.com/products/6760200") - item.claimCanonical(canonical) - - assertEquals(canonical.url, item.canonicalUrl) - assertEquals(canonical.hash, item.canonicalHash) - } - - @Test - fun `같은 귀결점 재확정은 멱등이다 - 병합 경합에서 진 쪽의 재시도가 무해해야 한다`() { - val item = Item(link = ProductLink.parse("https://www.musinsa.com/products/6760200")) - val canonical = canonical("https://www.musinsa.com/products/6760200") - item.claimCanonical(canonical) - item.claimCanonical(canonical) - assertEquals(canonical.hash, item.canonicalHash) - } - - @Test - fun `다른 귀결점으로 재확정하면 코드 버그로 본다 - 정체성은 불변이다`() { - val item = Item(link = ProductLink.parse("https://www.musinsa.com/products/6760200")) - item.claimCanonical(canonical("https://www.musinsa.com/products/6760200")) - assertFailsWith { - item.claimCanonical(canonical("https://www.musinsa.com/products/6252844")) - } - } - - @Test - fun `link 와 sourceImageKey 를 동시에 가질 수 없다`() { - assertFailsWith { - Item(link = ProductLink.parse("https://www.musinsa.com/products/1"), sourceImageKey = "raw/abc.jpg") - } - } -} diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/ItemVersionsTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/ItemVersionsTest.kt index 213e3af40..fb892abea 100644 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemVersionsTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/domain/ItemVersionsTest.kt @@ -1,5 +1,7 @@ package com.depromeet.piki.item.domain +import com.depromeet.piki.support.withId +import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test @@ -8,94 +10,40 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue -// 카드 표시값 규칙(#1051)의 분기 망라. 입력은 한 상품의 버전들, 카드 주인, 카드가 기다리는 행뿐이라 프레임워크 없이 검증한다. -// 불변식: 남의 진행 중·남의 수기·남의 미완은 내 카드에 새어 들어오지 않는다. 내 수기값은 그보다 새로운 서버 READY 에만 진다. -// 새로고침은 어떤 플로우도 막지 않는다 — 진행 중은 그 행을 기다리는 카드에만 보인다. class ItemVersionsTest { private val me = UUID.randomUUID() private val other = UUID.randomUUID() @Test - fun `카드가 기다리는 행이 진행 중이면 완성 값이 있어도 진행 중을 보인다 - 내가 시작한 갱신`() { + fun `내 입력값은 최신 서버 READY 보다 새로우면 이긴다`() { val ready = machineReady(1, by = other) - val myPending = pending(2, by = me) + val myInput = userVersion(2, by = me) - assertEquals(myPending, display(listOf(ready, myPending), waitingOn = myPending)) + assertEquals(myInput, display(ready, myInput)) } @Test - fun `같은 사람의 다른 카드는 그 갱신을 기다리지 않으므로 흔들리지 않는다 - 위시 새로고침 중인 사람의 토너먼트 카드`() { - // 토너먼트 카드는 출전 pin(READY)을 기다린다. 위시 쪽 새로고침이 만든 진행 중 행은 만든 사람이 나라도 이 카드와 무관하다. - val pinned = machineReady(1, by = me) - val myWishRefresh = pending(2, by = me) - - assertEquals(pinned, display(listOf(pinned, myWishRefresh), waitingOn = pinned)) - } - - @Test - fun `남이 시킨 진행 중 버전은 내 카드에 보이지 않는다 - 내 카드는 완성 값을 그대로 본다`() { - val ready = machineReady(1, by = me) - val othersPending = pending(2, by = other) - - assertEquals(ready, display(listOf(ready, othersPending), waitingOn = ready)) - } - - @Test - fun `합류한 진행 중은 만든 사람이 남이라도 보인다 - 새로고침 합류`() { - // B 가 새로고침을 누르는데 A 의 새로고침이 진행 중이면 B 의 카드가 그 행을 기다리게 된다(#826). - val ready = machineReady(1, by = me) - val othersPending = pending(2, by = other) - - assertEquals(othersPending, display(listOf(ready, othersPending), waitingOn = othersPending)) - } - - @Test - fun `내 수기값은 최신 서버 READY 보다 새로우면 이긴다`() { - val ready = machineReady(1, by = other) - val myManual = manual(2, by = me) - - assertEquals(myManual, display(listOf(ready, myManual), waitingOn = myManual)) - } - - @Test - fun `내 수기값보다 새로운 서버 READY 가 생기면 서버값으로 돌아간다 - 누가 새로고침했든`() { - val myManual = manual(1, by = me) + fun `내 입력값보다 새로운 서버 READY 가 생기면 누가 새로고침했든 서버값으로 돌아간다`() { + val myInput = userVersion(1, by = me) val newerReady = machineReady(2, by = other) - assertEquals(newerReady, display(listOf(myManual, newerReady), waitingOn = myManual)) - } - - @Test - fun `남의 수기값은 내 카드에 보이지 않는다`() { - val ready = machineReady(1, by = other) - val othersManual = manual(2, by = other) - - assertEquals(ready, display(listOf(ready, othersManual), waitingOn = ready)) + assertEquals(newerReady, display(myInput, newerReady)) } @Test - fun `새로고침이 실패해도 카드는 새로고침 전과 같다 - FAILED 는 후보가 아니다`() { + fun `남의 입력값은 내 카드에 보이지 않는다`() { val ready = machineReady(1, by = other) - val myManual = manual(2, by = me) - val myFailed = failed(3, by = me) + val othersInput = userVersion(2, by = other) - assertEquals(myManual, display(listOf(ready, myManual, myFailed), waitingOn = myFailed)) + assertEquals(ready, display(ready, othersInput)) } @Test - fun `수기값만 있는 상품의 새로고침이 실패해도 내 수기값을 본다 - 빈 카드가 되지 않는다`() { - val myManual = manual(1, by = me) - val myFailed = failed(2, by = me) - - assertEquals(myManual, display(listOf(myManual, myFailed), waitingOn = myFailed)) - } - - @Test - fun `내 새로고침이 INCOMPLETE 로 끝나면 옛 READY 대신 일부만 빈 상태를 보인다 - 수기를 기다린다`() { + fun `내 새로고침이 INCOMPLETE 로 끝나면 옛 READY 대신 일부만 빈 값을 보인다`() { val ready = machineReady(1, by = me) val myIncomplete = incomplete(2, by = me) - assertEquals(myIncomplete, display(listOf(ready, myIncomplete), waitingOn = myIncomplete)) + assertEquals(myIncomplete, display(ready, myIncomplete)) } @Test @@ -103,7 +51,7 @@ class ItemVersionsTest { val ready = machineReady(1, by = me) val othersIncomplete = incomplete(2, by = other) - assertEquals(ready, display(listOf(ready, othersIncomplete), waitingOn = ready)) + assertEquals(ready, display(ready, othersIncomplete)) } @Test @@ -111,122 +59,87 @@ class ItemVersionsTest { val myIncomplete = incomplete(1, by = me) val newerReady = machineReady(2, by = other) - assertEquals(newerReady, display(listOf(myIncomplete, newerReady), waitingOn = myIncomplete)) - } - - @Test - fun `값이 하나도 없으면 기다리던 행의 결과를 본다 - 등록 합류가 INCOMPLETE 로 끝난 경우`() { - // B 가 A 의 첫 파싱에 합류했고 그 파싱이 INCOMPLETE 로 끝났다. B 도 값이 없으니 그 결과가 B 의 등록 결과다. - val joinedIncomplete = incomplete(1, by = other) - - assertEquals(joinedIncomplete, display(listOf(joinedIncomplete), waitingOn = joinedIncomplete)) + assertEquals(newerReady, display(myIncomplete, newerReady)) } @Test - fun `남이 뒤에 남긴 실패는 내가 기다리던 결과를 지우지 않는다`() { - val joinedIncomplete = incomplete(1, by = other) - val othersLaterFailed = failed(2, by = other) + fun `내 값도 기계 READY 도 없으면 남의 입력이 아닌 최신 버전을 본다`() { + val othersIncomplete = incomplete(1, by = other) + val othersInput = userVersion(2, by = other) - val versions = listOf(joinedIncomplete, othersLaterFailed) - - assertEquals(joinedIncomplete, display(versions, waitingOn = joinedIncomplete)) - } - - @Test - fun `값이 하나도 없어도 남의 수기값은 보지 않는다 - 내 실패를 본다`() { - val myFailed = failed(1, by = me) - val othersManual = manual(2, by = other) - - assertEquals(myFailed, display(listOf(myFailed, othersManual), waitingOn = myFailed)) + assertEquals(othersIncomplete, display(othersIncomplete, othersInput)) } @Test - fun `출처를 모르는 도입 전 READY 는 만든 사람이 채워져 있어도 공유 값이다`() { - // 추정 백필이 옛 행에 만든 사람을 채우므로, 공유 여부는 만든 사람이 아니라 출처(수기 아님)로만 가른다. - val legacy = version(1, ItemStatus.READY, source = null, by = other) + fun `사용자 입력 버전뿐인 상품은 남의 입력이라도 보여 빈 카드가 되지 않는다`() { + val othersInput = userVersion(1, by = other) - assertEquals(legacy, display(listOf(legacy), waitingOn = legacy)) + assertEquals(othersInput, display(othersInput)) } - // ── 해소 통지 판정(recovers): 이 버전으로 카드가 채워지는가 ────────────────────────── - @Test - fun `기다리던 행이 FAILED 였고 남의 서버 READY 가 생기면 채워진다`() { - val myFailed = failed(1, by = me) + fun `내 INCOMPLETE 를 보던 카드는 남의 서버 READY 가 생기면 채워진다`() { + val myIncomplete = incomplete(1, by = me) val othersReady = machineReady(2, by = other) - assertTrue(ItemVersions.of(listOf(myFailed, othersReady)).recovers(me, waitingOn = 1, snapshotId = 2)) + assertTrue(versions(myIncomplete, othersReady).recovers(me, snapshotId = 2)) } @Test - fun `기다리던 행이 INCOMPLETE 여도 남의 서버 READY 가 생기면 채워진다`() { - val myIncomplete = incomplete(1, by = me) + fun `내 값 없이 남의 INCOMPLETE 를 보던 카드도 서버 READY 가 생기면 채워진다`() { + val othersIncomplete = incomplete(1, by = other) val othersReady = machineReady(2, by = other) - assertTrue(ItemVersions.of(listOf(myIncomplete, othersReady)).recovers(me, waitingOn = 1, snapshotId = 2)) + assertTrue(versions(othersIncomplete, othersReady).recovers(me, snapshotId = 2)) } @Test - fun `그 버전을 기다리는 카드는 채워진 것이 아니라 완료다 - 두 알림은 배타적`() { + fun `첫 버전은 해소가 아니라 그 버전을 요청한 카드의 완료다`() { val ready = machineReady(1, by = me) - assertFalse(ItemVersions.of(listOf(ready)).recovers(me, waitingOn = 1, snapshotId = 1)) + assertFalse(versions(ready).recovers(me, snapshotId = 1)) } @Test - fun `기다리던 행이 FAILED 라도 내 수기값이 이미 카드에 떠 있었으면 채워진 것이 아니다`() { - // 옛 규칙(기다리는 행의 상태만 봄)은 이 카드에 해소 통지를 보냈다. 표시값 규칙으로는 카드가 비어 있던 적이 없다. - val myManual = manual(1, by = me) - val myFailed = failed(2, by = me) - val othersReady = machineReady(3, by = other) - - val versions = ItemVersions.of(listOf(myManual, myFailed, othersReady)) - - assertFalse(versions.recovers(me, waitingOn = 2, snapshotId = 3)) - } - - @Test - fun `옛 READY 를 보던 카드는 남의 새 READY 로 바뀌어도 채워진 것이 아니다 - 이미 값을 보고 있었다`() { + fun `이미 READY 를 보던 카드는 남의 새 READY 로 바뀌어도 채워진 것이 아니다`() { val oldReady = machineReady(1, by = me) val newReady = machineReady(2, by = other) - assertFalse(ItemVersions.of(listOf(oldReady, newReady)).recovers(me, waitingOn = 1, snapshotId = 2)) + assertFalse(versions(oldReady, newReady).recovers(me, snapshotId = 2)) } @Test - fun `다른 파싱을 기다리는 진행 중 카드는 채워진 것이 아니다 - 자기 결과를 따로 받는다`() { - val myPending = pending(1, by = me) + fun `내 입력값이 떠 있던 카드는 서버 READY 가 생겨도 채워진 것이 아니다`() { + val myInput = userVersion(1, by = me) val othersReady = machineReady(2, by = other) - assertFalse(ItemVersions.of(listOf(myPending, othersReady)).recovers(me, waitingOn = 1, snapshotId = 2)) + assertFalse(versions(myInput, othersReady).recovers(me, snapshotId = 2)) } @Test - fun `성공 버전이 둘 쌓여도 채워진 순간의 버전 한 번만 채워진 것으로 본다 - 뒤 버전이 앞 버전을 가리지 않는다`() { - // 비동기 디스패치·연속 새로고침으로 P1·P2 가 다 있는 채 P1 이벤트를 처리해도 P1 이 채운 것이고, P2 는 이미 값을 보던 카드다. - val myFailed = failed(1, by = me) - val p1 = machineReady(2, by = other) - val p2 = machineReady(3, by = other) - val versions = ItemVersions.of(listOf(myFailed, p1, p2)) + fun `남의 INCOMPLETE 는 내 INCOMPLETE 카드를 채우지 않는다`() { + val myIncomplete = incomplete(1, by = me) + val othersIncomplete = incomplete(2, by = other) - assertTrue(versions.recovers(me, waitingOn = 1, snapshotId = 2)) - assertFalse(versions.recovers(me, waitingOn = 1, snapshotId = 3)) + assertFalse(versions(myIncomplete, othersIncomplete).recovers(me, snapshotId = 2)) } + // 비동기 디스패치로 P1 처리 시점에 P2 가 이미 있어도 P1 이 채운 것이고 P2 는 이미 값을 보던 카드다 @Test - fun `그 버전 뒤에 기다리기 시작한 카드는 채워진 것이 아니다`() { - val othersReady = machineReady(1, by = other) - val myLaterFailed = failed(2, by = me) + fun `성공 버전이 연달아 쌓여도 채운 버전 한 번만 해소로 본다`() { + val myIncomplete = incomplete(1, by = me) + val p1 = machineReady(2, by = other) + val p2 = machineReady(3, by = other) + val versions = versions(myIncomplete, p1, p2) - assertFalse(ItemVersions.of(listOf(othersReady, myLaterFailed)).recovers(me, waitingOn = 2, snapshotId = 1)) + assertTrue(versions.recovers(me, snapshotId = 2)) + assertFalse(versions.recovers(me, snapshotId = 3)) } @Test fun `판정 대상 버전이 이 상품에 없으면 코드 버그다`() { - val ready = machineReady(1, by = me) - assertFailsWith { - ItemVersions.of(listOf(ready)).recovers(me, waitingOn = 1, snapshotId = 99) + versions(machineReady(1, by = me)).recovers(me, snapshotId = 99) } } @@ -235,61 +148,56 @@ class ItemVersionsTest { assertFailsWith { ItemVersions.of(emptyList()) } } - private fun display( - versions: List, - waitingOn: ItemSnapshot, - ): ItemSnapshot = ItemVersions.of(versions).displayFor(viewer = me, waitingOn = waitingOn.getId()) + @Test + fun `다른 상품의 버전은 한데 묶을 수 없다`() { + val a = machineReady(1, by = me) + val b = version(2, ready = true, userInput = false, by = me, itemId = 2L) - // ── fixture ───────────────────────────────────────────────────────────── - // 영속화 없이 id 를 흉내 내야 "최신" 판정(id 오름차순)을 검증할 수 있어 reflection 으로 id 를 박는다. + assertFailsWith { ItemVersions.of(listOf(a, b)) } + } - private fun machineReady( - id: Long, - by: UUID?, - ): ItemSnapshot = version(id, ItemStatus.READY, ItemSnapshotSource.SERVER, by) + private fun versions(vararg snapshots: ItemSnapshot): ItemVersions = ItemVersions.of(snapshots.toList()) - private fun manual( - id: Long, - by: UUID, - ): ItemSnapshot = version(id, ItemStatus.READY, ItemSnapshotSource.MANUAL, by) + private fun display(vararg snapshots: ItemSnapshot): ItemSnapshot = versions(*snapshots).displayFor(me) - private fun incomplete( + private fun machineReady( id: Long, by: UUID, - ): ItemSnapshot = version(id, ItemStatus.INCOMPLETE, ItemSnapshotSource.SERVER, by) + createdAt: LocalDateTime = LocalDateTime.now(), + ): ItemSnapshot = version(id, ready = true, userInput = false, by = by, createdAt = createdAt) - private fun failed( + private fun userVersion( id: Long, by: UUID, - ): ItemSnapshot = version(id, ItemStatus.FAILED, ItemSnapshotSource.SERVER, by) + createdAt: LocalDateTime = LocalDateTime.now(), + ): ItemSnapshot = version(id, ready = true, userInput = true, by = by, createdAt = createdAt) - private fun pending( + private fun incomplete( id: Long, by: UUID, - ): ItemSnapshot = version(id, ItemStatus.PENDING, null, by) + ): ItemSnapshot = version(id, ready = false, userInput = false, by = by) + // "최신" 판정이 id 순서라 영속화 없이 id 와 생성 시각을 reflection 으로 박는다 private fun version( id: Long, - status: ItemStatus, - source: ItemSnapshotSource?, - by: UUID?, + ready: Boolean, + userInput: Boolean, + by: UUID, + createdAt: LocalDateTime = LocalDateTime.now(), + itemId: Long = 1L, ): ItemSnapshot { - val hasValue = status == ItemStatus.READY || status == ItemStatus.INCOMPLETE val snapshot = ItemSnapshot( - itemId = 1L, - name = if (hasValue) "상품 $id" else null, - price = if (status == ItemStatus.READY) 10_000 else null, - imageUrl = if (status == ItemStatus.READY) "https://cdn.example.com/$id.jpg" else null, - status = status, - extractedAt = if (hasValue) LocalDateTime.now() else null, - source = source, + itemId = itemId, + name = "상품 $id", + imageUrl = "https://cdn.example.com/$id.jpg", + price = if (ready) 10_000 else null, + currency = "KRW", + method = null, + userInput = userInput, createdBy = by, ) - ItemSnapshot::class.java.superclass - .getDeclaredField("id") - .apply { isAccessible = true } - .set(snapshot, id) - return snapshot + ReflectionTestUtils.setField(snapshot, "createdAt", createdAt) + return snapshot.withId(id) } } diff --git a/src/test/kotlin/com/depromeet/piki/item/domain/NormalizedInputTest.kt b/src/test/kotlin/com/depromeet/piki/item/domain/NormalizedInputTest.kt new file mode 100644 index 000000000..ee8a20d84 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/item/domain/NormalizedInputTest.kt @@ -0,0 +1,188 @@ +package com.depromeet.piki.item.domain + +import com.depromeet.piki.product.domain.ProductLink +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +// 목록에 이름을 올리려면 그 이름이 붙은 실제 URL 과, 지워도 같은 상품이 열린다는 확인이 먼저다 +class NormalizedInputTest { + private fun normalized(raw: String): NormalizedInput = requireNotNull(NormalizedInput.of(ParseInput.Link(ProductLink.parse(raw)))) + + // --- 실제로 관측한 URL --- + + @Test + fun `무신사 onelink 귀결점의 공유자 추적 쿼리가 전부 떨어져 맨 URL 과 같은 정체성이 된다`() { + val dirty = + normalized( + "https://www.musinsa.com/products/6760200?source_caller=sdk&is_retargeting=true" + + "&shortlink=hxnauj24&af_channel=mobile_share&af_referrer_uid=1588832660726-6009526" + + "&pid=af_app_invites&af_referrer_customer_id=jsy0714", + ) + val bare = normalized("https://www.musinsa.com/products/6760200") + assertEquals(bare, dirty) + } + + // 2026-10-04 단축링크를 직접 따라가 받은 귀결점 + @Test + fun `무신사 단축링크가 실제로 붙여 온 파라미터 12개가 전부 떨어진다`() { + val redirected = + normalized( + "https://www.musinsa.com/products/6760200?af_force_deeplink=true&af_referrer_customer_id=jsy0714" + + "&utm_source=kakao&onelink_id=PvkC&af_dp=musinsaad%3A%2F%2Fopen&af_channel=mobile_share&shortlink=hxnauj24" + + "&af_referrer_uid=1588832660726-6009526&af_siteid=1003139529&pid=af_app_invites&source_caller=sdk&is_retargeting=true", + ) + assertEquals(normalized("https://www.musinsa.com/products/6760200"), redirected) + } + + @Test + fun `29cm 의 reward_key 가 떨어진다 - 공유자마다 다른 토큰이 정체성을 가르지 못한다`() { + val fromShareA = normalized("https://www.29cm.co.kr/products/3915971?reward_key=RK_AAAA&utm_source=29cm_pdp_share") + val fromShareB = normalized("https://www.29cm.co.kr/products/3915971?reward_key=RK_BBBB") + assertEquals(fromShareA, fromShareB) + } + + @Test + fun `29cm 카테고리 탐색 쿼리가 떨어져 브라우저 복사 URL 과 같은 정체성이 된다`() { + val browsed = + normalized( + "https://www.29cm.co.kr/products/3863006?categoryLargeCode=269100100&categoryMediumCode=269101100&categorySmallCode=", + ) + assertEquals(normalized("https://www.29cm.co.kr/products/3863006"), browsed) + } + + @Test + fun `지그재그 단축 귀결점 경로가 웹 경로로 접혀 클릭마다 바뀌는 쿼리와 무관하게 같은 정체성이 된다`() { + // event_uuid 는 클릭마다, referrer_timestamp 는 밀리초마다 값이 바뀐다 + val fromShortlink = + normalized( + "https://zigzag.kr/p/164171173?event_uuid=390edeb9-cc26-414f&referrer_timestamp=1785321370803" + + "&utm_source=sharelink&channel=sharelink&deeplink_url=zigzag%3A%2F%2Fopen", + ) + val fromWeb = normalized("https://zigzag.kr/catalog/products/164171173") + assertEquals(fromWeb, fromShortlink) + } + + @Test + fun `onelink 단축의 utm 이 떨어진다`() { + val withUtm = normalized("https://musinsa.onelink.me/PvkC/hxnauj24?utm_source=kakao") + assertEquals(normalized("https://musinsa.onelink.me/PvkC/hxnauj24"), withUtm) + } + + // onelink 의 긴 형식은 경로가 템플릿뿐이고 목적지를 쿼리에 싣는다. 통째로 지우면 서로 다른 상품이 한 URL 로 접힌다 + @Test + fun `onelink 의 모르는 쿼리는 남는다`() { + val a = normalized("https://musinsa.onelink.me/PvkC?deep_link_value=6760200") + val b = normalized("https://musinsa.onelink.me/PvkC?deep_link_value=6252844") + assertNotEquals(a, b) + } + + @ParameterizedTest + @CsvSource( + "https://kream.co.kr/products/984835?foo=bar, https://kream.co.kr/products/984835", + "https://m.a-bly.com/goods/70768193?abc=1, https://m.a-bly.com/goods/70768193", + "https://s.zigzag.kr/pby6JjF2dc?x=1, https://s.zigzag.kr/pby6JjF2dc", + ) + fun `관측한 적 없는 파라미터는 어느 몰에서든 남는다`( + withUnknown: String, + bare: String, + ) { + assertNotEquals(normalized(bare), normalized(withUnknown)) + } + + // --- 규칙 --- + + @Test + fun `추적 파라미터 이름은 몰과 무관하게 떨어진다`() { + val shared = + normalized( + "https://unknown-mall.example.com/goods/77?source_caller=sdk&is_retargeting=true&shortlink=abc" + + "&event_uuid=390edeb9&referrer_timestamp=1785321370803&reward_key=RK_AAAA&channel=sharelink", + ) + assertEquals(normalized("https://unknown-mall.example.com/goods/77"), shared) + } + + // pid 는 AppsFlyer 에서는 유입 경로이고 다른 몰에서는 상품 번호일 수 있다 + @Test + fun `pid 는 AppsFlyer 파라미터가 같이 있을 때만 떨어진다`() { + assertEquals( + normalized("https://www.musinsa.com/products/6760200"), + normalized("https://www.musinsa.com/products/6760200?pid=af_app_invites&af_channel=mobile_share"), + ) + assertNotEquals( + normalized("https://unknown-mall.example.com/goods?pid=123"), + normalized("https://unknown-mall.example.com/goods?pid=456"), + ) + assertEquals( + "https://unknown-mall.example.com/goods?pid=123", + normalized("https://unknown-mall.example.com/goods?pid=123&utm_source=instagram").value, + ) + } + + @Test + fun `cafe24 계열은 쿼리가 곧 상품 번호라 보존된다 - 전부 제거하면 그 몰의 모든 상품이 하나로 합쳐지는 사고`() { + val a = normalized("https://m.reetkeem.com/product/detail.html?product_no=9275&cate_no=65") + val b = normalized("https://m.reetkeem.com/product/detail.html?product_no=9090&cate_no=65") + assertNotEquals(a, b) + } + + @Test + fun `미확인 몰도 추적 파라미터는 이름으로 떨어진다 - 광고에서 복사한 URL 의 fbclid 와 utm`() { + val fromAd = + normalized( + "https://cosymosy.co.kr/shop_view/?idx=4360&utm_source=instagram&utm_medium=paid&fbclid=PAdGRleASVDk1", + ) + assertEquals(normalized("https://cosymosy.co.kr/shop_view/?idx=4360"), fromAd) + } + + @Test + fun `남는 쿼리는 이름순 정렬되어 파라미터 순서만 다른 URL 이 같은 정체성이 된다`() { + val a = normalized("https://m.reetkeem.com/product/detail.html?product_no=9275&cate_no=65") + val b = normalized("https://m.reetkeem.com/product/detail.html?cate_no=65&product_no=9275") + assertEquals(a, b) + } + + // --- 전역 규칙 --- + + @Test + fun `fragment 는 떨어진다 - HTTP 요청에 실리지 않아 서버 렌더 몰에서 상품을 바꿀 수 없다`() { + val withFragment = normalized("https://m.reetkeem.com/product/loose-unbal-tee/9277/category/65/display/1/#none") + val bare = normalized("https://m.reetkeem.com/product/loose-unbal-tee/9277/category/65/display/1/") + assertEquals(bare, withFragment) + } + + @Test + fun `trailing slash 유무로 정체성이 갈리지 않는다`() { + assertEquals( + normalized("https://shopsoez.com/product/boat-neck/72"), + normalized("https://shopsoez.com/product/boat-neck/72/"), + ) + } + + @Test + fun `host 대소문자와 trailing dot 이 정규화된다`() { + assertEquals( + normalized("https://www.musinsa.com/products/6760200"), + normalized("https://WWW.MUSINSA.COM./products/6760200"), + ) + } + + @Test + fun `다른 상품은 당연히 다른 정체성이다`() { + assertNotEquals( + normalized("https://www.musinsa.com/products/6760200"), + normalized("https://www.musinsa.com/products/6252844"), + ) + } + + @Test + fun `저장 상한을 넘는 URL 은 정규화 값이 없다 - 절단은 거짓 정체성이라 하지 않는다`() { + val longPath = "/product/" + "%EC%A0%80".repeat(300) + assertNull(NormalizedInput.of(ParseInput.Link(ProductLink.parse("https://unknown-mall.example.com$longPath")))) + assertNotNull(NormalizedInput.of(ParseInput.Link(ProductLink.parse("https://unknown-mall.example.com/p/1")))) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemCardIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemCardIntegrationTest.kt new file mode 100644 index 000000000..a4cc98d9b --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemCardIntegrationTest.kt @@ -0,0 +1,143 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.product.service.ProductSnapshot +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.completeLatest +import com.depromeet.piki.support.failLatest +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.user.domain.User +import com.depromeet.piki.user.repository.UserJpaRepository +import com.depromeet.piki.wishlist.domain.Wish +import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.service.dto.RefreshWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import jakarta.persistence.EntityManager +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.transaction.annotation.Transactional +import java.sql.Timestamp +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Transactional +class ItemCardIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var itemCardService: ItemCardService + + @Autowired private lateinit var wishPersistenceService: WishPersistenceService + + @Autowired private lateinit var itemParsingService: ItemParsingService + + @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository + + @Autowired private lateinit var entityManager: EntityManager + + @Autowired private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired private lateinit var userJpaRepository: UserJpaRepository + + @Test + fun `아이템이 없고 최근 요청이 진행 중이면 PARSING 이고 기다리는 URL 을 싣는다`() { + val url = "https://www.musinsa.com/products/3000001" + val wish = wishPersistenceService.persist(RegisterWishFromUrlCommand(newMember(), ProductLink.parse(url))).wish + + val card = itemCardService.cardOf(wish.cardQuery()) + + assertEquals(ItemStatus.PARSING, card.status) + assertNull(card.itemId) + assertNull(card.display) + assertFalse(card.refreshing) + assertEquals(ProductLink.parse(url), card.sourceLink) + } + + @Test + fun `아이템이 없고 최근 요청이 실패했으면 FAILED 다`() { + val wish = wishPersistenceService.persist(RegisterWishFromUrlCommand(newMember(), ProductLink.parse("https://www.musinsa.com/products/3000002"))).wish + itemParsingService.failLatest(entityManager, wish.slot) + + val card = itemCardService.cardOf(wish.cardQuery()) + + assertEquals(ItemStatus.FAILED, card.status) + assertNull(card.itemId) + } + + @Test + fun `아이템이 있으면 표시 버전의 값 개수로 READY 와 INCOMPLETE 가 갈린다`() { + val ready = parsedWish("https://www.musinsa.com/products/3000003", ProductSnapshot(name = "상품", price = 1_000, imageUrl = IMAGE)) + val incomplete = parsedWish("https://www.musinsa.com/products/3000004", ProductSnapshot(name = "상품", price = 1_000)) + + val cards = itemCardService.cardsOf(listOf(ready.cardQuery(), incomplete.cardQuery())) + + val readyCard = cards.getValue(ready.cardQuery()) + assertEquals(ItemStatus.READY, readyCard.status) + assertEquals(ready.itemId, readyCard.itemId) + assertEquals("상품", readyCard.display?.name) + assertEquals(ItemStatus.INCOMPLETE, cards.getValue(incomplete.cardQuery()).status) + } + + @Test + fun `아이템이 있는 카드의 새로고침이 진행 중이면 상태는 그대로 두고 refreshing 이다`() { + val owner = newMember() + val wish = parsedWish("https://www.musinsa.com/products/3000005", ProductSnapshot(name = "상품", price = 1_000, imageUrl = IMAGE), owner) + wishPersistenceService.refresh(RefreshWishItemCommand(owner, wish.getId())) + + val card = itemCardService.cardOf(wish.cardQuery()) + + assertEquals(ItemStatus.READY, card.status) + assertTrue(card.refreshing) + } + + @Test + fun `기준 시각이 있으면 그 뒤에 생긴 버전을 빼고 새로고침 진행도 보이지 않는다`() { + val owner = newMember() + val wish = parsedWish("https://www.musinsa.com/products/3000006", ProductSnapshot(name = "시작 전 값", price = 1_000, imageUrl = IMAGE), owner) + val itemId = assertNotNull(wish.itemId) + val later = itemSnapshotRepository.save(itemVersion(itemId = itemId, name = "시작 뒤 값", createdBy = owner)) + entityManager.flush() + jdbcTemplate.update("UPDATE item_snapshots SET created_at = ? WHERE id = ?", Timestamp.valueOf(LocalDateTime.now().plusHours(1)), later.getId()) + entityManager.clear() + wishPersistenceService.refresh(RefreshWishItemCommand(owner, wish.getId())) + + val card = itemCardService.cardOf(wish.cardQuery().copy(asOf = LocalDateTime.now())) + + assertEquals("시작 전 값", card.display?.name) + assertFalse(card.refreshing) + assertEquals("시작 뒤 값", itemCardService.cardOf(wish.cardQuery()).display?.name) + } + + private fun parsedWish( + url: String, + extracted: ProductSnapshot, + owner: UUID = newMember(), + ): Wish { + val wish = wishPersistenceService.persist(RegisterWishFromUrlCommand(owner, ProductLink.parse(url))).wish + itemParsingService.completeLatest(entityManager, wish.slot, extracted) + return wish + } + + private fun newMember(): UUID { + val userId = UUID.randomUUID() + userJpaRepository.save( + User( + id = userId, + nickname = "곰${userId.toString().take(5)}", + profileImage = "https://cdn.example.com/p.jpg", + identityType = IdentityType.MEMBER, + ), + ) + return userId + } + + companion object { + private const val IMAGE = "https://img.example.com/a.png" + } +} diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemCardTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemCardTest.kt new file mode 100644 index 000000000..2f5ffbe63 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemCardTest.kt @@ -0,0 +1,146 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.ItemErrorCode +import com.depromeet.piki.item.domain.ItemException +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.UserInput +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.linkItem +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class ItemCardTest { + @Test + fun `파싱 중인 요청만 있는 카드는 PARSING 이고 값을 다 보내도 사용자 입력을 거부한다`() { + val card = ItemCard.Unresolved(request()) + + assertEquals(ItemStatus.PARSING, card.status) + val e = assertFailsWith { card.userInputValues(fullInput()) } + assertEquals(ItemErrorCode.PARSING_IN_PROGRESS, e.errorCode) + } + + @Test + fun `바꿀 값이 하나도 없는 입력은 카드 상태와 무관하게 거부한다`() { + val empties = + listOf( + UserInput(name = null, price = null, imageUrl = null, currency = null), + UserInput(name = " ", price = null, imageUrl = null, currency = ""), + ) + val cards = listOf(ItemCard.Resolved(item = linkItem(ITEM_URL), display = version(), refreshing = false), ItemCard.Unresolved(failedRequest())) + + for (card in cards) { + for (empty in empties) { + val e = assertFailsWith { card.userInputValues(empty) } + assertEquals(ItemErrorCode.EMPTY_USER_INPUT, e.errorCode, "${card.status} 카드, $empty") + } + } + } + + @Test + fun `실패한 요청만 있는 카드는 보낸 값을 그대로 쓴다`() { + val card = ItemCard.Unresolved(failedRequest()) + + assertEquals(ItemStatus.FAILED, card.status) + val values = card.userInputValues(fullInput()) + assertEquals("직접 입력", values.name) + assertEquals(5_000, values.price) + assertEquals("https://img.example.com/m.png", values.imageUrl) + } + + @Test + fun `실패한 요청만 있는 카드는 보낸 값에 이미지가 없으면 거부한다`() { + val card = ItemCard.Unresolved(failedRequest()) + + val e = assertFailsWith { card.userInputValues(UserInput(name = "직접 입력", price = 5_000, imageUrl = null, currency = null)) } + assertEquals(ItemErrorCode.IMAGE_REQUIRED_FOR_READY, e.errorCode) + } + + @Test + fun `값이 다 있는 카드는 보내지 않은 필드를 지금 보이는 값으로 채운다`() { + val card = ItemCard.Resolved(item = linkItem(ITEM_URL), display = version(), refreshing = false) + + val values = card.userInputValues(UserInput(name = null, price = 79_000, imageUrl = null, currency = null)) + + assertEquals("나이키 에어포스", values.name) + assertEquals(79_000, values.price) + assertEquals("https://img.example.com/a.png", values.imageUrl) + assertEquals("KRW", values.currency) + } + + @Test + fun `미완성 카드는 비어 있는 필드를 보내지 않으면 거부한다`() { + val card = ItemCard.Resolved(item = linkItem(ITEM_URL), display = version(price = null), refreshing = false) + + assertEquals(ItemStatus.INCOMPLETE, card.status) + val e = assertFailsWith { card.userInputValues(UserInput(name = "이름만", price = null, imageUrl = null, currency = null)) } + assertEquals(ItemErrorCode.PRICE_REQUIRED_FOR_READY, e.errorCode) + } + + @Test + fun `미완성 카드는 비어 있는 필드만 보내도 나머지를 지금 값으로 채운다`() { + val card = ItemCard.Resolved(item = linkItem(ITEM_URL), display = version(price = null), refreshing = false) + + assertEquals(10_000, card.userInputValues(UserInput(name = null, price = 10_000, imageUrl = null, currency = null)).price) + } + + @Test + fun `새로고침 중인 카드는 값이 있어도 사용자 입력을 거부한다`() { + val card = ItemCard.Resolved(item = linkItem(ITEM_URL), display = version(), refreshing = true) + + assertEquals(ItemStatus.READY, card.status) + val e = assertFailsWith { card.userInputValues(UserInput(name = "바꾼 이름", price = null, imageUrl = null, currency = null)) } + assertEquals(ItemErrorCode.PARSING_IN_PROGRESS, e.errorCode) + } + + @Test + fun `출처 링크는 상품이 정해지기 전엔 요청한 링크이고 정해진 뒤엔 상품 주소다`() { + val requested = ProductLink.parse("https://short.example.net/abc") + + assertEquals(requested, ItemCard.Unresolved(request(ParseInput.Link(requested))).sourceLink) + assertEquals(ProductLink.parse(ITEM_URL), ItemCard.Resolved(item = linkItem(ITEM_URL), display = version(), refreshing = false).sourceLink) + } + + @Test + fun `이미지로 등록한 카드는 상품이 정해지기 전후 모두 출처 링크가 없다`() { + assertNull(ItemCard.Unresolved(request(ParseInput.Image(ProductImageKey.newRaw("png")))).sourceLink) + assertNull(ItemCard.Resolved(item = imageItem(), display = version(), refreshing = false).sourceLink) + } + + private fun request(input: ParseInput = ParseInput.Link(ProductLink.parse("https://shop.example.com/products/1"))): ItemParseOutbox = + ItemParseOutbox.of(ItemSlot.wish(1L), ParsePurpose.REGISTER, input) + + private fun failedRequest(): ItemParseOutbox = + request().also { + it.claim() + it.fail() + } + + private fun fullInput(): UserInput = UserInput(name = "직접 입력", price = 5_000, imageUrl = "https://img.example.com/m.png", currency = "KRW") + + private fun version(price: Int? = 99_000): ItemSnapshot = + ItemSnapshot( + itemId = 1L, + name = "나이키 에어포스", + imageUrl = "https://img.example.com/a.png", + price = price, + currency = "KRW", + method = ExtractionMethod.STRUCTURED, + userInput = false, + createdBy = UUID.randomUUID(), + ) + + companion object { + private const val ITEM_URL = "https://shop.example.com/products/1" + } +} diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt deleted file mode 100644 index bf708c91b..000000000 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.depromeet.piki.item.service - -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.repository.ItemJpaRepository -import com.depromeet.piki.item.repository.ItemLinkJpaRepository -import com.depromeet.piki.item.repository.ItemLinkRepository -import com.depromeet.piki.item.repository.ItemRepository -import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.product.domain.CanonicalLink -import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.wishlist.domain.Wish -import com.depromeet.piki.wishlist.repository.WishJpaRepository -import org.springframework.beans.factory.annotation.Autowired -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -// 상품 정체성 기록(#825 관측 단계)의 DB 계약 검증 — 별칭 INSERT IGNORE·조건부 canonical claim·충돌 관측은 -// 전부 DB 문장(unique·조건부 UPDATE)의 실제 동작이라 단위로 내릴 수 없다. -// -// 클래스 @Transactional 을 쓰지 않는다 — 운영에서 recordParsingIdentity 는 호출마다 자기 트랜잭션(REQUIRED)으로 -// 돌고, 조건부 UPDATE 는 native 라 영속성 컨텍스트를 우회한다. 테스트가 트랜잭션을 공유하면 recorder 내부의 -// 재조회(findById)가 stale 1차 캐시를 읽어 운영과 다른 분기(already_same 이 drift 로 오판)를 탄다. -// 대신 각 테스트가 자기 행을 메서드 끝에서 명시 정리하고, 테스트 간 충돌이 없게 URL 을 테스트별로 달리 쓴다 -// (동시성 통합 테스트와 같은 격리 방식). -class ItemIdentityRecordingIntegrationTest : IntegrationTestSupport() { - @Autowired - private lateinit var itemIdentityRecorder: ItemIdentityRecorder - - @Autowired - private lateinit var itemRepository: ItemRepository - - @Autowired - private lateinit var itemLinkRepository: ItemLinkRepository - - @Autowired - private lateinit var itemJpaRepository: ItemJpaRepository - - @Autowired - private lateinit var itemLinkJpaRepository: ItemLinkJpaRepository - - @Autowired - private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository - - @Autowired - private lateinit var wishJpaRepository: WishJpaRepository - - private fun newItem(url: String): Item = itemRepository.save(Item(link = ProductLink.parse(url))) - - private fun cleanup(itemIds: List) { - itemIds.forEach { id -> - itemLinkJpaRepository.deleteAll(itemLinkJpaRepository.findByItemIdAndDeletedAtIsNull(id)) - itemJpaRepository.deleteById(id) - } - } - - @Test - fun `등록 별칭은 정규화된 입력으로 기록된다 - 추적 파라미터가 떨어진 형태`() { - val item = newItem("https://www.musinsa.com/products/1000001?utm_source=kakao&af_channel=share") - try { - itemIdentityRecorder.recordRegistrationAlias(item) - - val expected = CanonicalLink.of(ProductLink.parse("https://www.musinsa.com/products/1000001")) - val alias = itemLinkRepository.findByUrlHash(expected.hash) - assertNotNull(alias) - assertEquals(item.getId(), alias.itemId) - assertEquals(expected.url, alias.url) - } finally { - cleanup(listOf(item.getId())) - } - } - - @Test - fun `같은 문자열 재등록은 별칭을 덮지 않고 첫 item 소속으로 남는다 - 등록은 죽지 않는다`() { - val first = newItem("https://www.musinsa.com/products/1000002") - val second = newItem("https://www.musinsa.com/products/1000002") - try { - itemIdentityRecorder.recordRegistrationAlias(first) - itemIdentityRecorder.recordRegistrationAlias(second) - - val hash = CanonicalLink.of(ProductLink.parse("https://www.musinsa.com/products/1000002")).hash - val alias = itemLinkRepository.findByUrlHash(hash) - assertNotNull(alias) - assertEquals(first.getId(), alias.itemId) - // 공유 활성화 전이라 두 번째 등록도 자기 item 을 그대로 갖는다(관측만). - assertTrue(itemLinkRepository.findByItemId(second.getId()).isEmpty()) - } finally { - cleanup(listOf(first.getId(), second.getId())) - } - } - - @Test - fun `파싱 완료 시 귀결점으로 canonical 이 확정되고 귀결점 별칭이 남는다`() { - val item = newItem("https://musinsa.onelink.me/PvkC/idrec0001") - try { - itemIdentityRecorder.recordParsingIdentity( - item.getId(), - "https://www.musinsa.com/products/1000003?af_referrer_customer_id=jsy0714&shortlink=idrec0001", - ) - - val expected = CanonicalLink.of(ProductLink.parse("https://www.musinsa.com/products/1000003")) - val reloaded = itemRepository.findById(item.getId()) - assertNotNull(reloaded) - assertEquals(expected.url, reloaded.canonicalUrl) - assertEquals(expected.hash, reloaded.canonicalHash) - val alias = itemLinkRepository.findByUrlHash(expected.hash) - assertNotNull(alias) - assertEquals(item.getId(), alias.itemId) - } finally { - cleanup(listOf(item.getId())) - } - } - - @Test - fun `같은 귀결점 재확정은 멱등이고 다른 귀결점은 첫 확정을 유지한다 - 정체성 불변`() { - val item = newItem("https://www.29cm.co.kr/products/1000004") - try { - itemIdentityRecorder.recordParsingIdentity(item.getId(), "https://www.29cm.co.kr/products/1000004?reward_key=RK_AAAA") - val firstHash = itemRepository.findById(item.getId())?.canonicalHash - assertNotNull(firstHash) - - // 같은 값 재확정(재파싱) — 멱등. - itemIdentityRecorder.recordParsingIdentity(item.getId(), "https://www.29cm.co.kr/products/1000004?reward_key=RK_BBBB") - assertEquals(firstHash, itemRepository.findById(item.getId())?.canonicalHash) - - // 다른 귀결점(드리프트) — 첫 확정 유지. - itemIdentityRecorder.recordParsingIdentity(item.getId(), "https://www.29cm.co.kr/products/9999994") - assertEquals(firstHash, itemRepository.findById(item.getId())?.canonicalHash) - } finally { - cleanup(listOf(item.getId())) - } - } - - @Test - fun `다른 item 이 같은 귀결점을 소유하면 병합 후보로 관측만 하고 확정하지 않는다`() { - val owner = newItem("https://musinsa.onelink.me/PvkC/idrec0002") - val latecomer = newItem("https://musinsa.onelink.me/PvkC/idrec0003") - val finalUrl = "https://www.musinsa.com/products/1000005" - try { - itemIdentityRecorder.recordParsingIdentity(owner.getId(), finalUrl) - itemIdentityRecorder.recordParsingIdentity(latecomer.getId(), finalUrl) - - val expectedHash = CanonicalLink.of(ProductLink.parse(finalUrl)).hash - assertEquals(expectedHash, itemRepository.findById(owner.getId())?.canonicalHash) - // 공유 활성화 전 — 병합하지 않고 미확정으로 남긴다. - assertNull(itemRepository.findById(latecomer.getId())?.canonicalHash) - // 귀결점 별칭은 먼저 확정한 쪽 소속 그대로다. - assertEquals(owner.getId(), itemLinkRepository.findByUrlHash(expectedHash)?.itemId) - } finally { - cleanup(listOf(owner.getId(), latecomer.getId())) - } - } - - @Test - fun `병합되면 진 item 을 가리키던 위시의 item_id 가 이긴 item 으로 따라간다`() { - // 위시는 item 을 직접 참조하므로(#1051) 버전 재부모화만으로는 참조가 안 따라간다 — 병합 트랜잭션 안에서 - // ItemMerged 를 위시가 받아 옮긴다. 이게 빠지면 2단계(item_id 읽기 전환)에서 위시가 soft delete 된 item 을 가리킨다. - val owner = newItem("https://musinsa.onelink.me/PvkC/idrec0010") - val latecomer = newItem("https://musinsa.onelink.me/PvkC/idrec0011") - val finalUrl = "https://www.musinsa.com/products/1000010" - val userId = UUID.randomUUID() - val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(latecomer.getId(), requestedBy = userId)) - val wish = - wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = latecomer.getId())) - try { - itemIdentityRecorder.recordParsingIdentity(owner.getId(), finalUrl) - itemIdentityRecorder.recordParsingIdentity(latecomer.getId(), finalUrl) - - assertEquals(owner.getId(), wishJpaRepository.findById(wish.getId()).orElseThrow().itemId) - assertEquals(owner.getId(), itemSnapshotJpaRepository.findById(snapshot.getId()).orElseThrow().itemId) - } finally { - wishJpaRepository.deleteById(wish.getId()) - itemSnapshotJpaRepository.deleteById(snapshot.getId()) - cleanup(listOf(owner.getId(), latecomer.getId())) - } - } - - @Test - fun `finalUrl 이 없거나 파싱 불가하면 canonical 은 미확정으로 남는다 - 구버전 extractor 호환`() { - val item = newItem("https://www.musinsa.com/products/1000006") - try { - itemIdentityRecorder.recordParsingIdentity(item.getId(), null) - assertNull(itemRepository.findById(item.getId())?.canonicalHash) - - itemIdentityRecorder.recordParsingIdentity(item.getId(), "ftp://insecure.example.com/p/1") - assertNull(itemRepository.findById(item.getId())?.canonicalHash) - } finally { - cleanup(listOf(item.getId())) - } - } - - @Test - fun `http 귀결점은 같은 주소의 https 와 같은 정체성으로 확정된다`() { - val item = newItem("https://unknown-mall.example.com/p/1000008") - try { - itemIdentityRecorder.recordParsingIdentity(item.getId(), "http://unknown-mall.example.com/product/1000008") - - val expected = CanonicalLink.of(ProductLink.parse("https://unknown-mall.example.com/product/1000008")) - assertEquals(expected.hash, itemRepository.findById(item.getId())?.canonicalHash) - } finally { - cleanup(listOf(item.getId())) - } - } - - @Test - fun `저장 상한을 넘는 귀결점은 확정·별칭 모두 건너뛴다 - 절단은 거짓 정체성`() { - val item = newItem("https://unknown-mall.example.com/p/1000007") - try { - val oversize = "https://unknown-mall.example.com/product/" + "%EC%A0%80".repeat(300) - itemIdentityRecorder.recordParsingIdentity(item.getId(), oversize) - - assertNull(itemRepository.findById(item.getId())?.canonicalHash) - assertTrue(itemLinkRepository.findByItemId(item.getId()).isEmpty()) - } finally { - cleanup(listOf(item.getId())) - } - } -} diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingCapacityConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingCapacityConcurrencyIntegrationTest.kt index 0f713793f..00eda0152 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingCapacityConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingCapacityConcurrencyIntegrationTest.kt @@ -1,15 +1,19 @@ package com.depromeet.piki.item.service import com.depromeet.piki.common.config.AsyncConfig -import com.depromeet.piki.item.domain.Item import com.depromeet.piki.item.domain.ItemParseOutboxStatus -import com.depromeet.piki.item.repository.ItemRepository +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubLinkSnapshotExtractor import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.deleteParseOutboxOf import com.depromeet.piki.support.parseOutboxStatusOf +import com.depromeet.piki.wishlist.domain.Wish +import com.depromeet.piki.wishlist.repository.WishJpaRepository import org.awaitility.Awaitility.await import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout @@ -31,7 +35,7 @@ class ItemParsingCapacityConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var parsingEnqueuer: ParsingEnqueuer - @Autowired private lateinit var itemRepository: ItemRepository + @Autowired private lateinit var wishJpaRepository: WishJpaRepository @Autowired private lateinit var stubLinkSnapshotExtractor: StubLinkSnapshotExtractor @@ -46,28 +50,41 @@ class ItemParsingCapacityConcurrencyIntegrationTest : IntegrationTestSupport() { @Test @Timeout(90) fun `풀이 가득 차면 집지 않고 슬롯이 나면 그때 집는다`() { - stubLinkSnapshotExtractor.build = { ProductSnapshot(link = it, name = "상품", price = 1_000) } + stubLinkSnapshotExtractor.build = { ProductSnapshot(name = "상품", price = 1_000) } val release = CountDownLatch(1) val slots = itemParsingExecutor.maxPoolSize - var itemId = 0L + var slot: ItemSlot? = null try { await().atMost(Duration.ofSeconds(30)).until { itemParsingExecutor.activeCount == 0 } repeat(slots) { itemParsingExecutor.execute { release.await(60, TimeUnit.SECONDS) } } await().atMost(Duration.ofSeconds(10)).until { itemParsingExecutor.activeCount >= slots } - itemId = itemRepository.save(Item(ProductLink.parse("https://shop.example.com/products/capacity-${UUID.randomUUID()}"))).getId() - TransactionTemplate(transactionManager).executeWithoutResult { parsingEnqueuer.enqueue(itemId, requestedBy = UUID.randomUUID()) } + val link = ProductLink.parse("https://shop.example.com/products/capacity-${UUID.randomUUID()}") + val wishSlot = + TransactionTemplate(transactionManager).execute { + val wish = wishJpaRepository.save(Wish(userId = UUID.randomUUID(), itemId = null)) + parsingEnqueuer.enqueue(wish.slot, ParsePurpose.REGISTER, ParseInput.Link(link)) + wish.slot + } ?: error("위시 적재가 커밋되어야 한다") + slot = wishSlot itemParsingScheduler.dispatch() - assertEquals(ItemParseOutboxStatus.PENDING, jdbcTemplate.parseOutboxStatusOf(itemId), "가용 슬롯이 없으면 대기해야 한다") + assertEquals(ItemParseOutboxStatus.PENDING, jdbcTemplate.parseOutboxStatusOf(wishSlot), "가용 슬롯이 없으면 대기해야 한다") release.countDown() await().atMost(Duration.ofSeconds(30)).until { itemParsingExecutor.activeCount == 0 } itemParsingScheduler.dispatch() - await().atMost(Duration.ofSeconds(10)).until { jdbcTemplate.parseOutboxStatusOf(itemId) == ItemParseOutboxStatus.SUCCEEDED } + await().atMost(Duration.ofSeconds(10)).until { jdbcTemplate.parseOutboxStatusOf(wishSlot) == ItemParseOutboxStatus.SUCCEEDED } } finally { release.countDown() - if (itemId != 0L) jdbcTemplate.deleteItems(listOf(itemId)) + slot?.let { cleanup(it) } } } + + private fun cleanup(slot: ItemSlot) { + val itemIds = jdbcTemplate.queryForList("SELECT item_id FROM wishes WHERE id = ? AND item_id IS NOT NULL", Long::class.java, slot.id) + jdbcTemplate.deleteItems(itemIds.filterNotNull()) + jdbcTemplate.deleteParseOutboxOf(listOf(slot)) + jdbcTemplate.update("DELETE FROM wishes WHERE id = ?", slot.id) + } } diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingClaimConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingClaimConcurrencyIntegrationTest.kt index 3f544b616..c359b472f 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingClaimConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemParsingClaimConcurrencyIntegrationTest.kt @@ -1,48 +1,50 @@ package com.depromeet.piki.item.service -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.repository.ItemJpaRepository +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.deleteParseOutboxOf import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import org.springframework.beans.factory.annotation.Autowired import org.springframework.jdbc.core.JdbcTemplate import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong import kotlin.concurrent.thread +import kotlin.random.Random import kotlin.test.assertTrue -import java.util.UUID // 커밋 안 된 적재가 대기열 행을 잠근 동안에도 집기는 기다리지 않아야 한다. 기다리면 사용자 요청 트랜잭션과 교착한다. class ItemParsingClaimConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemParsingService: ItemParsingService - @Autowired private lateinit var itemJpaRepository: ItemJpaRepository + @Autowired private lateinit var parsingEnqueuer: ParsingEnqueuer + @Autowired private lateinit var transactionManager: PlatformTransactionManager + @Autowired private lateinit var jdbcTemplate: JdbcTemplate @Test @Timeout(30) fun `다른 트랜잭션이 적재 중인 대기열 행을 잠근 동안에도 집기는 대기 없이 반환된다`() { - val insertedItemId = AtomicLong(0) + val slot = ItemSlot.wish(Random.nextLong(1, Long.MAX_VALUE)) val inserted = CountDownLatch(1) val release = CountDownLatch(1) - val holder = thread(name = "uncommitted-pending-insert") { - TransactionTemplate(transactionManager).execute { - val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/claim-skip-locked-test.jpg")) - insertedItemId.set(item.getId()) - parsingEnqueuer.enqueue(item.getId(), requestedBy = UUID.randomUUID()) - itemJpaRepository.flush() - inserted.countDown() - release.await(8, TimeUnit.SECONDS) + val holder = + thread(name = "uncommitted-pending-insert") { + TransactionTemplate(transactionManager).execute { + parsingEnqueuer.enqueue(slot, ParsePurpose.REGISTER, ParseInput.Image(ProductImageKey.newRaw("jpg"))) + inserted.countDown() + release.await(8, TimeUnit.SECONDS) + } } - } try { assertTrue(inserted.await(5, TimeUnit.SECONDS), "미커밋 적재가 준비되어야 한다") @@ -52,7 +54,7 @@ class ItemParsingClaimConcurrencyIntegrationTest : IntegrationTestSupport() { } finally { release.countDown() holder.join(10_000) - jdbcTemplate.deleteItems(listOf(insertedItemId.get())) + jdbcTemplate.deleteParseOutboxOf(listOf(slot)) } } } diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemResolverIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemResolverIntegrationTest.kt new file mode 100644 index 000000000..e6462b77f --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemResolverIntegrationTest.kt @@ -0,0 +1,104 @@ +package com.depromeet.piki.item.service + +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.ItemException +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.product.domain.ParseInputType +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.normalizedOf +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +// 정체 URL 조회는 unique 색인을 타므로 DB 로 검증한다 +@Transactional +class ItemResolverIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var itemResolver: ItemResolver + + @Test + fun `처음 보는 링크는 귀결점을 정체 URL 로 가진 새 아이템이 된다`() { + val shortUrl = "https://musinsa.onelink.me/PvkC/resolver0001" + val finalUrl = "https://www.musinsa.com/products/2000001?af_referrer_customer_id=abc&shortlink=resolver0001" + + val item = itemResolver.resolve(link(shortUrl), ProductLink.parse(finalUrl)) + + assertEquals(normalizedOf("https://www.musinsa.com/products/2000001").asCanonical(), item.canonicalUrl) + } + + @Test + fun `귀결점이 없어도 등록 URL 이 이미 있는 정체 URL 이면 그 아이템을 재사용한다`() { + val url = "https://www.musinsa.com/products/2000002" + val first = itemResolver.resolve(link(url), ProductLink.parse(url)) + + val second = itemResolver.resolve(link("$url?utm_source=kakao"), null) + + assertEquals(first.getId(), second.getId()) + } + + @Test + fun `등록 URL 이 달라도 귀결점이 같으면 그 아이템을 재사용한다`() { + val finalUrl = "https://www.musinsa.com/products/2000003" + val first = itemResolver.resolve(link("https://musinsa.onelink.me/PvkC/resolver0002"), ProductLink.parse(finalUrl)) + + val second = itemResolver.resolve(link("https://musinsa.onelink.me/PvkC/resolver0003"), ProductLink.parse(finalUrl)) + + assertEquals(first.getId(), second.getId()) + } + + @Test + fun `귀결점이 없으면 등록 URL 이 정체 URL 이 된다`() { + val url = "https://www.29cm.co.kr/products/2000004" + + val item = itemResolver.resolve(link(url), null) + + assertEquals(normalizedOf(url).asCanonical(), item.canonicalUrl) + } + + // 절단된 정규화 값은 다른 상품과 충돌할 수 있는 거짓 정체성이다(#843) + @Test + fun `저장 상한을 넘는 귀결점은 쓰지 않고 등록 URL 이 정체 URL 이 된다`() { + val url = "https://unknown-mall.example.com/p/2000006" + val oversize = "https://unknown-mall.example.com/product/" + "%EC%A0%80".repeat(300) + + val item = itemResolver.resolve(link(url), ProductLink.parse(oversize)) + + assertEquals(normalizedOf(url).asCanonical(), item.canonicalUrl) + } + + @Test + fun `귀결점과 등록 URL 이 모두 저장 상한을 넘으면 정체 값 없는 아이템을 만들지 않고 실패한다`() { + val oversize = "https://unknown-mall.example.com/product/" + "%EC%A0%80".repeat(300) + + assertFailsWith { itemResolver.resolve(link(oversize), ProductLink.parse(oversize)) } + } + + @Test + fun `이미지 입력은 이미지 키를 정체 값으로 가진 아이템이 되고 쇼핑몰 링크는 없다`() { + val key = "items/raw/${UUID.randomUUID()}.png" + + val item = itemResolver.resolve(ParseInput.Image(ProductImageKey.parse(key)), null) + + assertEquals(ParseInputType.IMAGE, item.sourceType) + assertEquals(key, item.canonicalUrl.value) + assertNull(item.sourceLink()) + assertFailsWith { item.refreshInput() } + } + + @Test + fun `같은 이미지 키는 같은 아이템이고 다른 키는 다른 아이템이다`() { + val key = "items/raw/${UUID.randomUUID()}.png" + val first = itemResolver.resolve(ParseInput.Image(ProductImageKey.parse(key)), null) + + assertEquals(first.getId(), itemResolver.resolve(ParseInput.Image(ProductImageKey.parse(key)), null).getId()) + assertNotEquals(first.getId(), itemResolver.resolve(ParseInput.Image(ProductImageKey.newRaw("png")), null).getId()) + } + + private fun link(url: String): ParseInput = ParseInput.Link(ProductLink.parse(url)) + +} diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt index 014b7ac3d..4211a2703 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemSharingIntegrationTest.kt @@ -1,218 +1,326 @@ package com.depromeet.piki.item.service import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.domain.ItemStatus -import com.depromeet.piki.item.repository.ItemLinkJpaRepository -import com.depromeet.piki.item.repository.ItemLinkRepository -import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.event.ItemParsingCompleted +import com.depromeet.piki.item.event.ItemSlotMerged +import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.support.completeLatest +import com.depromeet.piki.support.wishItemIdOf +import com.depromeet.piki.tournament.domain.TournamentItem +import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserJpaRepository +import com.depromeet.piki.wishlist.domain.WishMemo +import com.depromeet.piki.wishlist.repository.WishMemoRepository import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import jakarta.persistence.EntityManager import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.context.event.ApplicationEvents +import org.springframework.test.context.event.RecordApplicationEvents +import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext +import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue -// 공유 정체성 활성화(#825 3단계)의 계약 검증 — 별칭 히트 재사용·진행 중 합류·앞문 409·병합(재부모화)은 전부 -// DB 문장과 여러 빈의 협업이라 통합으로만 검증 가능하다. -// -// 클래스 @Transactional 없음. recorder 의 병합은 자기 트랜잭션·native 문장으로 돌아 트랜잭션을 공유하면 stale 읽기로 운영과 다른 분기를 탐 +@Transactional +@RecordApplicationEvents class ItemSharingIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var wishPersistenceService: WishPersistenceService - - @Autowired private lateinit var itemIdentityRecorder: ItemIdentityRecorder - - @Autowired private lateinit var itemSharingService: ItemSharingService + @Autowired private lateinit var applicationEvents: ApplicationEvents - @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository - - @Autowired private lateinit var itemLinkRepository: ItemLinkRepository + @Autowired private lateinit var wishPersistenceService: WishPersistenceService - @Autowired private lateinit var itemLinkJpaRepository: ItemLinkJpaRepository + @Autowired private lateinit var itemParsingService: ItemParsingService @Autowired private lateinit var userJpaRepository: UserJpaRepository - @Autowired private lateinit var jwtProvider: JwtProvider @Autowired private lateinit var webApplicationContext: WebApplicationContext + @Autowired private lateinit var entityManager: EntityManager + @Autowired private lateinit var jdbcTemplate: JdbcTemplate + @Autowired private lateinit var wishMemoRepository: WishMemoRepository + + @Autowired private lateinit var tournamentItemRepository: TournamentItemRepository + + @Autowired private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository + @Test - fun `같은 링크의 두 번째 등록은 새 item 없이 기존 item 의 진행 중 파싱에 합류한다`() { - val userA = newMember() - val userB = newMember() - val url = "https://www.musinsa.com/products/8100001" - val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) - try { - // 첫 등록이 PENDING(진행 중) — 두 번째 등록은 새 item·새 작업 없이 같은 snapshot 을 함께 기다린다(#826). - val second = wishPersistenceService.persist(userB, ProductLink.parse(url)) - assertEquals(first.item.getId(), second.item.getId()) - assertEquals(first.snapshot.getId(), second.snapshot.getId()) - } finally { - cleanup(listOf(first.item.getId()), listOf(userA, userB)) - } + fun `처음 보는 링크는 아이템 없이 담기고 파싱을 요청한다`() { + val user = newMember() + + val result = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse("https://www.musinsa.com/products/8100001"))) + + assertNull(result.wish.itemId) + assertEquals(ItemStatus.PARSING, result.card.status) + assertEquals(1, outboxCountOf(result.wish.slot)) } @Test - fun `기계 READY 가 신선하면 재등록이 파싱 없이 그 버전을 재사용하고 갱신 권고 없이 내려간다`() { - val userA = newMember() - val userB = newMember() + fun `같은 새 링크를 두 사람이 등록하면 진행 중 파싱에 합류하지 않고 각자 파싱을 요청한다`() { val url = "https://www.musinsa.com/products/8100002" - val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) - try { - seedMachineReady(first.snapshot.getId(), extractedHoursAgo = 1) - - val second = wishPersistenceService.persist(userB, ProductLink.parse(url)) - assertEquals(first.item.getId(), second.item.getId()) - assertEquals(first.snapshot.getId(), second.snapshot.getId()) - assertEquals(ItemStatus.READY, second.snapshot.status) - assertTrue(second.reused) - assertFalse(second.refreshNeeded) - // 새 PENDING 이 만들어지지 않았다 — 재사용은 파싱 비용 0 이 본질. - assertNull(itemSnapshotRepository.findLatestInProgressByItemId(first.item.getId())) - } finally { - cleanup(listOf(first.item.getId()), listOf(userA, userB)) - } + + val first = wishPersistenceService.persist(RegisterWishFromUrlCommand(newMember(), ProductLink.parse(url))) + val second = wishPersistenceService.persist(RegisterWishFromUrlCommand(newMember(), ProductLink.parse(url))) + + assertNull(second.wish.itemId) + assertEquals(1, outboxCountOf(first.wish.slot)) + assertEquals(1, outboxCountOf(second.wish.slot)) } @Test - fun `낡은 READY 도 재사용하되 refreshNeeded 로 갱신을 권고한다 - 등록은 자동 재추출을 만들지 않는다`() { - val userA = newMember() - val userB = newMember() + fun `이미 확정된 링크를 다른 사람이 등록해도 아이템 없이 담기고 새로 파싱한다`() { val url = "https://www.musinsa.com/products/8100003" - val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) - try { - // 갱신 권고 임계(24h) 밖의 기계 READY — 그래도 그 값에 붙고, 재추출 여부는 사용자 선택(#853). - seedMachineReady(first.snapshot.getId(), extractedHoursAgo = 25) - - val second = wishPersistenceService.persist(userB, ProductLink.parse(url)) - assertEquals(first.item.getId(), second.item.getId()) - assertEquals(first.snapshot.getId(), second.snapshot.getId()) - assertTrue(second.reused) - assertTrue(second.refreshNeeded) - // 등록이 새 파싱을 만들지 않았다 — 위시 행 수에 비례하는 자동 부하를 만들지 않는 것이 본질. - assertNull(itemSnapshotRepository.findLatestInProgressByItemId(first.item.getId())) - } finally { - cleanup(listOf(first.item.getId()), listOf(userA, userB)) - } + registerParsed(newMember(), url) + + val second = wishPersistenceService.persist(RegisterWishFromUrlCommand(newMember(), ProductLink.parse(url))) + + assertNull(second.wish.itemId) + assertEquals(ItemStatus.PARSING, second.card.status) + assertEquals(1, outboxCountOf(second.wish.slot)) } @Test - fun `캐시 재사용 등록의 HTTP 응답에 reused·refreshNeeded 플래그가 내려간다`() { - val userA = newMember() - val userB = newMember() - val url = "https://www.musinsa.com/products/8100006" - val first = wishPersistenceService.persist(userA, ProductLink.parse(url)) - try { - seedMachineReady(first.snapshot.getId(), extractedHoursAgo = 25) - - val mockMvc = - MockMvcBuilders - .webAppContextSetup(webApplicationContext) - .apply(springSecurity()) - .build() - val auth = "Bearer ${jwtProvider.generateAccessToken(userB, IdentityType.MEMBER)}" - mockMvc - .perform( - post("/api/v1/wishlists") - .header(HttpHeaders.AUTHORIZATION, auth) - .contentType(MediaType.APPLICATION_JSON) - .content("""{"url": "$url"}"""), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.data.item.status").value("READY")) - .andExpect(jsonPath("$.data.reused").value(true)) - .andExpect(jsonPath("$.data.refreshNeeded").value(true)) - } finally { - cleanup(listOf(first.item.getId()), listOf(userA, userB)) - } + fun `이미 확정된 링크의 파싱이 성공하면 같은 아이템에 붙고 버전이 하나 더 쌓인다`() { + val url = "https://www.musinsa.com/products/8100004" + val itemId = registerParsed(newMember(), url) + + val secondItemId = registerParsed(newMember(), url) + + assertEquals(itemId, secondItemId) + assertEquals(2, versionCountOf(itemId)) + } + + @Test + fun `등록 URL 이 달라도 파싱 귀결점이 같으면 같은 아이템에 붙는다`() { + val finalUrl = "https://www.musinsa.com/products/8100005" + val itemId = registerParsed(newMember(), "https://musinsa.onelink.me/PvkC/share0001", finalUrl = finalUrl) + + val secondItemId = registerParsed(newMember(), finalUrl) + + assertEquals(itemId, secondItemId) } + // 추적 쿼리가 달라도 같은 정체 URL 로 정규화된다. 문자열 비교였다면 통과했을 재등록 @Test - fun `같은 사용자가 같은 상품을 다시 담으면 409 - 링크 모양이 달라도 정체성 기준`() { + fun `확정된 상품을 같은 사용자가 링크 모양만 바꿔 다시 담으면 409 다`() { val user = newMember() - val first = wishPersistenceService.persist(user, ProductLink.parse("https://www.musinsa.com/products/8100004")) - try { - val mockMvc = - MockMvcBuilders - .webAppContextSetup(webApplicationContext) - .apply(springSecurity()) - .build() - val auth = "Bearer ${jwtProvider.generateAccessToken(user, IdentityType.MEMBER)}" - - // override 몰이라 추적 쿼리가 달라도 같은 정체성으로 정규화된다 — 문자열 비교였다면 통과했을 재등록이 막힌다. - mockMvc - .perform( - post("/api/v1/wishlists") - .header(HttpHeaders.AUTHORIZATION, auth) - .contentType(MediaType.APPLICATION_JSON) - .content("""{"url": "https://www.musinsa.com/products/8100004?utm_source=kakao"}"""), - ).andExpect(status().isConflict) - .andExpect(jsonPath("$.code").value("WISH-009")) - .andExpect(jsonPath("$.detail").value("이미 위시리스트에 등록된 상품이에요.")) - } finally { - cleanup(listOf(first.item.getId()), listOf(user)) - } + registerParsed(user, "https://www.musinsa.com/products/8100007") + + mockMvc() + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url": "https://www.musinsa.com/products/8100007?utm_source=kakao"}"""), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("WISH-009")) + .andExpect(jsonPath("$.detail").value("이미 위시리스트에 등록된 상품이에요.")) + } + + @Test + fun `단축링크로 담아 확정된 상품을 같은 단축링크로 다시 담으면 요청 이력 기준으로 409 다`() { + val user = newMember() + val shortUrl = "https://musinsa.onelink.me/PvkC/share0009" + registerParsed(user, shortUrl, finalUrl = "https://www.musinsa.com/products/8100009") + + mockMvc() + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url": "$shortUrl"}"""), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("WISH-009")) + } + + // 단축 코드는 대소문자를 가린다. 대소문자를 무시하는 비교였다면 409 로 막혔을 등록 + @Test + fun `대소문자만 다른 단축링크는 다른 요청이라 둘 다 담긴다`() { + val user = newMember() + wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse("https://musinsa.onelink.me/PvkC/CaseA1"))) + entityManager.flush() + + mockMvc() + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url": "https://musinsa.onelink.me/PvkC/casea1"}"""), + ).andExpect(status().isCreated) } @Test - fun `귀결점 충돌은 병합된다 - 스냅샷 재부모화 + 별칭 이관 + 임시 item 폐기 + 이후 등록은 승자에 붙음`() { - val userA = newMember() - val userB = newMember() - val userC = newMember() - // 서로 다른 단축 모양이라 별칭 미스 — 각자 item 이 생기고, 파싱 완료 시 같은 귀결점으로 충돌한다(뒷문). - val first = wishPersistenceService.persist(userA, ProductLink.parse("https://musinsa.onelink.me/PvkC/share0001")) - val second = wishPersistenceService.persist(userB, ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002")) - val winnerId = first.item.getId() - val loserId = second.item.getId() - try { - val finalUrl = "https://www.musinsa.com/products/8100005" - itemIdentityRecorder.recordParsingIdentity(winnerId, finalUrl) - itemIdentityRecorder.recordParsingIdentity(loserId, finalUrl) - - // 진 쪽 버전이 승자 소속으로 재부모화됐다 — 위시·출전은 snapshot 만 참조하므로 포인터 수정 없이 자동 추종. - val loserSnapshot = itemSnapshotRepository.findById(second.snapshot.getId()) - assertNotNull(loserSnapshot) - assertEquals(winnerId, loserSnapshot.itemId) - // 별칭이 전부 승자 소속으로 이관됐고 빈 껍데기 item 은 soft delete 됐다. - assertTrue(itemLinkRepository.findByItemId(loserId).isEmpty()) - val deletedAt = - jdbcTemplate.queryForObject( - "SELECT deleted_at FROM items WHERE id = ?", - java.sql.Timestamp::class.java, - loserId, - ) - assertNotNull(deletedAt) - // 이후 진 쪽 단축 모양으로 재등록해도 승자 item 에 붙는다 — 병합이 정체성 조회 공간을 하나로 만든다. - val resolved = itemSharingService.resolveExistingItem(ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002")) - assertEquals(winnerId, resolved?.getId()) - // 실제 등록 흐름(persist)까지 승자에 붙는지 확인 — 조회 공간만이 아니라 attach 도 병합 결과를 따른다. - val third = wishPersistenceService.persist(userC, ProductLink.parse("https://musinsa.onelink.me/PvkC/share0002")) - assertEquals(winnerId, third.item.getId()) - } finally { - cleanup(listOf(winnerId, loserId), listOf(userA, userB, userC)) + fun `파싱 중인 링크를 같은 사용자가 다시 담으면 요청 입력 기준으로 409 다`() { + val user = newMember() + wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse("https://www.musinsa.com/products/8100008"))) + + mockMvc() + .perform( + post("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"url": "https://www.musinsa.com/products/8100008?utm_source=kakao"}"""), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("WISH-009")) + } + + @Test + fun `같은 사용자의 두 위시가 같은 아이템으로 확정되면 나중 위시가 지워지고 메모는 쓴 시각 그대로 앞 위시로 옮겨진다`() { + val user = newMember() + val finalUrl = "https://www.musinsa.com/products/8100010" + val first = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse("https://musinsa.onelink.me/PvkC/merge0010"))).wish + val second = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse(finalUrl))).wish + wishMemoRepository.save(WishMemo(wishId = first.getId(), content = "선물용", writtenAt = LocalDateTime.of(2026, 10, 19, 10, 5))) + wishMemoRepository.save(WishMemo(wishId = second.getId(), content = "사이즈 M", writtenAt = LocalDateTime.of(2026, 10, 19, 10, 0))) + complete(first.slot, finalUrl) + + val settlement = complete(second.slot, finalUrl) + + entityManager.flush() + assertEquals(ExtractionSettlement.Applied(ItemStatus.READY), settlement) + val itemId = assertNotNull(jdbcTemplate.wishItemIdOf(first.getId())) + assertEquals(2, versionCountOf(itemId), "합쳐져도 새 버전은 아이템에 쌓인다") + assertNotNull(deletedAtOf("wishes", second.getId())) + assertNull(deletedAtOf("wishes", first.getId())) + assertEquals( + listOf("사이즈 M" to LocalDateTime.of(2026, 10, 19, 10, 0), "선물용" to LocalDateTime.of(2026, 10, 19, 10, 5)), + memosOf(first.getId()), + ) + } + + @Test + fun `나중 위시가 앞 위시에 합쳐지면 파싱 완료 대신 앞 위시를 가리키는 합침 사실을 발행한다`() { + val user = newMember() + val finalUrl = "https://www.musinsa.com/products/8100013" + val first = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse("https://musinsa.onelink.me/PvkC/merge0013"))).wish + val second = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse(finalUrl))).wish + complete(first.slot, finalUrl) + + complete(second.slot, finalUrl) + + val itemId = assertNotNull(jdbcTemplate.wishItemIdOf(first.getId())) + assertEquals(listOf(ItemSlotMerged(second.slot, first.slot, user, user, itemId)), applicationEvents.stream(ItemSlotMerged::class.java).toList()) + assertEquals(listOf(first.slot), applicationEvents.stream(ItemParsingCompleted::class.java).map { it.slot }.toList()) + } + + // 지운 위시까지 합치면 사용자가 지운 메모가 남은 위시에 나타난다 + @Test + fun `지운 위시가 이미 담긴 아이템으로 확정되면 합치지 않고 채워 메모를 옮기지 않는다`() { + val user = newMember() + val finalUrl = "https://www.musinsa.com/products/8100011" + val first = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse("https://musinsa.onelink.me/PvkC/merge0011"))).wish + val second = wishPersistenceService.persist(RegisterWishFromUrlCommand(user, ProductLink.parse(finalUrl))).wish + wishMemoRepository.save(WishMemo(wishId = second.getId(), content = "지운 위시 메모", writtenAt = LocalDateTime.of(2026, 10, 19, 10, 0))) + complete(first.slot, finalUrl) + second.delete() + + complete(second.slot, finalUrl) + + entityManager.flush() + assertEquals(jdbcTemplate.wishItemIdOf(first.getId()), jdbcTemplate.wishItemIdOf(second.getId())) + assertEquals(emptyList(), memosOf(first.getId())) + } + + @Test + fun `같은 토너먼트의 두 출전이 같은 아이템으로 확정되면 나중 출전이 지워진다`() { + val owner = newMember() + val finalUrl = "https://www.musinsa.com/products/8100012" + val first = tournamentItemRepository.save(TournamentItem(tournamentId = 8100012L, userId = owner, itemId = null)) + val second = tournamentItemRepository.save(TournamentItem(tournamentId = 8100012L, userId = owner, itemId = null)) + listOf(first to "https://musinsa.onelink.me/PvkC/merge0012", second to finalUrl).forEach { (entry, url) -> + itemParseOutboxRepository.save(ItemParseOutbox.of(entry.slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(url)))) } + complete(first.slot, finalUrl) + + complete(second.slot, finalUrl) + + entityManager.flush() + assertNotNull(deletedAtOf("tournament_items", second.getId())) + assertNull(deletedAtOf("tournament_items", first.getId())) + } + + // 등록 뒤 파싱 성공과 같은 경로로 아이템을 확정한다 + private fun registerParsed( + userId: UUID, + url: String, + finalUrl: String? = url, + ): Long { + val result = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(url))) + complete(result.wish.slot, finalUrl) + entityManager.flush() + entityManager.clear() + val itemId = jdbcTemplate.queryForObject("SELECT item_id FROM wishes WHERE id = ?", Long::class.java, result.wish.getId()) + return assertNotNull(itemId) + } + + private fun complete( + slot: ItemSlot, + finalUrl: String?, + ): ExtractionSettlement = + itemParsingService.completeLatest( + entityManager, + slot, + ProductSnapshot( + name = "공유 상품", + price = 10_000, + currency = "KRW", + imageUrl = "https://img.example.com/s.png", + finalUrl = finalUrl?.let(ProductLink::parse), + method = ExtractionMethod.STRUCTURED, + ), + ) + + private fun deletedAtOf( + table: String, + id: Long, + ): LocalDateTime? = jdbcTemplate.queryForObject("SELECT deleted_at FROM $table WHERE id = ?", LocalDateTime::class.java, id) + + private fun memosOf(wishId: Long): List> = + jdbcTemplate.query( + "SELECT content, written_at FROM wish_memos WHERE wish_id = ? ORDER BY written_at", + { rs, _ -> rs.getString("content") to rs.getObject("written_at", LocalDateTime::class.java) }, + wishId, + ) + + private fun versionCountOf(itemId: Long): Int = + jdbcTemplate.queryForObject("SELECT COUNT(*) FROM item_snapshots WHERE item_id = ?", Int::class.java, itemId) ?: 0 + + private fun outboxCountOf(slot: ItemSlot): Int { + entityManager.flush() + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM item_parse_outbox WHERE slot_type = ? AND slot_id = ?", + Int::class.java, + slot.type.name, + slot.id, + ) ?: 0 } private fun newMember(): UUID { @@ -228,31 +336,11 @@ class ItemSharingIntegrationTest : IntegrationTestSupport() { return userId } - private fun seedMachineReady( - snapshotId: Long, - extractedHoursAgo: Long, - ) { - jdbcTemplate.update( - """ - UPDATE item_snapshots - SET status = 'READY', name = '공유 상품', image_url = 'https://img.example.com/s.png', - price = 10000, currency = 'KRW', source = 'SERVER', - extracted_at = DATE_SUB(NOW(6), INTERVAL ? HOUR), updated_at = NOW(6) - WHERE id = ? - """, - extractedHoursAgo, - snapshotId, - ) - } + private fun bearer(userId: UUID): String = "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}" - private fun cleanup( - itemIds: List, - userIds: List, - ) { - jdbcTemplate.deleteItems(itemIds) - userIds.forEach { userId -> - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) - } - } + private fun mockMvc(): MockMvc = + MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() } diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemSlotMergeConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemSlotMergeConcurrencyIntegrationTest.kt new file mode 100644 index 000000000..dd8acac5b --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemSlotMergeConcurrencyIntegrationTest.kt @@ -0,0 +1,222 @@ +package com.depromeet.piki.item.service + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import com.depromeet.piki.image.domain.ProductImage +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemParseOutboxStatus +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.repository.ItemJpaRepository +import com.depromeet.piki.item.repository.ItemParseOutboxRepository +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.product.service.ProductSnapshot +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.StubLinkSnapshotExtractor +import com.depromeet.piki.support.awaitTicking +import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.deleteParseOutboxOf +import com.depromeet.piki.support.deleteWishDataOf +import com.depromeet.piki.support.linkItem +import com.depromeet.piki.support.parseOutboxStatusOf +import com.depromeet.piki.support.runningQueries +import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.tournament.domain.TournamentItem +import com.depromeet.piki.tournament.repository.TournamentItemRepository +import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.user.domain.User +import com.depromeet.piki.user.repository.UserJpaRepository +import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.service.WishlistService +import com.depromeet.piki.wishlist.service.dto.RecoverWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import com.depromeet.piki.wishlist.service.dto.UpdateWishMemoCommand +import org.awaitility.Awaitility.await +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.Duration +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// 같은 아이템을 동시에 확정하면 늦은 쪽은 앞선 커밋을 못 본 스냅샷으로 판정한다. 유니크 거부 뒤 다시 실행해야 합쳐진다. +// 별도 트랜잭션의 동시 진행이 본질이라 @Transactional 없이 새 UUID 로 격리하고 finally 에서 정리한다 +class ItemSlotMergeConcurrencyIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var wishPersistenceService: WishPersistenceService + + @Autowired private lateinit var itemParsingScheduler: ItemParsingScheduler + + @Autowired private lateinit var stubLinkSnapshotExtractor: StubLinkSnapshotExtractor + + @Autowired private lateinit var userJpaRepository: UserJpaRepository + + @Autowired private lateinit var itemJpaRepository: ItemJpaRepository + + @Autowired private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository + + @Autowired private lateinit var tournamentItemRepository: TournamentItemRepository + + @Autowired private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired private lateinit var wishlistService: WishlistService + + @Autowired private lateinit var transactionManager: PlatformTransactionManager + + @Test + fun `같은 사용자의 두 위시가 이미 있는 아이템으로 동시에 확정되면 한 위시만 남고 메모가 합쳐진다`() { + val finalUrl = "https://shop.example.com/products/merge-race-${UUID.randomUUID()}" + itemJpaRepository.save(linkItem(finalUrl)) + + assertWishesMergeUnderRace(finalUrl) + } + + // 아이템 생성이 먼저 부딪혀 정규화 URL 유니크가 거부한다. 회수 대상으로 남지 않고 다시 기록해야 끝난다 + @Test + fun `같은 사용자의 두 위시가 처음 보는 아이템으로 동시에 확정돼도 한 위시만 남고 메모가 합쳐진다`() { + val finalUrl = "https://shop.example.com/products/merge-race-new-${UUID.randomUUID()}" + + assertWishesMergeUnderRace(finalUrl) + } + + @Test + fun `같은 토너먼트의 두 출전이 같은 아이템으로 동시에 확정되면 한 출전만 남는다`() { + val finalUrl = "https://shop.example.com/products/merge-race-entry-${UUID.randomUUID()}" + val item = itemJpaRepository.save(linkItem(finalUrl)) + val tournamentId = System.nanoTime() + val owner = UUID.randomUUID() + val entries = + listOf("a", "b").map { suffix -> + val entry = tournamentItemRepository.save(TournamentItem(tournamentId = tournamentId, userId = owner, itemId = null)) + itemParseOutboxRepository.save( + ItemParseOutbox.of(entry.slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse("https://short.example.com/$suffix-$tournamentId"))), + ) + entry.slot + } + try { + stubBothExtractionsFinishTogether(finalUrl) + + itemParsingScheduler.awaitTicking(Duration.ofSeconds(10)) { entries.all { jdbcTemplate.parseOutboxStatusOf(it) == ItemParseOutboxStatus.SUCCEEDED } } + + val live = + jdbcTemplate.queryForList( + "SELECT item_id FROM tournament_items WHERE tournament_id = ? AND deleted_at IS NULL", + Long::class.java, + tournamentId, + ) + assertEquals(listOf(item.getId()), live) + } finally { + jdbcTemplate.deleteParseOutboxOf(entries) + jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", tournamentId) + jdbcTemplate.deleteItems(listOf(item.getId())) + } + } + + // 파싱 성공이 아이템 잠금을 쥔 상태를 SQL 로 재현한다. 사용자 입력이 그 잠금을 기다린 뒤에 커밋해야 사용자 입력의 스냅샷이 커밋 전으로 고정된다 + @Test + fun `같은 상품을 확정 중인 위시가 있을 때 실패 위시를 사용자 입력으로 살리면 다시 실행해 그 위시에 합쳐진다`() { + val userId = UUID.randomUUID() + userJpaRepository.save(User(id = userId, nickname = "merge", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER)) + val finalUrl = "https://shop.example.com/products/merge-race-input-$userId" + val item = itemJpaRepository.save(linkItem(finalUrl)) + val retryLogs = ListAppender().apply { start() } + val retryLogger = LoggerFactory.getLogger(UniqueConflictRetry::class.java) as Logger + retryLogger.addAppender(retryLogs) + val confirmer = Executors.newSingleThreadExecutor() + try { + val failed = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(finalUrl))).wish + jdbcTemplate.update("UPDATE item_parse_outbox SET status = 'FAILED' WHERE slot_type = 'WISH' AND slot_id = ?", failed.getId()) + val holder = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse("https://short.example.com/input-$userId"))).wish + val holderFilled = CountDownLatch(1) + val confirming = + confirmer.submit { + TransactionTemplate(transactionManager).executeWithoutResult { + jdbcTemplate.queryForObject("SELECT id FROM items WHERE id = ? FOR UPDATE", Long::class.java, item.getId()) + jdbcTemplate.update("UPDATE wishes SET item_id = ? WHERE id = ?", item.getId(), holder.getId()) + holderFilled.countDown() + await().atMost(Duration.ofSeconds(5)).until { jdbcTemplate.runningQueries("%from items %for update%") > 0 } + } + } + assertTrue(holderFilled.await(5, TimeUnit.SECONDS)) + + val result = + wishlistService.recoverWishItem( + RecoverWishItemCommand( + userId = userId, + wishId = failed.getId(), + name = "직접 입력한 이름", + price = 50_000, + currency = "KRW", + image = ProductImage.of(byteArrayOf(1, 2, 3), "image/png"), + memo = null, + ), + ) + confirming.get(5, TimeUnit.SECONDS) + + assertEquals(holder.getId(), result.wish.getId()) + val live = jdbcTemplate.queryForList("SELECT id FROM wishes WHERE user_id = ? AND deleted_at IS NULL", Long::class.java, uuidToBytes(userId)) + assertEquals(listOf(holder.getId()), live) + assertTrue(retryLogs.list.any { it.formattedMessage.startsWith("item.confirm.retry wish=${failed.getId()}") }) + } finally { + confirmer.shutdownNow() + retryLogger.detachAppender(retryLogs) + jdbcTemplate.deleteWishDataOf(userId) + jdbcTemplate.deleteItems(listOf(item.getId())) + jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) + } + } + + private fun assertWishesMergeUnderRace(finalUrl: String) { + val userId = UUID.randomUUID() + userJpaRepository.save(User(id = userId, nickname = "merge", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER)) + try { + val slots = + listOf("선물용", "사이즈 M").mapIndexed { index, memo -> + val url = "https://short.example.com/$index-$userId" + val wish = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(url))).wish + wishPersistenceService.updateMemo(UpdateWishMemoCommand(userId = userId, wishId = wish.getId(), memo = memo)) + wish.slot + } + stubBothExtractionsFinishTogether(finalUrl) + + itemParsingScheduler.awaitTicking(Duration.ofSeconds(10)) { slots.all { jdbcTemplate.parseOutboxStatusOf(it) == ItemParseOutboxStatus.SUCCEEDED } } + + val live = + jdbcTemplate.queryForList( + "SELECT id FROM wishes WHERE user_id = ? AND deleted_at IS NULL", + Long::class.java, + uuidToBytes(userId), + ) + assertEquals(1, live.size, "같은 아이템을 가진 살아 있는 위시는 하나") + val memos = jdbcTemplate.queryForList("SELECT content FROM wish_memos WHERE wish_id = ?", String::class.java, live.single()) + assertEquals(setOf("선물용", "사이즈 M"), memos.toSet()) + } finally { + jdbcTemplate.deleteWishDataOf(userId) + jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) + } + } + + // 두 추출이 함께 끝나야 두 확정이 서로의 커밋 전에 스냅샷을 잡는다 + private fun stubBothExtractionsFinishTogether(finalUrl: String) { + val bothExtracted = CyclicBarrier(2) + stubLinkSnapshotExtractor.build = { + bothExtracted.await(5, TimeUnit.SECONDS) + ProductSnapshot( + name = "합칠 상품", + price = 10_000, + currency = "KRW", + imageUrl = "https://img.example.com/m.png", + finalUrl = ProductLink.parse(finalUrl), + ) + } + } +} diff --git a/src/test/kotlin/com/depromeet/piki/metrics/dashboard/MetricsDashboardIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/metrics/dashboard/MetricsDashboardIntegrationTest.kt index 4add96d6f..a218ace36 100644 --- a/src/test/kotlin/com/depromeet/piki/metrics/dashboard/MetricsDashboardIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/metrics/dashboard/MetricsDashboardIntegrationTest.kt @@ -81,13 +81,12 @@ class MetricsDashboardIntegrationTest : IntegrationTestSupport() { uuidToBytes(member2), "b@t.com", "GOOGLE", UUID.randomUUID().toString(), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow), ) - // 위시 — URL 아이템 1 · 이미지(source_url NULL) 아이템 1, 구간 내. snapshot_id 로 참조(정규화). - jdbcTemplate.update("INSERT INTO items (id, source_url, created_at, updated_at) VALUES (1001, 'https://shop.example/a', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO items (id, created_at, updated_at) VALUES (1002, ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (2001, 1001, 'READY', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (2002, 1002, 'FAILED', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at) VALUES (?, 2001, 1001, ?, ?)", uuidToBytes(member1), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at) VALUES (?, 2002, 1002, ?, ?)", uuidToBytes(member1), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + // 위시 — URL 등록이 성공해 아이템이 확정된 1건, 이미지 등록이 실패해 아이템이 없는 1건, 구간 내 + jdbcTemplate.update("INSERT INTO items (id, source_type, canonical_url, created_at, updated_at) VALUES (1001, 'LINK', 'https://shop.example/a', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + insertWish(6001, member1, itemId = 1001, at = withinWindow) + insertWish(6002, member1, itemId = null, at = withinWindow) + insertOutbox("WISH", 6001, imageKey = null, status = "SUCCEEDED", at = withinWindow) + insertOutbox("WISH", 6002, imageKey = "items/raw/metrics-1.png", status = "FAILED", at = withinWindow) // 리텐션 — 구간(6/20)에 가입한 3명 중 2명이 다음날(6/21 KST) 활동 jdbcTemplate.update("INSERT INTO user_daily_activity (user_id, active_date, created_at) VALUES (?, ?, NOW(6))", uuidToBytes(member1), Date.valueOf(LocalDate.of(2026, 6, 21))) @@ -150,22 +149,19 @@ class MetricsDashboardIntegrationTest : IntegrationTestSupport() { val member = UUID.randomUUID() insertUser(member, "MEMBER", withinWindow) - // 위시 아이템 — 활성 2건(URL·이미지) + 삭제 1건(URL). 셋 다 위시로 참조돼 파싱은 '위시 파싱'. - jdbcTemplate.update("INSERT INTO items (id, source_url, created_at, updated_at) VALUES (3001, 'https://shop.example/a', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO items (id, created_at, updated_at) VALUES (3002, ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) // 이미지(source_url NULL) - jdbcTemplate.update("INSERT INTO items (id, source_url, created_at, updated_at) VALUES (3003, 'https://shop.example/c', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (3101, 3001, 'READY', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (3102, 3002, 'READY', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (3103, 3003, 'FAILED', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at) VALUES (?, 3101, 3001, ?, ?)", uuidToBytes(member), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at) VALUES (?, 3102, 3002, ?, ?)", uuidToBytes(member), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - // 삭제된 위시 — 활성(stock)에선 빠지지만 유입(flow)엔 잡히고, 파싱은 '위시 파싱'으로 남는다(아이템이 위시로 참조됨). - jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at, deleted_at) VALUES (?, 3103, 3003, ?, ?, ?)", uuidToBytes(member), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - - // 토너먼트 전용 아이템 — 위시가 없어 '토너먼트 파싱'으로 잡힌다. - jdbcTemplate.update("INSERT INTO items (id, source_url, created_at, updated_at) VALUES (3004, 'https://shop.example/d', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (3104, 3004, 'READY', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO tournament_items (tournament_id, user_id, snapshot_id, created_at, updated_at) VALUES (8500, ?, 3104, ?, ?)", uuidToBytes(member), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + // 위시 — 활성 2건(URL·이미지 성공) + 삭제 1건(URL 실패). 셋 다 위시 요청이라 '위시 파싱' + jdbcTemplate.update("INSERT INTO items (id, source_type, canonical_url, created_at, updated_at) VALUES (3001, 'LINK', 'https://shop.example/a', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + jdbcTemplate.update("INSERT INTO items (id, source_type, canonical_url, created_at, updated_at) VALUES (3002, 'IMAGE', 'items/raw/550e8400-e29b-41d4-a716-446655443002.png', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + insertWish(7001, member, itemId = 3001, at = withinWindow) + insertWish(7002, member, itemId = 3002, at = withinWindow) + insertWish(7003, member, itemId = null, at = withinWindow, deleted = true) + insertOutbox("WISH", 7001, imageKey = null, status = "SUCCEEDED", at = withinWindow) + insertOutbox("WISH", 7002, imageKey = "items/raw/metrics-2.png", status = "SUCCEEDED", at = withinWindow) + insertOutbox("WISH", 7003, imageKey = null, status = "FAILED", at = withinWindow) + + // 토너먼트 아이템 요청 — '토너먼트 파싱' + jdbcTemplate.update("INSERT INTO tournament_items (id, tournament_id, user_id, created_at, updated_at) VALUES (8601, 8500, ?, ?, ?)", uuidToBytes(member), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + insertOutbox("TOURNAMENT_ITEM", 8601, imageKey = null, status = "SUCCEEDED", at = withinWindow) val snapshot = mockMvc() @@ -242,10 +238,10 @@ class MetricsDashboardIntegrationTest : IntegrationTestSupport() { jdbcTemplate.update("INSERT INTO developers (user_id) VALUES (?)", uuidToBytes(dev)) // 위시 — 각 유저 1건(snapshot_id 로 참조). - jdbcTemplate.update("INSERT INTO items (id, source_url, created_at, updated_at) VALUES (7001, 'https://shop.example/n', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO items (id, source_url, created_at, updated_at) VALUES (7002, 'https://shop.example/d', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (7101, 7001, 'READY', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) - jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_at, updated_at) VALUES (7102, 7002, 'READY', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + jdbcTemplate.update("INSERT INTO items (id, source_type, canonical_url, created_at, updated_at) VALUES (7001, 'LINK', 'https://shop.example/n', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + jdbcTemplate.update("INSERT INTO items (id, source_type, canonical_url, created_at, updated_at) VALUES (7002, 'LINK', 'https://shop.example/d', ?, ?)", Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_by, created_at, updated_at) VALUES (7101, 7001, 'READY', ?, ?, ?)", uuidToBytes(normal), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) + jdbcTemplate.update("INSERT INTO item_snapshots (id, item_id, status, created_by, created_at, updated_at) VALUES (7102, 7002, 'READY', ?, ?, ?)", uuidToBytes(normal), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at) VALUES (?, 7101, 7001, ?, ?)", uuidToBytes(normal), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) jdbcTemplate.update("INSERT INTO wishes (user_id, snapshot_id, item_id, created_at, updated_at) VALUES (?, 7102, 7002, ?, ?)", uuidToBytes(dev), Timestamp.valueOf(withinWindow), Timestamp.valueOf(withinWindow)) @@ -338,4 +334,44 @@ class MetricsDashboardIntegrationTest : IntegrationTestSupport() { assertEquals(2, included.tournament.participants) assertEquals(3, included.tournament.plays) } + + private fun insertWish( + id: Long, + userId: UUID, + itemId: Long?, + at: String, + deleted: Boolean = false, + ) { + jdbcTemplate.update( + "INSERT INTO wishes (id, user_id, item_id, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?)", + id, + uuidToBytes(userId), + itemId, + Timestamp.valueOf(at), + Timestamp.valueOf(at), + Timestamp.valueOf(at).takeIf { deleted }, + ) + } + + // 이미지 키가 없으면 URL 요청이다 + private fun insertOutbox( + slotType: String, + slotId: Long, + imageKey: String?, + status: String, + at: String, + ) { + jdbcTemplate.update( + "INSERT INTO item_parse_outbox (slot_type, slot_id, purpose, input_type, input_value, status, created_at, updated_at) " + + "VALUES (?, ?, 'REGISTER', ?, ?, ?, ?, ?)", + slotType, + slotId, + imageKey?.let { "IMAGE" } ?: "LINK", + imageKey ?: "https://shop.example/$slotId", + status, + Timestamp.valueOf(at), + Timestamp.valueOf(at), + ) + } + } diff --git a/src/test/kotlin/com/depromeet/piki/notification/domain/NotificationKindTest.kt b/src/test/kotlin/com/depromeet/piki/notification/domain/NotificationKindTest.kt index 0068a2f11..6a4c49b49 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/domain/NotificationKindTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/domain/NotificationKindTest.kt @@ -68,6 +68,9 @@ class NotificationKindTest { Arguments.of(NotificationType.ITEM_PARSING_RECOVERED, NotificationKind.WISH, NotificationKind.WISH), Arguments.of(NotificationType.ITEM_PARSING_RECOVERED, NotificationKind.TOURNAMENT, NotificationKind.TOURNAMENT), Arguments.of(NotificationType.ITEM_PARSING_RECOVERED, null, NotificationKind.WISH), + Arguments.of(NotificationType.ITEM_PARSING_MERGED, NotificationKind.WISH, NotificationKind.WISH), + Arguments.of(NotificationType.ITEM_PARSING_MERGED, NotificationKind.TOURNAMENT, NotificationKind.TOURNAMENT), + Arguments.of(NotificationType.ITEM_PARSING_MERGED, null, NotificationKind.WISH), Arguments.of(NotificationType.ITEM_REFRESH_COMPLETED, NotificationKind.WISH, NotificationKind.WISH), Arguments.of(NotificationType.ITEM_REFRESH_COMPLETED, NotificationKind.TOURNAMENT, NotificationKind.TOURNAMENT), Arguments.of(NotificationType.ITEM_REFRESH_COMPLETED, null, NotificationKind.WISH), diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt index 38b8cf39d..e61372a8b 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationEventHandlerIntegrationTest.kt @@ -1,13 +1,12 @@ package com.depromeet.piki.notification.handler -import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.event.ItemParsingCompleted import com.depromeet.piki.item.event.ItemParsingFailed import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.notification.domain.NotificationType import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.tournament.domain.TournamentItem -import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.tournament.event.TournamentItemAdded import com.depromeet.piki.tournament.event.TournamentItemDeleted import com.depromeet.piki.tournament.event.TournamentJoined @@ -18,11 +17,8 @@ import org.springframework.transaction.annotation.Transactional import java.util.UUID import kotlin.test.assertEquals -// 핸들러가 이제 repo·resolver 를 주입받으므로 무인자 생성이 불가하다. 실제 와이어링된 빈을 autowire 해 -// 베이스 클래스의 제네릭 eventType 도출·notificationType·유일성을 검증한다(내부 모킹 없이 실제 빈으로). -// -// @Transactional 자동 롤백 — itemName 검증이 실제 snapshot 행을 쓰는데, 롤백이 없으면 items 행 없는 -// PROCESSING snapshot 이 공유 DB 에 남아 정원 배치 스캔에 유령 행으로 섞인다. +// 핸들러가 repo·resolver 를 주입받아 무인자 생성이 불가하다. 실제 와이어링된 빈으로 검증한다 +// itemName 검증이 items 행 없는 버전을 쓰므로 @Transactional 롤백으로 공유 DB 에 남기지 않는다 @Transactional class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemParsingCompletedHandler: ItemParsingCompletedHandler @@ -47,17 +43,15 @@ class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository - @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository - // eventType 은 제네릭 타입 인자 E 에서 GenericTypeResolver 로 자동 도출된다(::class 명시 제거). // reflection 기반이라 클래스 계층이 바뀌면 조용히 틀어질 수 있어, 도출 결과를 직접 못 박아 회귀를 잡는다. @Test fun `각 핸들러의 eventType 이 제네릭 인자에서 올바르게 도출된다`() { assertEquals(ItemParsingCompleted::class, itemParsingCompletedHandler.eventType) assertEquals(ItemParsingFailed::class, itemParsingFailedHandler.eventType) - // 해소 통지는 완료와 **같은 이벤트** 를 구독한다 — 한 사실에서 수신자가 배타적인 두 알림이 갈린다(#1028). + // 해소 통지는 완료와 같은 이벤트를 구독한다. 한 사실에서 수신자가 배타적인 두 알림이 갈린다(#1028) assertEquals(ItemParsingCompleted::class, itemParsingRecoveredHandler.eventType) - // 새로고침 알림(#1036)도 등록 완료·실패와 **같은 이벤트** 를 구독한다 — 위시 생성시각으로 수신자가 배타적으로 갈린다. + // 새로고침 알림(#1036)도 등록 완료·실패와 같은 이벤트를 구독한다. 이벤트의 refresh 로 수신자가 배타적으로 갈린다 assertEquals(ItemParsingCompleted::class, itemRefreshCompletedHandler.eventType) assertEquals(ItemParsingFailed::class, itemRefreshFailedHandler.eventType) assertEquals(TournamentItemAdded::class, tournamentItemAddedHandler.eventType) @@ -89,58 +83,35 @@ class NotificationEventHandlerIntegrationTest : IntegrationTestSupport() { assertEquals(pairs.size, pairs.toSet().size) } - // 파싱 완료 알림 문구에 담을 itemName 변수를 그 버전(snapshot)의 이름에서 채운다(#895). @Test fun `파싱 완료 핸들러는 그 버전의 아이템 이름을 itemName 변수로 담는다`() { - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9101L, name = "나이키 에어맥스")).getId() + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = 9101L, name = "나이키 에어맥스")).getId() - val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9101L, snapshotId = snapshotId)) + val context = itemParsingCompletedHandler.resolveActorContext(completed(itemId = 9101L, snapshotId = snapshotId)) assertEquals("나이키 에어맥스", context.variables["itemName"]) } - // best-effort — 버전이 없어도(이례) 이름 하나 때문에 알림을 떨구지 않고 기본값을 담는다. + // best-effort. 버전이 없어도(이례) 이름 하나 때문에 알림을 떨구지 않는다 @Test fun `파싱 완료 핸들러는 버전을 못 찾으면 itemName 을 기본값으로 담는다`() { - val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9102L, snapshotId = 9_999_999L)) + val context = itemParsingCompletedHandler.resolveActorContext(completed(itemId = 9102L, snapshotId = 9_999_999L)) assertEquals("상품", context.variables["itemName"]) } - // 파싱 완료 문구는 등록 출처(위시/토너먼트)로 갈리지 않는다 — 갈리면 안 되는 이유가 구조에 있다. - // dispatcher 는 문구·라우팅을 수신자 루프 **밖에서 한 번** 해석해 전 수신자에게 같은 값을 박는데, 공유(#825)의 - // "진행 중 합류" 로 한 snapshot 에 위시 주인과 토너먼트 등록자가 함께 붙을 수 있다. 그 상태로 출처별 문구를 쓰면 - // 위시 주인이 토너먼트 문구를 받는다. 출처별 분기는 수신자별 라우팅 해석과 함께 후속(#933)에서 한다. - // - // 그래서 "출전 pin 이 있어도 문구 변수가 늘지 않는다" 를 못 박는다 — 수신자별 해석 없이 분기를 되살리면 여기서 깨진다. - @Test - fun `파싱 완료 문구 변수는 출전 pin 이 있어도 itemName 하나뿐이다`() { - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9103L, name = "나이키 에어맥스")).getId() - // 이 버전을 pin 한 출전이 있으면 라우팅은 Tournament 로 해석된다. 그래도 문구는 그대로여야 한다. - tournamentItemJpaRepository.save(TournamentItem(tournamentId = 9201L, userId = UUID.randomUUID(), snapshotId = snapshotId)) - - val context = itemParsingCompletedHandler.resolveActorContext(ItemParsingCompleted(itemId = 9103L, snapshotId = snapshotId)) - - assertEquals(setOf("itemName"), context.variables.keys) - assertEquals("나이키 에어맥스", context.variables["itemName"]) - } - - // 파싱 실패 알림도 완료·미완과 동일하게 title 변수 itemName 을 그 버전 이름에서 채운다(#959). - // 실패 템플릿 문구는 현재 고정이라 렌더엔 안 쓰이지만, 어드민이 실패 문구에 이름을 넣어 편집할 수 있게 dispatch 가 채워 둔다. + // 실패는 버전을 만들지 않아 읽을 이름이 없다. 어드민이 실패 문구에 이름을 넣어도 렌더가 깨지지 않게 기본값을 채운다(#959) @Test - fun `파싱 실패 핸들러는 그 버전의 아이템 이름을 itemName 변수로 담는다`() { - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 9111L, name = "나이키 에어맥스")).getId() + fun `파싱 실패 핸들러는 itemName 을 기본값으로 담는다`() { + val failed = ItemParsingFailed(ItemSlot.wish(9111L), UUID.randomUUID(), refresh = false) - val context = itemParsingFailedHandler.resolveActorContext(ItemParsingFailed(itemId = 9111L, snapshotId = snapshotId)) - - assertEquals("나이키 에어맥스", context.variables["itemName"]) - } - - // 추출 자체가 실패해 이름이 비는 게 오히려 흔한 경로 — 그때도 이름 하나 때문에 알림을 떨구지 않고 기본값을 담는다. - @Test - fun `파싱 실패 핸들러는 버전을 못 찾으면 itemName 을 기본값으로 담는다`() { - val context = itemParsingFailedHandler.resolveActorContext(ItemParsingFailed(itemId = 9112L, snapshotId = 9_999_998L)) + val context = itemParsingFailedHandler.resolveActorContext(failed) assertEquals("상품", context.variables["itemName"]) } + + private fun completed( + itemId: Long, + snapshotId: Long, + ) = ItemParsingCompleted(ItemSlot.wish(9001L), UUID.randomUUID(), itemId, snapshotId, refresh = false) } diff --git a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt index de15b8ef4..aca8e9b77 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/handler/NotificationRecipientResolutionIntegrationTest.kt @@ -1,18 +1,22 @@ package com.depromeet.piki.notification.handler -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.event.ItemParsingCompleted +import com.depromeet.piki.item.event.ItemSlotMerged import com.depromeet.piki.item.event.ItemParsingFailed import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.notification.domain.NotificationRouting import com.depromeet.piki.notification.domain.NotificationType import com.depromeet.piki.notification.repository.NotificationRepository import com.depromeet.piki.notification.service.NotificationDispatcher +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.completeLatest +import com.depromeet.piki.support.failLatest +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.tournament.domain.Tournament -import com.depromeet.piki.tournament.domain.TournamentItem import com.depromeet.piki.tournament.domain.TournamentUser import com.depromeet.piki.tournament.event.TournamentCompleted import com.depromeet.piki.tournament.event.TournamentItemAdded @@ -21,16 +25,21 @@ import com.depromeet.piki.tournament.event.TournamentJoined import com.depromeet.piki.tournament.event.TournamentPlayedFromLink import com.depromeet.piki.tournament.event.TournamentResultReady import com.depromeet.piki.tournament.event.TournamentStarted -import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository +import com.depromeet.piki.tournament.service.TournamentItemPersistenceService import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserRepository import com.depromeet.piki.wishlist.domain.Wish -import com.depromeet.piki.wishlist.repository.WishRepository +import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.service.dto.RefreshWishItemCommand +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import jakarta.persistence.EntityManager import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.event.ApplicationEvents +import org.springframework.test.context.event.RecordApplicationEvents import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.util.UUID @@ -40,7 +49,9 @@ import kotlin.test.assertTrue // 핸들러의 수신자(resolveRecipients)·actor 컨텍스트(resolveActorContext) 도출은 DB 역조회에 의존하므로 통합으로 검증한다. // 영속 fixture(참가자·위시·토너먼트 아이템·유저)를 깔고 실제 빈으로 도출 결과를 단언한다. @Transactional 자동 롤백. +// 파싱 이벤트는 AFTER_COMMIT 리스너가 롤백 트랜잭션에선 안 돌아, 발행된 이벤트를 기록해 핸들러에 직접 넘긴다 @Transactional +@RecordApplicationEvents class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemAddedHandler: TournamentItemAddedHandler @@ -64,15 +75,23 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() @Autowired private lateinit var parsingRecoveredHandler: ItemParsingRecoveredHandler + @Autowired private lateinit var mergedHandler: ItemParsingMergedHandler + @Autowired private lateinit var refreshCompletedHandler: ItemRefreshCompletedHandler @Autowired private lateinit var refreshFailedHandler: ItemRefreshFailedHandler @Autowired private lateinit var tournamentUserRepository: TournamentUserRepository - @Autowired private lateinit var tournamentItemRepository: TournamentItemRepository + @Autowired private lateinit var tournamentItemPersistenceService: TournamentItemPersistenceService + + @Autowired private lateinit var wishPersistenceService: WishPersistenceService + + @Autowired private lateinit var itemParsingService: ItemParsingService + + @Autowired private lateinit var entityManager: EntityManager - @Autowired private lateinit var wishRepository: WishRepository + @Autowired private lateinit var applicationEvents: ApplicationEvents @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository @@ -124,7 +143,7 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() val tournamentId = 1202L val actor = UUID.randomUUID() userRepository.save(User(id = actor, nickname = "홍길동", profileImage = "https://x/p.jpg", identityType = IdentityType.GUEST)) - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 7202L, name = "나이키 에어맥스")).getId() + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = 7202L, name = "나이키 에어맥스")).getId() val variables = itemDeletedHandler.resolveActorContext( @@ -145,7 +164,7 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() val actor = UUID.randomUUID() userRepository.save(User(id = actor, nickname = "홍길동", profileImage = "https://x/p.jpg", identityType = IdentityType.GUEST)) val longName = "가".repeat(30) - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 7205L, name = longName)).getId() + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = 7205L, name = longName)).getId() val variables = itemDeletedHandler.resolveActorContext( @@ -167,7 +186,7 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() userRepository.save(User(id = actor, nickname = "홍길동", profileImage = "https://x/p.jpg", identityType = IdentityType.GUEST)) // 앞에 1 char 를 둬 절단 경계를 홀수로 민다 — 이모지(2 char)만 있으면 코드 유닛으로 잘라도 짝이 맞아떨어져 // 회귀가 드러나지 않는다. 이 한 글자가 경계를 surrogate pair 한가운데로 옮긴다. - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 7206L, name = "가" + "😀".repeat(20))).getId() + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = 7206L, name = "가" + "😀".repeat(20))).getId() val variables = itemDeletedHandler.resolveActorContext( @@ -189,12 +208,10 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() @Test fun `토너먼트 아이템 삭제 변수 itemName 은 상품명이 아직 없으면 fallback 이다`() { - // 파싱 전(PENDING) 아이템을 지운 경우 — snapshot.name 이 null 이라 fallback 문구로 채운다. - val snapshotId = itemSnapshotRepository.save(ItemSnapshot.pending(7203L, requestedBy = UUID.randomUUID())).getId() - + // 파싱 전 출전을 지우면 버전이 없어 이벤트에 snapshotId 가 없다 val variables = itemDeletedHandler.resolveActorContext( - TournamentItemDeleted(tournamentId = 1203L, tournamentItemId = 1L, snapshotId = snapshotId, actorId = UUID.randomUUID()), + TournamentItemDeleted(tournamentId = 1203L, tournamentItemId = 1L, snapshotId = null, actorId = UUID.randomUUID()), ).variables assertEquals("상품", variables["itemName"]) @@ -276,9 +293,10 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() @Test fun `시스템 알림(파싱)은 actor 가 없어 actorImageUrl 이 null 이다 - negative control`() { - // 파싱 핸들러는 resolveActorContext 를 override 하지 않는다 → 기본 빈 컨텍스트라 imageUrl 이 null 이다. - assertEquals(null, parsingCompletedHandler.resolveActorContext(ItemParsingCompleted(2099L, 20990L)).imageUrl) - assertEquals(null, parsingFailedHandler.resolveActorContext(ItemParsingFailed(2099L, 20990L)).imageUrl) + val slot = ItemSlot.wish(2099L) + val owner = UUID.randomUUID() + assertEquals(null, parsingCompletedHandler.resolveActorContext(ItemParsingCompleted(slot, owner, 2099L, 20990L, refresh = false)).imageUrl) + assertEquals(null, parsingFailedHandler.resolveActorContext(ItemParsingFailed(slot, owner, refresh = false)).imageUrl) } @Test @@ -296,7 +314,7 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() assertEquals(1, saved.size) assertEquals("https://x/snap.png", saved.first().actorImageUrl) // 발송 시점 actor 프사가 그대로 박혔다 // 같은 컨텍스트의 변수(actorName)가 템플릿에 렌더돼 제목에 박힌다 — 변수·프사가 한 조회에서 함께 흐르는지 end-to-end 로 가드한다. - assertEquals("행위자님이 아이템을 추가했어요", saved.first().title) + assertEquals("행위자님이 토너먼트 후보를 담았어요", saved.first().title) } @Test @@ -307,7 +325,7 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() val recipient = UUID.randomUUID() listOf(actor, recipient).forEach { tournamentUserRepository.save(TournamentUser(tournamentId, it)) } userRepository.save(User(id = actor, nickname = "행위자", profileImage = "https://x/snap.png", identityType = IdentityType.GUEST)) - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = 7205L, name = "나이키 에어맥스")).getId() + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = 7205L, name = "나이키 에어맥스")).getId() // 이벤트 발행 → dispatch → 삭제 핸들러(snapshot 으로 상품명 해석) → 삭제 템플릿 렌더 + 라우팅까지 실제 체인을 탄다. notificationDispatcher.dispatch( @@ -325,462 +343,181 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), saved.first().routing()) } - @Test - fun `파싱 완료 수신자 - 그 버전을 활성으로 가리키는 위시 주인들이다 (다른 버전을 보는 위시는 제외)`() { - // 라우팅 키는 버전(#576) — 공유(#825)로 한 버전을 여러 위시가 가리키면 전원이 받고, - // 같은 item 이라도 다른 버전(갱신 전 등)을 보는 위시는 이 파싱을 기다린 적이 없어 받지 않는다. - val itemId = 2001L - val sharedVersion = snapshotIdFor(itemId) - val otherVersion = snapshotIdFor(itemId) - val owner1 = UUID.randomUUID() - val owner2 = UUID.randomUUID() - val otherVersionOwner = UUID.randomUUID() - wishRepository.save(Wish(owner1, sharedVersion, itemId)) - wishRepository.save(Wish(owner2, sharedVersion, itemId)) - wishRepository.save(Wish(otherVersionOwner, otherVersion, itemId)) - - val recipients = parsingCompletedHandler.resolveRecipients(ItemParsingCompleted(itemId, sharedVersion)) - - assertEquals(setOf(owner1, owner2), recipients) - } - - @Test - fun `파싱 완료 수신자 - 토너먼트로 담긴 아이템은 올린 본인(adder)에게만 가고 다른 참가자는 제외된다`() { - val itemId = 2002L - val tournamentId = 1005L - val adder = UUID.randomUUID() - val otherParticipant = UUID.randomUUID() - // otherParticipant 는 추가 시점에 TOURNAMENT_ITEM_ADDED 로 갱신하므로 파싱완료는 안 받는다 — 참가자로 깔아두고 제외를 확인. - listOf(adder, otherParticipant).forEach { tournamentUserRepository.save(TournamentUser(tournamentId, it)) } - val snapshotId = snapshotIdFor(itemId) - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))) - - val recipients = parsingCompletedHandler.resolveRecipients(ItemParsingCompleted(itemId, snapshotId)) - - assertEquals(setOf(adder), recipients) - } - - @Test - fun `파싱 완료 수신자 - 위시 주인과 토너먼트 adder 의 합집합이다 (그냥 참가자는 제외)`() { - val itemId = 2003L - val tournamentId = 1006L - val wishOwner = UUID.randomUUID() - val adder = UUID.randomUUID() - val otherParticipant = UUID.randomUUID() - // 공유(#825)의 세계 — 위시와 토너먼트 출전이 같은 버전을 가리킨다. - val snapshotId = snapshotIdFor(itemId) - wishRepository.save(Wish(wishOwner, snapshotId, itemId)) - listOf(adder, otherParticipant).forEach { tournamentUserRepository.save(TournamentUser(tournamentId, it)) } - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))) - - val recipients = parsingCompletedHandler.resolveRecipients(ItemParsingCompleted(itemId, snapshotId)) - - assertEquals(setOf(wishOwner, adder), recipients) - } - - @Test - fun `파싱 완료 수신자 - 어디에도 안 담긴 아이템은 빈 집합이다`() { - val recipients = parsingCompletedHandler.resolveRecipients(ItemParsingCompleted(999_999L, 999_999L)) - - assertTrue(recipients.isEmpty()) - } + // ── 파싱 알림: 요청한 카드의 주인 한 명에게 그 카드 좌표로 ────────────────────────── @Test - fun `파싱 실패 핸들러도 완료와 동일한 역조회 규칙을 쓴다`() { - val itemId = 2004L + fun `위시 등록 파싱 완료는 그 위시 주인 한 명에게 위시 좌표와 위시 완료 문구로 간다`() { val owner = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - wishRepository.save(Wish(owner, snapshotId, itemId)) + val wish = registerWish(owner, newUrl()) - val recipients = parsingFailedHandler.resolveRecipients(ItemParsingFailed(itemId, snapshotId)) + itemParsingService.completeLatest(entityManager, wish.slot, product("나이키")) + val event = lastEvent() + assertFalse(event.refresh) + val recipients = parsingCompletedHandler.resolveRecipients(event) assertEquals(setOf(owner), recipients) + assertTrue(refreshCompletedHandler.resolveRecipients(event).isEmpty()) + val context = parsingCompletedHandler.resolveRecipientContexts(event, recipients).getValue(owner) + assertEquals(NotificationRouting.Wish(wish.getId()), context.routing) + assertEquals(ItemParsingCompletedHandler.WISH_COMPLETION_MESSAGE, context.variables["completionMessage"]) } @Test - fun `파싱 완료 라우팅 - 위시 주인은 자기 wishId 를 실은 WISH 와 위시 완료 문구를 받는다`() { - val itemId = 3001L - val owner = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - val wishId = wishRepository.save(Wish(owner, snapshotId, itemId)).getId() - - val contexts = parsingCompletedHandler.resolveRecipientContexts(ItemParsingCompleted(itemId, snapshotId), setOf(owner)) - - assertEquals(NotificationRouting.Wish(wishId), contexts.getValue(owner).routing) - assertEquals(ItemParsingCompletedHandler.WISH_COMPLETION_MESSAGE, contexts.getValue(owner).variables["completionMessage"]) - } - - @Test - fun `파싱 완료 라우팅 - 토너먼트 등록자는 자기 출전 좌표 TOURNAMENT 와 토너먼트 완료 문구를 받는다`() { - val itemId = 3002L - val tournamentId = 1100L + fun `토너먼트 출전 파싱 완료는 올린 본인에게만 출전 좌표와 토너먼트 완료 문구로 가고 다른 참가자는 받지 않는다`() { + val host = UUID.randomUUID() val adder = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - val tournamentItemId = - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))).first().getId() + val tournamentId = createRootWithOwner(host) + tournamentUserRepository.save(TournamentUser(tournamentId, adder)) + val tournamentItemId = addTournamentItem(adder, tournamentId, newUrl()) - val contexts = parsingCompletedHandler.resolveRecipientContexts(ItemParsingCompleted(itemId, snapshotId), setOf(adder)) + itemParsingService.completeLatest(entityManager, ItemSlot.tournamentItem(tournamentItemId), product("나이키")) + val event = lastEvent() - assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), contexts.getValue(adder).routing) - assertEquals( - ItemParsingCompletedHandler.TOURNAMENT_COMPLETION_MESSAGE, - contexts.getValue(adder).variables["completionMessage"], - ) + val recipients = parsingCompletedHandler.resolveRecipients(event) + assertEquals(setOf(adder), recipients) + val context = parsingCompletedHandler.resolveRecipientContexts(event, recipients).getValue(adder) + assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), context.routing) + assertEquals(ItemParsingCompletedHandler.TOURNAMENT_COMPLETION_MESSAGE, context.variables["completionMessage"]) } @Test - fun `파싱 완료 - 위시 주인과 토너먼트 등록자가 한 버전을 공유하면 각자 자기 라우팅·문구를 받는다 (수신자별 negative control)`() { - // 이벤트 단위로 라우팅·문구를 한 번만 해석하던 이전 구조에선 둘 중 하나가 남의 딥링크·문구를 받아 이 단언이 반드시 깨진다. - val itemId = 3005L - val tournamentId = 1102L - val wishOwner = UUID.randomUUID() - val adder = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - val wishId = wishRepository.save(Wish(wishOwner, snapshotId, itemId)).getId() - val tournamentItemId = - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))).first().getId() + fun `같은 상품의 위시에 합쳐진 위시의 주인은 남은 위시 좌표와 위시 합침 문구로 알림을 받는다`() { + val owner = UUID.randomUUID() + val finalUrl = newUrl() + val holder = registerWish(owner, "https://short.example.com/${UUID.randomUUID()}") + val merged = registerWish(owner, finalUrl) + itemParsingService.completeLatest(entityManager, holder.slot, landedOn(finalUrl, "옛 이름")) + itemParsingService.completeLatest(entityManager, merged.slot, landedOn(finalUrl, "새 이름")) - val contexts = - parsingCompletedHandler.resolveRecipientContexts(ItemParsingCompleted(itemId, snapshotId), setOf(wishOwner, adder)) + notificationDispatcher.dispatch(lastEvent()) - assertEquals(NotificationRouting.Wish(wishId), contexts.getValue(wishOwner).routing) - assertEquals( - ItemParsingCompletedHandler.WISH_COMPLETION_MESSAGE, - contexts.getValue(wishOwner).variables["completionMessage"], - ) - assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), contexts.getValue(adder).routing) - assertEquals( - ItemParsingCompletedHandler.TOURNAMENT_COMPLETION_MESSAGE, - contexts.getValue(adder).variables["completionMessage"], - ) + val saved = notificationRepository.findPage(owner, cursor = null, limit = 10).single { it.type == NotificationType.ITEM_PARSING_MERGED } + assertEquals("새 이름", saved.title) + assertEquals(ItemParsingMergedHandler.WISH_MERGED_MESSAGE, saved.body) + assertEquals(NotificationRouting.Wish(holder.getId()), saved.routing()) } + // 내가 입력한 값은 남이 올린 후보 카드에 안 보인다. 제목은 눌러서 가는 그 카드의 이름이어야 한다 @Test - fun `파싱 완료 - 한 사람이 위시 주인이면서 그 버전을 토너먼트에도 올렸으면 WISH 가 우선이다`() { - val itemId = 3006L - val tournamentId = 1103L - val user = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - val wishId = wishRepository.save(Wish(user, snapshotId, itemId)).getId() - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, user, snapshotId))) + fun `직접 입력한 후보가 남이 올린 후보에 합쳐지면 올린 사람만 남은 후보 좌표와 그 카드의 이름으로 알림을 받는다`() { + val host = UUID.randomUUID() + val adder = UUID.randomUUID() + val tournamentId = createRootWithOwner(host) + tournamentUserRepository.save(TournamentUser(tournamentId, adder)) + val finalUrl = newUrl() + val holderId = addTournamentItem(host, tournamentId, "https://short.example.com/${UUID.randomUUID()}") + val mergedId = addTournamentItem(adder, tournamentId, finalUrl) + itemParsingService.completeLatest(entityManager, ItemSlot.tournamentItem(holderId), landedOn(finalUrl, "주최자 카드의 이름")) + itemParsingService.failLatest(entityManager, ItemSlot.tournamentItem(mergedId)) - val contexts = parsingCompletedHandler.resolveRecipientContexts(ItemParsingCompleted(itemId, snapshotId), setOf(user)) + tournamentItemPersistenceService.applyUserInput(adder, tournamentId, mergedId, "직접 입력한 이름", 10_000, "https://img.example.com/u.png", "KRW") + val event = lastEvent() - assertEquals(NotificationRouting.Wish(wishId), contexts.getValue(user).routing) + assertEquals(setOf(adder), mergedHandler.resolveRecipients(event)) + assertEquals("주최자 카드의 이름", mergedHandler.resolveActorContext(event).variables["itemName"]) + val context = mergedHandler.resolveRecipientContexts(event, setOf(adder)).getValue(adder) + assertEquals(NotificationRouting.Tournament(tournamentId, holderId), context.routing) + assertEquals(ItemParsingMergedHandler.TOURNAMENT_MERGED_MESSAGE, context.variables["mergedMessage"]) } @Test - fun `파싱 실패 라우팅 - 위시 주인은 WISH(wishId), 토너먼트 등록자는 TOURNAMENT 좌표를 수신자별로 받는다 (문구는 단일)`() { - val itemId = 3003L - val tournamentId = 1101L - val wishOwner = UUID.randomUUID() - val adder = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - val wishId = wishRepository.save(Wish(wishOwner, snapshotId, itemId)).getId() - val tournamentItemId = - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))).first().getId() + fun `아이템이 있는 위시의 새로고침 완료는 새로고침 완료로만 가고 등록 완료와 배타적이다`() { + val owner = UUID.randomUUID() + val wish = registerWish(owner, newUrl()) + itemParsingService.completeLatest(entityManager, wish.slot, product("옛 이름")) + wishPersistenceService.refresh(RefreshWishItemCommand(owner, wish.getId())) - val contexts = - parsingFailedHandler.resolveRecipientContexts(ItemParsingFailed(itemId, snapshotId), setOf(wishOwner, adder)) + itemParsingService.completeLatest(entityManager, wish.slot, product("나이키")) + val event = lastEvent() - assertEquals(NotificationRouting.Wish(wishId), contexts.getValue(wishOwner).routing) - assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), contexts.getValue(adder).routing) - // 실패 알림은 출처별 문구가 없다 — completionMessage 변수를 싣지 않는다(단일 문구는 템플릿이 소유). - assertTrue(contexts.getValue(wishOwner).variables.isEmpty()) + assertTrue(event.refresh) + assertEquals(setOf(owner), refreshCompletedHandler.resolveRecipients(event)) + assertTrue(parsingCompletedHandler.resolveRecipients(event).isEmpty()) + assertEquals(NotificationRouting.Wish(wish.getId()), refreshCompletedHandler.resolveRouting(event)) } @Test - fun `dispatch 가 한 파싱 완료 이벤트를 위시 주인·토너먼트 등록자에게 각자의 딥링크·문구·wishId 로 저장한다 - end-to-end`() { - // 이슈가 지정한 dispatch 레벨 negative control — 이벤트 단위 1회 해석이던 이전 구조에선 둘 중 하나가 - // 남의 딥링크·문구를 저장받아 이 단언이 반드시 깨진다. - val itemId = 3100L - val tournamentId = 1200L - val wishOwner = UUID.randomUUID() - val adder = UUID.randomUUID() - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, name = "나이키")).getId() - val wishId = wishRepository.save(Wish(wishOwner, snapshotId, itemId)).getId() - val tournamentItemId = - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))).first().getId() - - notificationDispatcher.dispatch(ItemParsingCompleted(itemId, snapshotId)) + fun `위시 등록 파싱 실패는 등록 실패로만 가고 위시 좌표를 싣는다`() { + val owner = UUID.randomUUID() + val wish = registerWish(owner, newUrl()) - val ownerNoti = notificationRepository.findPage(wishOwner, cursor = null, limit = 10).first() - assertEquals(NotificationRouting.Wish(wishId), ownerNoti.routing()) - assertEquals(wishId, ownerNoti.wishId) - assertEquals(ItemParsingCompletedHandler.WISH_COMPLETION_MESSAGE, ownerNoti.body) + itemParsingService.failLatest(entityManager, wish.slot) + val event = lastEvent() - val adderNoti = notificationRepository.findPage(adder, cursor = null, limit = 10).first() - assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), adderNoti.routing()) - assertEquals(ItemParsingCompletedHandler.TOURNAMENT_COMPLETION_MESSAGE, adderNoti.body) + assertFalse(event.refresh) + assertEquals(setOf(owner), parsingFailedHandler.resolveRecipients(event)) + assertTrue(refreshFailedHandler.resolveRecipients(event).isEmpty()) + assertEquals(NotificationRouting.Wish(wish.getId()), parsingFailedHandler.resolveRouting(event)) + assertEquals(wish.getId(), parsingFailedHandler.resolveRefId(event)) } - // ── 해소 통지(#1028): 남의 성공이 내 실패·미완을 풀었을 때 ────────────────────────── - @Test - fun `해소 통지 수신자 - 카드 표시값이 FAILED·INCOMPLETE 였다가 이 버전으로 채워지는 사람들이고, 이 파싱을 기다린 사람과 배타적이다`() { - // 같은 링크는 한 item 을 공유하므로(#825) 남이 성공시키면 멈춰 있던 카드가 표시값 파생으로 채워진다. - // 판정은 카드 표시값 규칙(ItemVersions.recovers) 그대로다 — 이 버전을 기다리면 완료 알림, 화면이 미완성이었다가 - // 이 버전으로 채워지면 해소 통지. - val itemId = 4001L - val failedOwner = UUID.randomUUID() - val incompleteOwner = UUID.randomUUID() - val waitingOwner = UUID.randomUUID() - // 멈춰 있던 버전들이 먼저, 성공 버전이 나중이다(id 순서가 곧 시간). 성공 버전이 채우던 순간의 상태로 판정한다. - wishRepository.save(Wish(failedOwner, snapshotWithStatus(itemId, ItemStatus.FAILED), itemId)) - wishRepository.save(Wish(incompleteOwner, snapshotWithStatus(itemId, ItemStatus.INCOMPLETE, name = "나이키"), itemId)) - val succeeded = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - wishRepository.save(Wish(waitingOwner, succeeded, itemId)) - - val event = ItemParsingCompleted(itemId, succeeded) - - assertEquals(setOf(failedOwner, incompleteOwner), parsingRecoveredHandler.resolveRecipients(event)) - assertEquals(setOf(waitingOwner), parsingCompletedHandler.resolveRecipients(event)) - } + fun `새로고침 파싱 실패는 새로고침 실패로만 가고 등록 실패와 배타적이다`() { + val owner = UUID.randomUUID() + val wish = registerWish(owner, newUrl()) + itemParsingService.completeLatest(entityManager, wish.slot, product("나이키")) + wishPersistenceService.refresh(RefreshWishItemCommand(owner, wish.getId())) - @Test - fun `해소 통지 수신자 - 진행 중이거나 옛 READY 를 가리키는 사람은 제외된다 (negative control)`() { - // 옛 READY 는 흔한 실제 케이스다 — 남이 새로고침해 새 성공본을 만들면 나는 옛 성공본을 기다린 채 남는다. - // 이미 값을 보고 있으니 "해소" 라 부를 것이 없다. 표시값이 미완성이었는지를 안 보면 여기서 깨진다. - // - // 진행 중은 순차 흐름에선 도달 불가능하다(등록·새로고침 둘 다 진행 중이 있으면 합류하므로 item 당 하나로 - // 수렴하고, 그 하나가 성공한 것이 이 이벤트다). 새로고침이 item 행 락을 안 잡아 생기는 경합 창만 남아, - // 여기선 그 창에서도 상태 화이트리스트가 버티는지를 함께 못 박는다. - val itemId = 4002L - val succeeded = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - wishRepository.save(Wish(UUID.randomUUID(), snapshotIdFor(itemId), itemId)) - wishRepository.save(Wish(UUID.randomUUID(), snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키"), itemId)) + itemParsingService.failLatest(entityManager, wish.slot) + val event = lastEvent() - assertTrue(parsingRecoveredHandler.resolveRecipients(ItemParsingCompleted(itemId, succeeded)).isEmpty()) + assertTrue(event.refresh) + assertEquals(setOf(owner), refreshFailedHandler.resolveRecipients(event)) + assertTrue(parsingFailedHandler.resolveRecipients(event).isEmpty()) + assertEquals(NotificationRouting.Wish(wish.getId()), refreshFailedHandler.resolveRouting(event)) } @Test - fun `해소 통지 수신자 - 기다리는 행이 FAILED 라도 내 수기값이 카드에 떠 있던 사람은 제외된다 (표시값 규칙)`() { - // 기다리는 행의 상태만 보던 옛 규칙과 갈리는 케이스 — 수기로 고쳐 둔 카드는 비어 있던 적이 없으니 "해소" 가 아니다. - val itemId = 4004L - val fixedOwner = UUID.randomUUID() + fun `해소 통지는 미완이던 같은 아이템의 다른 카드 주인에게 각자 카드 좌표로 가고 요청한 카드는 빠진다`() { + val url = newUrl() val stuckOwner = UUID.randomUUID() - // 수기·FAILED 가 succeeded 보다 먼저 있어야 "채워지던 순간" 에 존재한다(id 순서가 곧 시간). - val fixedWaiting = snapshotWithStatus(itemId, ItemStatus.FAILED) - itemSnapshotRepository.save( - ItemSnapshot( - itemId = itemId, - name = "수기", - price = 1_000, - imageUrl = "https://img.example.com/x.png", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - source = ItemSnapshotSource.MANUAL, - createdBy = fixedOwner, - ), - ) - val stuckWaiting = snapshotWithStatus(itemId, ItemStatus.FAILED) - val succeeded = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - wishRepository.save(Wish(fixedOwner, fixedWaiting, itemId)) - wishRepository.save(Wish(stuckOwner, stuckWaiting, itemId)) - - assertEquals(setOf(stuckOwner), parsingRecoveredHandler.resolveRecipients(ItemParsingCompleted(itemId, succeeded))) - } - - @Test - fun `해소 통지 수신자 - 시작된 토너먼트의 출전 카드는 pin 을 박제해 읽으므로 제외되고, 대기실 카드는 내 INCOMPLETE 로 미완성이었으면 포함된다`() { - // 대기실 카드는 pin 이 READY 라도 주인의 더 새 INCOMPLETE(위시 새로고침 결과)가 규칙 2 로 카드를 미완성으로 만든다 — - // 기다리는 행의 상태만 보는 선필터로는 못 잡는 케이스라, 후보를 상태로 좁히지 않는다. - val itemId = 4005L - val pendingAdder = UUID.randomUUID() - val startedAdder = UUID.randomUUID() - val pendingTournamentId = createRootWithOwner(pendingAdder) - val startedTournamentId = createRootWithOwner(startedAdder) - val readyPin = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - tournamentItemRepository.saveAll(listOf(TournamentItem(pendingTournamentId, pendingAdder, readyPin))) - tournamentItemRepository.saveAll(listOf(TournamentItem(startedTournamentId, startedAdder, snapshotWithStatus(itemId, ItemStatus.FAILED)))) - tournamentRepository.findTournamentById(startedTournamentId)!!.let { it.start(); tournamentRepository.saveTournament(it) } - // pendingAdder 의 위시 새로고침이 INCOMPLETE 로 끝난 상황 — pin 보다 새로운 내 INCOMPLETE 라 대기실 카드가 미완성이 된다. - itemSnapshotRepository.save( - ItemSnapshot( - itemId = itemId, - name = "일부만", - status = ItemStatus.INCOMPLETE, - extractedAt = LocalDateTime.now(), - source = ItemSnapshotSource.SERVER, - createdBy = pendingAdder, + val requesterOwner = UUID.randomUUID() + val host = UUID.randomUUID() + val adder = UUID.randomUUID() + val stuckWish = registerWish(stuckOwner, url) + val requesterWish = registerWish(requesterOwner, url) + itemParsingService.completeLatest(entityManager, stuckWish.slot, product(name = "나이키", price = null)) + val tournamentId = createRootWithOwner(host) + tournamentUserRepository.save(TournamentUser(tournamentId, adder)) + val tournamentItemId = addTournamentItem(adder, tournamentId, url) + itemParsingService.completeLatest(entityManager, ItemSlot.tournamentItem(tournamentItemId), product(name = "나이키", price = null)) + + itemParsingService.completeLatest(entityManager, requesterWish.slot, product("나이키")) + val event = lastEvent() + + // 요청한 카드도 직전 표시값이 남의 미완이라 recovers 는 참이다. 완료 알림과 겹치지 않게 빠져야 한다 + val recipients = parsingRecoveredHandler.resolveRecipients(event) + assertEquals(setOf(stuckOwner, adder), recipients) + val routings = parsingRecoveredHandler.resolveRecipientContexts(event, recipients).mapValues { it.value.routing } + assertEquals( + mapOf( + stuckOwner to NotificationRouting.Wish(stuckWish.getId()), + adder to NotificationRouting.Tournament(tournamentId, tournamentItemId), ), + routings, ) - val succeeded = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - - assertEquals(setOf(pendingAdder), parsingRecoveredHandler.resolveRecipients(ItemParsingCompleted(itemId, succeeded))) } @Test - fun `해소 통지 라우팅 - 위시 주인은 자기 wishId 를 실은 WISH, 토너먼트 등록자는 자기 출전 좌표를 받는다`() { - val itemId = 4003L - val wishOwner = UUID.randomUUID() - val adder = UUID.randomUUID() - val tournamentId = createRootWithOwner(adder) - val wishId = wishRepository.save(Wish(wishOwner, snapshotWithStatus(itemId, ItemStatus.FAILED), itemId)).getId() - val tournamentItemId = - tournamentItemRepository - .saveAll(listOf(TournamentItem(tournamentId, adder, snapshotWithStatus(itemId, ItemStatus.FAILED)))) - .first() - .getId() - val succeeded = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - - val contexts = - parsingRecoveredHandler.resolveRecipientContexts(ItemParsingCompleted(itemId, succeeded), setOf(wishOwner, adder)) - - assertEquals(NotificationRouting.Wish(wishId), contexts.getValue(wishOwner).routing) - assertEquals(NotificationRouting.Tournament(tournamentId, tournamentItemId), contexts.getValue(adder).routing) - } - - @Test - fun `dispatch 가 한 파싱 완료 이벤트를 완료 알림과 해소 통지로 갈라 저장한다 - end-to-end`() { - // 핸들러를 이벤트당 하나만 두던 이전 dispatcher(associateBy)에선 둘 중 한 핸들러가 통째로 사라져 - // 한쪽 수신자가 알림을 아예 못 받는다 — 이 단언이 반드시 깨진다. - val itemId = 4100L - val waitingOwner = UUID.randomUUID() + fun `dispatch 가 한 파싱 완료 이벤트를 요청자 완료 알림과 다른 카드 해소 통지로 갈라 저장한다 - end-to-end`() { + val url = newUrl() val stuckOwner = UUID.randomUUID() - wishRepository.save(Wish(stuckOwner, snapshotWithStatus(itemId, ItemStatus.FAILED), itemId)) - val succeeded = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - wishRepository.save(Wish(waitingOwner, succeeded, itemId)) - - notificationDispatcher.dispatch(ItemParsingCompleted(itemId, succeeded)) + val requesterOwner = UUID.randomUUID() + val stuckWish = registerWish(stuckOwner, url) + val requesterWish = registerWish(requesterOwner, url) + itemParsingService.completeLatest(entityManager, stuckWish.slot, product(name = "나이키", price = null)) + itemParsingService.completeLatest(entityManager, requesterWish.slot, product("나이키")) - val waiting = notificationRepository.findPage(waitingOwner, cursor = null, limit = 10) - assertEquals(listOf(NotificationType.ITEM_PARSING_COMPLETED), waiting.map { it.type }) - val stuck = notificationRepository.findPage(stuckOwner, cursor = null, limit = 10) - assertEquals(listOf(NotificationType.ITEM_PARSING_RECOVERED), stuck.map { it.type }) - // 제목은 방금 성공한 버전의 이름 — 수신자가 지금 카드에서 보게 되는 그 값이다. - assertEquals("나이키", stuck.first().title) - } - - // ── 새로고침 알림(#1036): 등록과 갱신을 타입으로 가른다 ────────────────────────── - - @Test - fun `새로고침 수신자 - 버전보다 먼저 있던 위시 주인이고, 그 버전으로 등록한 사람과 배타적이다`() { - // 등록/새로고침을 적는 컬럼은 없다. 위시는 늘 이미 있는 버전을 가리키며 태어나므로 위시가 버전보다 먼저라는 - // 사실 자체가 "생성 후 포인터를 이 버전으로 스왑했다" 를 뜻하고, 그 스왑은 새로고침뿐이다. - val itemId = 5001L - val refresher = UUID.randomUUID() - val registrant = UUID.randomUUID() - val refresherWish = wishRepository.save(Wish(refresher, readyVersion(itemId), itemId)) - val newVersion = snapshotIdFor(itemId) - refresherWish.waitFor(newVersion) - wishRepository.save(Wish(registrant, newVersion, itemId)) - - val event = ItemParsingCompleted(itemId, newVersion) - - assertEquals(setOf(refresher), refreshCompletedHandler.resolveRecipients(event)) - assertEquals(setOf(registrant), parsingCompletedHandler.resolveRecipients(event)) - } + notificationDispatcher.dispatch(lastEvent()) - @Test - fun `새로고침 실패 수신자도 같은 규칙이고 등록 실패 알림과 배타적이다`() { - val itemId = 5002L - val refresher = UUID.randomUUID() - val registrant = UUID.randomUUID() - val refresherWish = wishRepository.save(Wish(refresher, readyVersion(itemId), itemId)) - val newVersion = snapshotIdFor(itemId) - refresherWish.waitFor(newVersion) - wishRepository.save(Wish(registrant, newVersion, itemId)) - - val event = ItemParsingFailed(itemId, newVersion) - - assertEquals(setOf(refresher), refreshFailedHandler.resolveRecipients(event)) - assertEquals(setOf(registrant), parsingFailedHandler.resolveRecipients(event)) - } - - @Test - fun `새로고침 수신자 - 남의 진행 중 버전에 합류한 새로고침도 새로고침이고, 합류한 등록은 등록이다 (#826 negative control)`() { - // 진행 중 합류는 두 방향이 있다. 새로고침이 남의 진행 중 버전으로 스왑하면 위시가 그 버전보다 먼저라 새로고침, - // 등록이 남의 진행 중 버전에 붙으면 위시가 그 버전보다 뒤라 등록이다. 버전을 누가 만들었는지는 판정에 안 끼어든다. - val itemId = 5003L - val refresher = UUID.randomUUID() - val joiner = UUID.randomUUID() - val refresherWish = wishRepository.save(Wish(refresher, readyVersion(itemId), itemId)) - val othersInProgress = snapshotIdFor(itemId) - refresherWish.waitFor(othersInProgress) - wishRepository.save(Wish(joiner, othersInProgress, itemId)) - - val event = ItemParsingCompleted(itemId, othersInProgress) - - assertEquals(setOf(refresher), refreshCompletedHandler.resolveRecipients(event)) - assertEquals(setOf(joiner), parsingCompletedHandler.resolveRecipients(event)) - } - - @Test - fun `새로고침 수신자 - 토너먼트 등록자는 새로고침이 없어 등록 완료 쪽에 남는다`() { - val itemId = 5004L - val adder = UUID.randomUUID() - val snapshotId = snapshotIdFor(itemId) - tournamentItemRepository.saveAll(listOf(TournamentItem(1500L, adder, snapshotId))) - - val event = ItemParsingCompleted(itemId, snapshotId) - - assertTrue(refreshCompletedHandler.resolveRecipients(event).isEmpty()) - assertEquals(setOf(adder), parsingCompletedHandler.resolveRecipients(event)) - } - - @Test - fun `새로고침 라우팅 - 자기 wishId 를 실은 WISH 다`() { - val itemId = 5005L - val refresher = UUID.randomUUID() - val refresherWish = wishRepository.save(Wish(refresher, readyVersion(itemId), itemId)) - val newVersion = snapshotIdFor(itemId) - refresherWish.waitFor(newVersion) - - val completed = - refreshCompletedHandler.resolveRecipientContexts(ItemParsingCompleted(itemId, newVersion), setOf(refresher)) - val failed = - refreshFailedHandler.resolveRecipientContexts(ItemParsingFailed(itemId, newVersion), setOf(refresher)) - - assertEquals(NotificationRouting.Wish(refresherWish.getId()), completed.getValue(refresher).routing) - assertEquals(NotificationRouting.Wish(refresherWish.getId()), failed.getValue(refresher).routing) - // 새로고침 알림은 출처별 문구가 없다 — 수신자별 변수를 싣지 않는다(문구는 템플릿이 소유). - assertTrue(completed.getValue(refresher).variables.isEmpty()) - assertTrue(failed.getValue(refresher).variables.isEmpty()) - } - - @Test - fun `dispatch 가 한 파싱 완료 이벤트를 새로고침 완료와 등록 완료로 갈라 저장한다 - end-to-end`() { - // 새로고침을 등록 완료 핸들러가 그대로 삼키던 이전 구조에선 새로고침한 사람이 ITEM_PARSING_COMPLETED 를 받아 - // 이 단언이 반드시 깨진다. - val itemId = 5100L - val refresher = UUID.randomUUID() - val registrant = UUID.randomUUID() - val refresherWish = wishRepository.save(Wish(refresher, readyVersion(itemId, name = "옛 이름"), itemId)) - val newVersion = snapshotWithStatus(itemId, ItemStatus.READY, name = "나이키") - refresherWish.waitFor(newVersion) - val registrantWishId = wishRepository.save(Wish(registrant, newVersion, itemId)).getId() - - notificationDispatcher.dispatch(ItemParsingCompleted(itemId, newVersion)) - - val refreshed = notificationRepository.findPage(refresher, cursor = null, limit = 10) - assertEquals(listOf(NotificationType.ITEM_REFRESH_COMPLETED), refreshed.map { it.type }) - assertEquals(NotificationRouting.Wish(refresherWish.getId()), refreshed.first().routing()) - // 제목은 새로고침으로 방금 성공한 버전의 이름 — 새로고침된 카드에서 보게 되는 그 값이다. - assertEquals("나이키", refreshed.first().title) - - val registered = notificationRepository.findPage(registrant, cursor = null, limit = 10) - assertEquals(listOf(NotificationType.ITEM_PARSING_COMPLETED), registered.map { it.type }) - assertEquals(NotificationRouting.Wish(registrantWishId), registered.first().routing()) - } - - @Test - fun `dispatch 가 한 파싱 실패 이벤트를 새로고침 실패와 등록 실패로 갈라 저장한다 - end-to-end`() { - val itemId = 5101L - val refresher = UUID.randomUUID() - val registrant = UUID.randomUUID() - val refresherWish = wishRepository.save(Wish(refresher, readyVersion(itemId), itemId)) - val newVersion = snapshotWithStatus(itemId, ItemStatus.FAILED) - refresherWish.waitFor(newVersion) - wishRepository.save(Wish(registrant, newVersion, itemId)) - - notificationDispatcher.dispatch(ItemParsingFailed(itemId, newVersion)) - - val refreshed = notificationRepository.findPage(refresher, cursor = null, limit = 10) - assertEquals(listOf(NotificationType.ITEM_REFRESH_FAILED), refreshed.map { it.type }) - assertEquals(NotificationRouting.Wish(refresherWish.getId()), refreshed.first().routing()) - val registered = notificationRepository.findPage(registrant, cursor = null, limit = 10) - assertEquals(listOf(NotificationType.ITEM_PARSING_FAILED), registered.map { it.type }) + val completed = notificationRepository.findPage(requesterOwner, cursor = null, limit = 10) + assertEquals(listOf(NotificationType.ITEM_PARSING_COMPLETED), completed.map { it.type }) + assertEquals(NotificationRouting.Wish(requesterWish.getId()), completed.first().routing()) + assertEquals(ItemParsingCompletedHandler.WISH_COMPLETION_MESSAGE, completed.first().body) + val recovered = notificationRepository.findPage(stuckOwner, cursor = null, limit = 10) + assertEquals(listOf(NotificationType.ITEM_PARSING_RECOVERED), recovered.map { it.type }) + assertEquals(NotificationRouting.Wish(stuckWish.getId()), recovered.first().routing()) + assertEquals("나이키", recovered.first().title) } // ── 신규 토너먼트 알림(#473): 플레이링크 플레이 · 완료 · 결과 ────────────────────────── @@ -853,26 +590,34 @@ class NotificationRecipientResolutionIntegrationTest : IntegrationTestSupport() tournamentRepository.saveTournament(root) return root.getId() } + private fun registerWish( + userId: UUID, + url: String, + ): Wish { + userRepository.findById(userId) ?: userRepository.save(User(id = userId, nickname = "담은이${userId.toString().take(5)}", profileImage = "https://x/p.png", identityType = IdentityType.MEMBER)) + return wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(url))).wish + } + + private fun addTournamentItem( + userId: UUID, + tournamentId: Long, + url: String, + ): Long = tournamentItemPersistenceService.persistLinkItem(userId, tournamentId, ProductLink.parse(url)) + + // 다른 테스트의 아이템과 겹치지 않게 매번 새 URL + private fun newUrl(): String = "https://shop.example.com/products/${UUID.randomUUID()}" + + private fun product( + name: String, + price: Int? = 99_000, + ): ProductSnapshot = ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png") + + private fun landedOn( + finalUrl: String, + name: String, + ): ProductSnapshot = product(name).copy(finalUrl = ProductLink.parse(finalUrl)) - // 알림 역조회는 wish/tournament_item→item_snapshots 를 snapshot_id 로 조인해 s.item_id 로 매칭한다. - // 따라서 그 itemId 로 시딩한 snapshot 의 id 를 wish/tournament_item 의 snapshotId 로 넘겨야 역조회가 맞아떨어진다. - private fun snapshotIdFor(itemId: Long): Long = - itemSnapshotRepository - .save(ItemSnapshot.pending(itemId, requestedBy = UUID.randomUUID())) - .getId() - - // 해소 통지(#1028)는 카드 표시값이 미완성이었는지로 수신자를 가르므로, 상태를 지정해 버전을 깐다. - private fun snapshotWithStatus( - itemId: Long, - status: ItemStatus, - name: String? = null, - ): Long = itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, name = name, status = status)).getId() - - // 새로고침 알림(#1036) fixture — 새로고침은 성공(READY) 항목에서만 시작되므로, 새로고침 전 위시가 가리키던 버전이다. - private fun readyVersion( - itemId: Long, - name: String = "나이키", - ): Long = snapshotWithStatus(itemId, ItemStatus.READY, name = name) + private inline fun lastEvent(): E = applicationEvents.stream(E::class.java).toList().last() private var inviteSeq = 0 diff --git a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationLiveAutoReadIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationLiveAutoReadIntegrationTest.kt index 5f95b0f6d..b406947d5 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationLiveAutoReadIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationLiveAutoReadIntegrationTest.kt @@ -1,6 +1,5 @@ package com.depromeet.piki.notification.service -import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.event.ItemParsingCompleted import com.depromeet.piki.item.event.ItemParsingFailed import com.depromeet.piki.item.repository.ItemSnapshotRepository @@ -8,6 +7,7 @@ import com.depromeet.piki.notification.repository.NotificationRepository import com.depromeet.piki.notification.sse.SseConnection import com.depromeet.piki.notification.sse.SseEmitterRegistry import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.tournament.domain.TournamentUser import com.depromeet.piki.tournament.event.TournamentItemAdded import com.depromeet.piki.tournament.repository.TournamentUserRepository @@ -50,12 +50,12 @@ class NotificationLiveAutoReadIntegrationTest : IntegrationTestSupport() { fun `인앱(SSE 연결) 유저의 파싱 완료 알림은 자동 읽음으로 저장된다`() { val userId = UUID.randomUUID() val itemId = 8101L - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, name = "나이키")).getId() - wishRepository.save(Wish(userId, snapshotId, itemId)) + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = itemId, name = "나이키")).getId() + val slot = wishRepository.save(Wish(userId, itemId)).slot val emitter = SseEmitter() sseEmitterRegistry.register(SseConnection(userId, emitter)) try { - notificationDispatcher.dispatch(ItemParsingCompleted(itemId, snapshotId)) + notificationDispatcher.dispatch(ItemParsingCompleted(slot, userId, itemId, snapshotId, refresh = false)) val saved = notificationRepository.findPage(userId, cursor = null, limit = 10) assertEquals(1, saved.size) @@ -70,13 +70,11 @@ class NotificationLiveAutoReadIntegrationTest : IntegrationTestSupport() { fun `인앱(SSE 연결) 유저의 파싱 실패 알림도 자동 읽음으로 저장된다`() { // 파싱 실패는 별도 이벤트(ItemParsingFailed)·별도 핸들러라, 완료와 무관하게 자동읽음 경로를 따로 가드한다. val userId = UUID.randomUUID() - val itemId = 8103L - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, name = "나이키")).getId() - wishRepository.save(Wish(userId, snapshotId, itemId)) + val slot = wishRepository.save(Wish(userId, itemId = null)).slot val emitter = SseEmitter() sseEmitterRegistry.register(SseConnection(userId, emitter)) try { - notificationDispatcher.dispatch(ItemParsingFailed(itemId, snapshotId)) + notificationDispatcher.dispatch(ItemParsingFailed(slot, userId, refresh = false)) val saved = notificationRepository.findPage(userId, cursor = null, limit = 10) assertEquals(1, saved.size) @@ -93,12 +91,12 @@ class NotificationLiveAutoReadIntegrationTest : IntegrationTestSupport() { // 여기선 이미 완료된 emitter 로 write 실패를 재현한다. val userId = UUID.randomUUID() val itemId = 8104L - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, name = "나이키")).getId() - wishRepository.save(Wish(userId, snapshotId, itemId)) + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = itemId, name = "나이키")).getId() + val slot = wishRepository.save(Wish(userId, itemId)).slot val deadEmitter = SseEmitter().apply { complete() } sseEmitterRegistry.register(SseConnection(userId, deadEmitter)) try { - notificationDispatcher.dispatch(ItemParsingCompleted(itemId, snapshotId)) + notificationDispatcher.dispatch(ItemParsingCompleted(slot, userId, itemId, snapshotId, refresh = false)) val saved = notificationRepository.findPage(userId, cursor = null, limit = 10) assertEquals(1, saved.size) @@ -112,10 +110,10 @@ class NotificationLiveAutoReadIntegrationTest : IntegrationTestSupport() { fun `연결 없는 유저의 파싱 완료 알림은 안읽음으로 저장된다`() { val userId = UUID.randomUUID() val itemId = 8102L - val snapshotId = itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, name = "나이키")).getId() - wishRepository.save(Wish(userId, snapshotId, itemId)) + val snapshotId = itemSnapshotRepository.save(itemVersion(itemId = itemId, name = "나이키")).getId() + val slot = wishRepository.save(Wish(userId, itemId)).slot - notificationDispatcher.dispatch(ItemParsingCompleted(itemId, snapshotId)) + notificationDispatcher.dispatch(ItemParsingCompleted(slot, userId, itemId, snapshotId, refresh = false)) val saved = notificationRepository.findPage(userId, cursor = null, limit = 10) assertEquals(1, saved.size) diff --git a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationPushPolicySeedIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationPushPolicySeedIntegrationTest.kt index b3eb7d52c..24324a2ec 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationPushPolicySeedIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationPushPolicySeedIntegrationTest.kt @@ -37,6 +37,8 @@ class NotificationPushPolicySeedIntegrationTest : IntegrationTestSupport() { // 실패로 멈춰 있던 카드가 남의 성공으로 채워진 해소 통지(#1028) — 낡은 실패 알림을 이미 받아 둔 // 사람에게 가므로, 앱이 닫혀 있어도 알려야 어긋남이 그대로 남지 않는다. NotificationType.ITEM_PARSING_RECOVERED to true, + // 등록한 카드가 사라지므로 앱이 닫혀 있어도 이유를 알린다 + NotificationType.ITEM_PARSING_MERGED to true, // 새로고침 결과(#1036)는 등록 완료·실패와 같은 결 — 사용자가 직접 누른 재추출의 끝을 앱이 닫혀 있어도 알린다. NotificationType.ITEM_REFRESH_COMPLETED to true, NotificationType.ITEM_REFRESH_FAILED to true, diff --git a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationTemplateRendererTest.kt b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationTemplateRendererTest.kt index 925e43a43..108bfb335 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationTemplateRendererTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationTemplateRendererTest.kt @@ -8,8 +8,8 @@ class NotificationTemplateRendererTest { @Test fun `플레이스홀더를 변수 값으로 치환한다`() { - val result = renderer.render("\${actorName}님이 아이템을 추가했어요", mapOf("actorName" to "철수")) - assertEquals("철수님이 아이템을 추가했어요", result) + val result = renderer.render("\${actorName}님이 토너먼트 후보를 담았어요", mapOf("actorName" to "철수")) + assertEquals("철수님이 토너먼트 후보를 담았어요", result) } @Test diff --git a/src/test/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseIntegrationTest.kt index 8d3cc7a08..89f9a554b 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseIntegrationTest.kt @@ -1,14 +1,17 @@ package com.depromeet.piki.notification.sse -import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.domain.ItemStatus -import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.item.event.ItemSlotMerged +import com.depromeet.piki.notification.controller.dto.TournamentItemMerged import com.depromeet.piki.notification.controller.dto.TournamentItemParsed import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.tournament.domain.TournamentItem import com.depromeet.piki.tournament.domain.TournamentUser import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository +import com.depromeet.piki.wishlist.domain.Wish +import com.depromeet.piki.wishlist.repository.WishRepository import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.transaction.annotation.Transactional @@ -19,10 +22,8 @@ import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals import kotlin.test.assertTrue -// 토너먼트 출전 아이템의 파싱 완료/실패 → 참여자 SSE 조용한 갱신(silent-sync, type=TOURNAMENT_ITEM_PARSED)을 실제 빈으로 검증한다. -// 라우팅 키는 snapshotId(버전)다(#576) — 브로드캐스터의 수신자·좌표 도출은 DB 역조회(tournament_item pin, tournament_user)에 의존하므로 통합으로 검증한다. -// 영속 fixture 는 @Transactional 자동 롤백. SseEmitterRegistry 는 인메모리 싱글톤이라 롤백 대상이 아니므로, -// 각 테스트가 랜덤 userId 로 등록하고 finally 에서 자기 emitter 를 정리한다(NotificationSseIntegrationTest 와 동일 규약). +// 수신자·좌표를 요청한 출전 행과 참여자 행에서 도출하므로 통합으로 검증한다. +// SseEmitterRegistry 는 인메모리 싱글톤이라 롤백되지 않아 각 테스트가 finally 에서 자기 emitter 를 정리한다 @Transactional class TournamentItemParsedSseIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var broadcaster: TournamentItemParsedSseBroadcaster @@ -33,61 +34,53 @@ class TournamentItemParsedSseIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var tournamentUserRepository: TournamentUserRepository - @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository + @Autowired private lateinit var wishRepository: WishRepository @Autowired private lateinit var objectMapper: ObjectMapper @Test fun `파싱 완료 시 그 토너먼트 참여자 전원이 출전 좌표와 status=READY 를 받고 비참여자는 못 받는다`() { - val itemId = 5001L val tournamentId = 7001L val adder = UUID.randomUUID() val participant = UUID.randomUUID() val outsider = UUID.randomUUID() listOf(adder, participant).forEach { tournamentUserRepository.save(TournamentUser(tournamentId, it)) } - val snapshotId = snapshotIdFor(itemId) - val tournamentItemId = - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, adder, snapshotId))).first().getId() + val tournamentItemId = tournamentItemRepository.save(TournamentItem(tournamentId, adder, itemId = null)).getId() val adderEmitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(adder, it)) } val participantEmitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(participant, it)) } val outsiderEmitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(outsider, it)) } try { - broadcaster.broadcast(snapshotId, ItemStatus.READY) + broadcaster.broadcast(ItemSlot.tournamentItem(tournamentItemId), ItemStatus.READY) - // 참여자 전원(올린 본인 adder 포함)이 그 토너먼트 좌표 + READY 를 받는다. listOf(adderEmitter, participantEmitter).forEach { emitter -> val payload = emitter.payloads().single() assertEquals(tournamentId, payload.tournamentId) assertEquals(tournamentItemId, payload.tournamentItemId) assertEquals(ItemStatus.READY, payload.status) assertTrue(emitter.sentData.any { it is String && it.contains("event:silent-sync") }) - // wire 직렬화 contract — type 판별자가 실제 JSON 에 실리는지(클라가 type 으로 분기하므로 누락되면 무라우팅). + // 클라가 type 으로 분기하므로 판별자가 실제 JSON 에 실리는지 본다 val node = objectMapper.readTree(objectMapper.writeValueAsString(payload)) assertEquals("TOURNAMENT_ITEM_PARSED", node.get("type").asString()) assertEquals(ItemStatus.READY.name, node.get("status").asString()) } - // 그 토너먼트 비참여자에겐 가지 않는다. assertTrue(outsiderEmitter.payloads().isEmpty()) } finally { - listOf(adder to adderEmitter, participant to participantEmitter, outsider to outsiderEmitter) - .forEach { (userId, _) -> registry.removeAll(userId) } + listOf(adder, participant, outsider).forEach { registry.removeAll(it) } } } @Test fun `파싱 실패 시 참여자는 status=FAILED 로 받는다`() { - val itemId = 5002L val tournamentId = 7002L val participant = UUID.randomUUID() tournamentUserRepository.save(TournamentUser(tournamentId, participant)) - val snapshotId = snapshotIdFor(itemId) - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentId, participant, snapshotId))) + val tournamentItemId = tournamentItemRepository.save(TournamentItem(tournamentId, participant, itemId = null)).getId() val emitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(participant, it)) } try { - broadcaster.broadcast(snapshotId, ItemStatus.FAILED) + broadcaster.broadcast(ItemSlot.tournamentItem(tournamentItemId), ItemStatus.FAILED) assertEquals(ItemStatus.FAILED, emitter.payloads().single().status) } finally { @@ -96,90 +89,78 @@ class TournamentItemParsedSseIntegrationTest : IntegrationTestSupport() { } @Test - fun `위시로만 담긴(어느 토너먼트에도 없는) 아이템이면 아무 emitter 에도 보내지 않는다`() { - // 이 버전을 pin 한 출전(tournament_item)이 없으면 findRoutingBySnapshotId 가 비어 broadcast 가 early return 한다. - val itemId = 5003L - val snapshotId = snapshotIdFor(itemId) // snapshot 만 있고 토너먼트엔 안 올림(위시 전용 상황 시뮬레이션) - val someUser = UUID.randomUUID() - val emitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(someUser, it)) } + fun `후보가 같은 상품의 다른 후보에 합쳐지면 참여자 전원이 사라진 후보와 남은 후보 좌표를 받는다`() { + val tournamentId = 7006L + val adder = UUID.randomUUID() + val participant = UUID.randomUUID() + listOf(adder, participant).forEach { tournamentUserRepository.save(TournamentUser(tournamentId, it)) } + val holderId = tournamentItemRepository.save(TournamentItem(tournamentId, participant, itemId = null)).getId() + val mergedId = tournamentItemRepository.save(TournamentItem(tournamentId, adder, itemId = null)).getId() + val emitters = listOf(adder, participant).map { user -> ItemParsedRecordingEmitter().also { registry.register(SseConnection(user, it)) } } try { - broadcaster.broadcast(snapshotId, ItemStatus.READY) + broadcaster.broadcastMerged(ItemSlotMerged(ItemSlot.tournamentItem(mergedId), ItemSlot.tournamentItem(holderId), adder, holderOwner = participant, itemId = 5006L)) - assertTrue(emitter.payloads().isEmpty()) + emitters.forEach { emitter -> + val payload = emitter.sentData.filterIsInstance().single() + assertEquals(TournamentItemMerged(tournamentId, tournamentItemId = mergedId, mergedIntoTournamentItemId = holderId), payload) + assertEquals("TOURNAMENT_ITEM_MERGED", objectMapper.readTree(objectMapper.writeValueAsString(payload)).get("type").asString()) + } } finally { - registry.removeAll(someUser) + listOf(adder, participant).forEach { registry.removeAll(it) } } } @Test - fun `한 아이템이 여러 토너먼트에 출전 중이면 각 토너먼트 참여자가 각자 그 토너먼트 좌표로 받는다`() { - val itemId = 5004L - val snapshotId = snapshotIdFor(itemId) - val tournamentA = 7004L - val tournamentB = 7005L - val userA = UUID.randomUUID() - val userB = UUID.randomUUID() - tournamentUserRepository.save(TournamentUser(tournamentA, userA)) - tournamentUserRepository.save(TournamentUser(tournamentB, userB)) - // 같은 버전(snapshot)을 두 토너먼트가 pin 한다(공유 #825 의 세계) → 같은 사실이므로 두 좌표 모두 받는 것이 맞다. - val itemIdA = tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentA, userA, snapshotId))).first().getId() - val itemIdB = tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentB, userB, snapshotId))).first().getId() - val emitterA = ItemParsedRecordingEmitter().also { registry.register(SseConnection(userA, it)) } - val emitterB = ItemParsedRecordingEmitter().also { registry.register(SseConnection(userB, it)) } + fun `위시가 요청한 파싱이면 같은 아이템이 토너먼트에 출전 중이어도 아무 emitter 에도 보내지 않는다`() { + val itemId = 5003L + val tournamentId = 7003L + val owner = UUID.randomUUID() + val participant = UUID.randomUUID() + tournamentUserRepository.save(TournamentUser(tournamentId, participant)) + tournamentItemRepository.save(TournamentItem(tournamentId, participant, itemId)) + val wishSlot = wishRepository.save(Wish(owner, itemId)).slot + val ownerEmitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(owner, it)) } + val participantEmitter = ItemParsedRecordingEmitter().also { registry.register(SseConnection(participant, it)) } try { - broadcaster.broadcast(snapshotId, ItemStatus.READY) - - val payloadA = emitterA.payloads().single() - assertEquals(tournamentA, payloadA.tournamentId) - assertEquals(itemIdA, payloadA.tournamentItemId) + broadcaster.broadcast(wishSlot, ItemStatus.READY) - val payloadB = emitterB.payloads().single() - assertEquals(tournamentB, payloadB.tournamentId) - assertEquals(itemIdB, payloadB.tournamentItemId) + assertTrue(ownerEmitter.payloads().isEmpty()) + assertTrue(participantEmitter.payloads().isEmpty()) } finally { - registry.removeAll(userA) - registry.removeAll(userB) + listOf(owner, participant).forEach { registry.removeAll(it) } } } @Test - fun `같은 item 의 다른 버전을 pin 한 카드는 받지 않는다 - 버전 라우팅의 spurious 차단`() { - // #576 의 핵심 계약: 파싱 사실은 버전의 것이다. 같은 item 이라도 다른 버전(갱신 전 버전 등)을 pin 한 - // 카드에 남의 완료가 전파되면 "아직 안 끝난 카드가 완료로 갱신"되는 오염이 된다. - val itemId = 5005L - val versionOne = snapshotIdFor(itemId) - val versionTwo = snapshotIdFor(itemId) - val tournamentA = 7006L - val tournamentB = 7007L + fun `같은 아이템이 다른 토너먼트에도 출전 중이면 요청한 출전의 토너먼트 참여자만 받는다`() { + val itemId = 5004L + val tournamentA = 7004L + val tournamentB = 7005L val userA = UUID.randomUUID() val userB = UUID.randomUUID() tournamentUserRepository.save(TournamentUser(tournamentA, userA)) tournamentUserRepository.save(TournamentUser(tournamentB, userB)) - tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentA, userA, versionOne))) - val cardB = tournamentItemRepository.saveAll(listOf(TournamentItem(tournamentB, userB, versionTwo))).first().getId() + val cardA = tournamentItemRepository.save(TournamentItem(tournamentA, userA, itemId)).getId() + tournamentItemRepository.save(TournamentItem(tournamentB, userB, itemId)) val emitterA = ItemParsedRecordingEmitter().also { registry.register(SseConnection(userA, it)) } val emitterB = ItemParsedRecordingEmitter().also { registry.register(SseConnection(userB, it)) } try { - broadcaster.broadcast(versionTwo, ItemStatus.READY) + broadcaster.broadcast(ItemSlot.tournamentItem(cardA), ItemStatus.READY) - // versionTwo 를 pin 한 카드만 받는다. - assertEquals(cardB, emitterB.payloads().single().tournamentItemId) - assertTrue(emitterA.payloads().isEmpty()) + val payloadA = emitterA.payloads().single() + assertEquals(tournamentA, payloadA.tournamentId) + assertEquals(cardA, payloadA.tournamentItemId) + assertTrue(emitterB.payloads().isEmpty()) } finally { - registry.removeAll(userA) - registry.removeAll(userB) + listOf(userA, userB).forEach { registry.removeAll(it) } } } - - // 파싱 중(PENDING) 버전을 하나 시딩한다 — pin(snapshotId)과 브로드캐스트 키가 같은 버전을 가리켜야 라우팅이 맞아떨어진다. - private fun snapshotIdFor(itemId: Long): Long = itemSnapshotRepository.save(ItemSnapshot.pending(itemId, requestedBy = UUID.randomUUID())).getId() } -// send(SseEventBuilder) 를 가로채 실제 IO 없이 전송 내용을 기록한다. build() 가 내놓는 data 항목 -// (메타 라인 문자열 + payload 객체)을 그대로 모아, 테스트가 payload 와 이벤트 name 을 단언할 수 있게 한다. +// send(SseEventBuilder) 를 가로채 실제 IO 없이 build() 의 data 항목(메타 라인 문자열과 payload 객체)을 기록한다 private class ItemParsedRecordingEmitter : SseEmitter() { val sentData = CopyOnWriteArrayList() diff --git a/src/test/kotlin/com/depromeet/piki/product/domain/CanonicalLinkTest.kt b/src/test/kotlin/com/depromeet/piki/product/domain/CanonicalLinkTest.kt deleted file mode 100644 index 1db6d07e5..000000000 --- a/src/test/kotlin/com/depromeet/piki/product/domain/CanonicalLinkTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.depromeet.piki.product.domain - -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -// 정규화는 "어디까지를 같은 상품으로 볼 것인가"의 경계 그 자체라, 몰별 규칙의 실측 근거(2026-07-30)를 -// 여기 케이스로 박제한다 — 규칙을 바꾸려면 이 테스트가 먼저 그 근거를 되묻게 된다. -class CanonicalLinkTest { - private fun canonical(raw: String): CanonicalLink = CanonicalLink.of(ProductLink.parse(raw)) - - // --- override 몰: 쿼리 전부 제거 (상품 번호가 경로에 있음을 실측) --- - - @Test - fun `무신사 onelink 귀결점의 공유자 추적 쿼리가 전부 떨어져 맨 URL 과 같은 정체성이 된다`() { - val dirty = - canonical( - "https://www.musinsa.com/products/6760200?source_caller=sdk&is_retargeting=true" + - "&shortlink=hxnauj24&af_channel=mobile_share&af_referrer_uid=1588832660726-6009526" + - "&pid=af_app_invites&af_referrer_customer_id=jsy0714", - ) - val bare = canonical("https://www.musinsa.com/products/6760200") - assertEquals(bare, dirty) - assertEquals(bare.hash, dirty.hash) - } - - @Test - fun `29cm 의 reward_key 는 denylist 이름이 아니지만 override 라 떨어진다 - 공유자마다 다른 토큰이 정체성을 가르지 못한다`() { - val fromShareA = canonical("https://www.29cm.co.kr/products/3915971?reward_key=RK_AAAA&utm_source=29cm_pdp_share") - val fromShareB = canonical("https://www.29cm.co.kr/products/3915971?reward_key=RK_BBBB") - assertEquals(fromShareA, fromShareB) - } - - @Test - fun `29cm 카테고리 탐색 쿼리가 떨어져 브라우저 복사 URL 과 같은 정체성이 된다`() { - val browsed = - canonical( - "https://www.29cm.co.kr/products/3863006?categoryLargeCode=269100100&categoryMediumCode=269101100&categorySmallCode=", - ) - assertEquals(canonical("https://www.29cm.co.kr/products/3863006"), browsed) - } - - @Test - fun `지그재그 단축 귀결점 경로가 웹 경로로 접혀 클릭마다 바뀌는 쿼리와 무관하게 같은 정체성이 된다`() { - // event_uuid 는 클릭마다, referrer_timestamp 는 밀리초마다 새 값 — 정규화 없이는 같은 링크의 - // 재파싱끼리도 갈라진다(실측). 경로 접기(/p/{id} → /catalog/products/{id})까지 겹쳐 검증한다. - val fromShortlink = - canonical( - "https://zigzag.kr/p/164171173?event_uuid=390edeb9-cc26-414f&referrer_timestamp=1785321370803" + - "&utm_source=sharelink&channel=sharelink&deeplink_url=zigzag%3A%2F%2Fopen", - ) - val fromWeb = canonical("https://zigzag.kr/catalog/products/164171173") - assertEquals(fromWeb, fromShortlink) - } - - @Test - fun `onelink 단축 자체도 쿼리가 떨어진다 - 경로가 곧 공유 코드라 쿼리는 상품을 식별하지 않는다`() { - val withUtm = canonical("https://musinsa.onelink.me/PvkC/hxnauj24?utm_source=kakao") - assertEquals(canonical("https://musinsa.onelink.me/PvkC/hxnauj24"), withUtm) - } - - @ParameterizedTest - @CsvSource( - "https://kream.co.kr/products/984835?foo=bar, https://kream.co.kr/products/984835", - "https://m.a-bly.com/goods/70768193?abc=1, https://m.a-bly.com/goods/70768193", - "https://s.zigzag.kr/pby6JjF2dc?x=1, https://s.zigzag.kr/pby6JjF2dc", - ) - fun `크림 에이블리 지그재그단축은 서브도메인 포함 도메인 단위로 override 가 적용된다`( - dirty: String, - bare: String, - ) { - assertEquals(canonical(bare), canonical(dirty)) - } - - // --- 기본 규칙 몰: 식별 쿼리 보존 + 추적 이름만 제거 --- - - @Test - fun `cafe24 계열은 쿼리가 곧 상품 번호라 보존된다 - 전부 제거하면 그 몰의 모든 상품이 하나로 합쳐지는 사고`() { - val a = canonical("https://m.reetkeem.com/product/detail.html?product_no=9275&cate_no=65") - val b = canonical("https://m.reetkeem.com/product/detail.html?product_no=9090&cate_no=65") - assertNotEquals(a, b) - } - - @Test - fun `미확인 몰도 추적 파라미터는 이름으로 떨어진다 - 광고에서 복사한 URL 의 fbclid 와 utm`() { - val fromAd = - canonical( - "https://cosymosy.co.kr/shop_view/?idx=4360&utm_source=instagram&utm_medium=paid&fbclid=PAdGRleASVDk1", - ) - assertEquals(canonical("https://cosymosy.co.kr/shop_view/?idx=4360"), fromAd) - } - - @Test - fun `남는 쿼리는 이름순 정렬되어 파라미터 순서만 다른 URL 이 같은 정체성이 된다`() { - val a = canonical("https://m.reetkeem.com/product/detail.html?product_no=9275&cate_no=65") - val b = canonical("https://m.reetkeem.com/product/detail.html?cate_no=65&product_no=9275") - assertEquals(a, b) - } - - // --- 전역 규칙 --- - - @Test - fun `fragment 는 떨어진다 - HTTP 요청에 실리지 않아 서버 렌더 몰에서 상품을 바꿀 수 없다`() { - val withFragment = canonical("https://m.reetkeem.com/product/loose-unbal-tee/9277/category/65/display/1/#none") - val bare = canonical("https://m.reetkeem.com/product/loose-unbal-tee/9277/category/65/display/1/") - assertEquals(bare, withFragment) - } - - @Test - fun `trailing slash 유무로 정체성이 갈리지 않는다`() { - assertEquals( - canonical("https://shopsoez.com/product/boat-neck/72"), - canonical("https://shopsoez.com/product/boat-neck/72/"), - ) - } - - @Test - fun `host 대소문자와 trailing dot 이 정규화된다`() { - assertEquals( - canonical("https://www.musinsa.com/products/6760200"), - canonical("https://WWW.MUSINSA.COM./products/6760200"), - ) - } - - @Test - fun `다른 상품은 당연히 다른 정체성이다`() { - assertNotEquals( - canonical("https://www.musinsa.com/products/6760200"), - canonical("https://www.musinsa.com/products/6252844"), - ) - } - - @Test - fun `저장 상한 2048자를 넘는 URL 은 exceedsStorageLimit 로 표시된다 - 절단은 거짓 정체성이라 하지 않는다`() { - val longPath = "/product/" + "%EC%A0%80".repeat(300) - val over = canonical("https://unknown-mall.example.com$longPath") - kotlin.test.assertTrue(over.url.length > 2048) - kotlin.test.assertTrue(over.exceedsStorageLimit) - kotlin.test.assertFalse(canonical("https://unknown-mall.example.com/p/1").exceedsStorageLimit) - } - - @Test - fun `hash 는 정규화된 url 의 SHA-256 hex 64자다`() { - val link = canonical("https://www.musinsa.com/products/6760200") - assertEquals(64, link.hash.length) - // 알고리즘 고정 벡터 — 구현이 바뀌어 기존 저장 hash 와 어긋나면 모든 별칭 매칭이 조용히 깨지므로 박제한다. - assertEquals( - "1a1bd981e9668435e3b3c58067afdb111ac601eeaa9e28751ae27a2befab8b08", - link.hash, - ) - } -} diff --git a/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt b/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt index 862eb4081..9e2a3273c 100644 --- a/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class ProductLinkTest { @@ -42,17 +43,14 @@ class ProductLinkTest { @Test fun `귀결점은 http 와 https 를 받는다`() { - assertEquals("http", ProductLink.parseHop("http://shop.example.com/items/42").value.scheme) - assertEquals("https", ProductLink.parseHop("https://shop.example.com/items/42").value.scheme) + assertEquals("http", ProductLink.parseHopOrNull("http://shop.example.com/items/42")?.value?.scheme) + assertEquals("https", ProductLink.parseHopOrNull("https://shop.example.com/items/42")?.value?.scheme) } @Test - fun `귀결점도 http·https 가 아닌 scheme 과 host 없는 주소는 거부한다`() { - listOf("ftp://example.com", "file:///etc/passwd", "javascript:alert(1)").forEach { raw -> - assertFailsWith("$raw 는 거부되어야 함") { ProductLink.parseHop(raw) } - } - listOf("http:///p", "https:p").forEach { raw -> - assertFailsWith("$raw 는 거부되어야 함") { ProductLink.parseHop(raw) } + fun `귀결점도 http·https 가 아닌 scheme 과 host 없는 주소는 없는 것으로 본다`() { + listOf("ftp://example.com", "file:///etc/passwd", "javascript:alert(1)", "http:///p", "https:p").forEach { raw -> + assertNull(ProductLink.parseHopOrNull(raw), "$raw 는 거부되어야 함") } } @@ -87,6 +85,20 @@ class ProductLinkTest { assertEquals("https 링크만 등록할 수 있어요.", ex.message) } + @Test + fun `URI 로는 읽히지만 host 가 없는 raw 는 형식 오류로 거부한다`() { + listOf( + "https:foo", + "https:///products/1", + "https://exa_mple.com/products/1", + "https://user@/products/1", + "https://.com/products/1", + ).forEach { raw -> + val e = assertFailsWith("'$raw' 는 거부되어야 함") { ProductLink.parse(raw) } + assertEquals(ProductLinkErrorCode.INVALID_FORMAT, e.errorCode) + } + } + @Test fun `대문자 scheme 도 case-insensitive 로 통과한다`() { val link = ProductLink.parse("HTTPS://shop.example.com/items/42") diff --git a/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt index 696b25dcb..36cfc5d2a 100644 --- a/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyIntegrationTest.kt @@ -3,9 +3,9 @@ package com.depromeet.piki.product.routing import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductSnapshot -import com.depromeet.piki.support.deleteItems import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubLinkSnapshotExtractor +import com.depromeet.piki.support.deleteItems import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.user.domain.IdentityType import org.junit.jupiter.api.Test @@ -119,7 +119,7 @@ class DomainAccessPolicyIntegrationTest : IntegrationTestSupport() { // 도메인은 테스트 격리용 유니크 값 — 서브도메인(shop.)까지 매칭되는지 함께 본다. val domain = "blocked-${UUID.randomUUID()}.example.com" val body = """{"url": "https://shop.$domain/p/1"}""" - stubLinkSnapshotExtractor.build = { ProductSnapshot(link = it, name = "테스트 상품", price = 9_900) } + stubLinkSnapshotExtractor.build = { ProductSnapshot(name = "테스트 상품", price = 9_900) } try { // 정책 추가 + reload — 배포 없이 곧바로 등록이 거부된다(백오피스 저장 → afterCommit reload 와 같은 경로). policyRepository.save(DomainAccessPolicyEntity(domain = domain, access = DomainAccess.BLOCKED.name, reason = "테스트")) @@ -161,7 +161,7 @@ class DomainAccessPolicyIntegrationTest : IntegrationTestSupport() { val userId = UUID.randomUUID() insertMember(userId) val domain = "allowed-${UUID.randomUUID()}.example.com" - stubLinkSnapshotExtractor.build = { ProductSnapshot(link = it, name = "테스트 상품", price = 9_900) } + stubLinkSnapshotExtractor.build = { ProductSnapshot(name = "테스트 상품", price = 9_900) } try { policyRepository.save( DomainAccessPolicyEntity( diff --git a/src/test/kotlin/com/depromeet/piki/product/service/ProductSnapshotTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/ProductSnapshotTest.kt index 0852c0e86..91fa5e855 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/ProductSnapshotTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/ProductSnapshotTest.kt @@ -7,11 +7,9 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull class ProductSnapshotTest { - private val link = ProductLink.parse("https://shop.example.com/products/42") - @Test fun `name 공백은 null 로 정규화된다`() { - assertNull(ProductSnapshot.fromExtracted(link, " ", null, 1_000, "KRW").name) + assertNull(ProductSnapshot.fromExtracted(" ", null, 1_000, "KRW").name) } @Test @@ -24,7 +22,7 @@ class ProductSnapshotTest { "", ).forEach { raw -> assertNull( - ProductSnapshot.fromExtracted(link, "상품", raw, 1_000, "KRW").imageUrl, + ProductSnapshot.fromExtracted("상품", raw, 1_000, "KRW").imageUrl, "'$raw' 는 거부되어야 함", ) } @@ -34,60 +32,53 @@ class ProductSnapshotTest { fun `https imageUrl 은 그대로 통과한다`() { assertEquals( "https://cdn.example.com/a.jpg", - ProductSnapshot.fromExtracted(link, "상품", "https://cdn.example.com/a.jpg", 1_000, "KRW").imageUrl, + ProductSnapshot.fromExtracted("상품", "https://cdn.example.com/a.jpg", 1_000, "KRW").imageUrl, ) } @Test fun `currency 대소문자·공백은 ISO 4217 대문자로 정규화된다`() { - assertEquals("USD", ProductSnapshot.fromExtracted(link, "상품", null, 1_000, " usd ").currency) + assertEquals("USD", ProductSnapshot.fromExtracted("상품", null, 1_000, " usd ").currency) } @Test fun `ISO 4217 이 아닌 currency 는 null 로 정규화된다`() { - assertNull(ProductSnapshot.fromExtracted(link, "상품", null, 1_000, "ZZZ").currency) + assertNull(ProductSnapshot.fromExtracted("상품", null, 1_000, "ZZZ").currency) } @Test fun `가격이 음수면 ProductSnapshotException 을 던진다`() { assertFailsWith { - ProductSnapshot.fromExtracted(link, "상품", null, -1, "KRW") - } - } - - @Test - fun `name 이 512자를 초과하면 ProductSnapshotException 을 던진다`() { - assertFailsWith { - ProductSnapshot.fromExtracted(link, "가".repeat(513), null, 1_000, "KRW") - } - } - - @Test - fun `imageUrl 이 2048자를 초과하면 ProductSnapshotException 을 던진다`() { - assertFailsWith { - ProductSnapshot.fromExtracted(link, "상품", "https://cdn.example.com/" + "a".repeat(2048), 1_000, "KRW") + ProductSnapshot.fromExtracted("상품", null, -1, "KRW") } } - @Test - fun `link 가 null 이어도(이미지 추출 경로) 변환된다`() { - val snapshot = ProductSnapshot.fromExtracted(null, "상품", null, 1_000, "KRW") - assertNull(snapshot.link) - assertEquals("상품", snapshot.name) - } - @Test fun `price 가 null 이면 예외 없이 null 로 통과한다`() { - assertNull(ProductSnapshot.fromExtracted(link, "상품", null, null, "KRW").price) + assertNull(ProductSnapshot.fromExtracted("상품", null, null, "KRW").price) } @Test fun `price 가 0 이면 0 으로 통과한다`() { - assertEquals(0, ProductSnapshot.fromExtracted(link, "상품", null, 0, "KRW").price) + assertEquals(0, ProductSnapshot.fromExtracted("상품", null, 0, "KRW").price) } @Test fun `price 가 양수이면 그대로 통과한다`() { - assertEquals(1_000, ProductSnapshot.fromExtracted(link, "상품", null, 1_000, "KRW").price) + assertEquals(1_000, ProductSnapshot.fromExtracted("상품", null, 1_000, "KRW").price) + } + + @Test + fun `finalUrl 은 귀결점 규칙(http·https·host)에 맞을 때만 링크로 들고 아니면 null 이다`() { + assertEquals( + ProductLink.parse("https://www.musinsa.com/products/1"), + ProductSnapshot.fromExtracted("상품", null, 1_000, "KRW", finalUrl = "https://www.musinsa.com/products/1").finalUrl, + ) + assertEquals( + "http://shop.example.com/p/1", + ProductSnapshot.fromExtracted("상품", null, 1_000, "KRW", finalUrl = "http://shop.example.com/p/1").finalUrl?.toString(), + ) + assertNull(ProductSnapshot.fromExtracted("상품", null, 1_000, "KRW", finalUrl = "not a url").finalUrl) + assertNull(ProductSnapshot.fromExtracted("상품", null, 1_000, "KRW", finalUrl = "ftp://shop.example.com/p/1").finalUrl) } } diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt index 7e525285c..5e51ea93d 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode +import com.depromeet.piki.product.domain.ParseInputType import org.junit.jupiter.api.Test import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -45,7 +46,7 @@ class HttpExtractionModelProbeTest { .body(body), ) } - val rejection = assertFailsWith { probe.verify(ExtractionTarget.LINK, "gemini-x") } + val rejection = assertFailsWith { probe.verify(ParseInputType.LINK, "gemini-x") } return rejection.message.orEmpty() } @@ -61,7 +62,7 @@ class HttpExtractionModelProbeTest { .andRespond(withSuccess()) } - probe.verify(ExtractionTarget.IMAGE, "gemini-3-flash") + probe.verify(ParseInputType.IMAGE, "gemini-3-flash") } @Test @@ -93,7 +94,7 @@ class HttpExtractionModelProbeTest { fun `422 가 아닌 status 는 일시 실패로 안내한다`() { val probe = probeWith { server -> server.expect(requestTo(probeUrl)).andRespond(withServerError()) } - val e = assertFailsWith { probe.verify(ExtractionTarget.LINK, "gemini-x") } + val e = assertFailsWith { probe.verify(ParseInputType.LINK, "gemini-x") } assertEquals(HttpExtractionModelProbe.MESSAGE_UNAVAILABLE, e.message) } @@ -104,7 +105,7 @@ class HttpExtractionModelProbeTest { server.expect(requestTo(probeUrl)).andRespond { throw IOException("connection reset") } } - val e = assertFailsWith { probe.verify(ExtractionTarget.LINK, "gemini-x") } + val e = assertFailsWith { probe.verify(ParseInputType.LINK, "gemini-x") } assertEquals(HttpExtractionModelProbe.MESSAGE_UNAVAILABLE, e.message) } } diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractorTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractorTest.kt index b765d63ad..e2d63fc6a 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractorTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpLinkSnapshotExtractorTest.kt @@ -3,6 +3,7 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.common.exception.BaseException import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.item.service.ItemParsingMetrics +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.domain.ProductLinkException import com.depromeet.piki.product.routing.DomainAccess @@ -45,10 +46,10 @@ class HttpLinkSnapshotExtractorTest { // 지정한 축에만 값을 준다 — target 을 무시하고 늘 같은 값을 돌려주면, 링크 추출기가 IMAGE 축을 읽는 // 회귀가 그대로 통과한다. 축 분리가 이 기능의 전제라 Fake 가 그 전제를 지켜야 한다. private class FakeModelSettings( - private val axis: ExtractionTarget, + private val axis: ParseInputType, private val model: String?, ) : ExtractionModelSettings { - override fun modelOf(target: ExtractionTarget): String? = model?.takeIf { target == axis } + override fun modelOverrideOf(target: ParseInputType): String? = model?.takeIf { target == axis } } private fun extractorWith( @@ -66,7 +67,7 @@ class HttpLinkSnapshotExtractorTest { return HttpLinkSnapshotExtractor( builder.build(), FakeAccessPolicy(access), - FakeModelSettings(ExtractionTarget.LINK, model), + FakeModelSettings(ParseInputType.LINK, model), ) } @@ -90,7 +91,6 @@ class HttpLinkSnapshotExtractorTest { val snapshot = extractor.extract(link) - assertEquals(link, snapshot.link) assertEquals("나이키", snapshot.name) assertEquals("https://cdn.example.com/i.png", snapshot.imageUrl) assertEquals(99_000, snapshot.price) @@ -137,7 +137,6 @@ class HttpLinkSnapshotExtractorTest { extractor.extract(link) } - @Test fun `허락받은 도메인은 authorized=true 를 실어 보낸다 - 원장은 이쪽에 있다`() { // 허가 원장(domain_access_policies.access)의 단일 진실은 이쪽 DB 이고, 무상태인 extractor 는 @@ -169,7 +168,6 @@ class HttpLinkSnapshotExtractorTest { assertFailsWith { extractor.extract(link) } } - @Test fun `200 이어도 경계 정규화를 거친다 - non-https imageUrl 은 null 로, 소문자 currency 는 ISO 정규형으로`() { // 원격 계약이 정상 값을 보장하더라도 신뢰 경계를 넘어온 값은 fromExtracted 정규화를 다시 거친다(다층 방어). diff --git a/src/test/kotlin/com/depromeet/piki/support/ItemFixtures.kt b/src/test/kotlin/com/depromeet/piki/support/ItemFixtures.kt new file mode 100644 index 000000000..55ee8822b --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/support/ItemFixtures.kt @@ -0,0 +1,15 @@ +package com.depromeet.piki.support + +import com.depromeet.piki.image.domain.ProductImageKey +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.NormalizedInput +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.product.domain.ProductLink + +fun linkItem(url: String): Item = itemOf(ParseInput.Link(ProductLink.parse(url))) + +fun imageItem(): Item = itemOf(ParseInput.Image(ProductImageKey.newRaw("png"))) + +fun normalizedOf(url: String): NormalizedInput = requireNotNull(NormalizedInput.of(ParseInput.Link(ProductLink.parse(url)))) + +private fun itemOf(input: ParseInput): Item = Item.of(input.type, requireNotNull(NormalizedInput.of(input)).asCanonical()) diff --git a/src/test/kotlin/com/depromeet/piki/support/ItemVersionFixtures.kt b/src/test/kotlin/com/depromeet/piki/support/ItemVersionFixtures.kt new file mode 100644 index 000000000..93ecbdf5e --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/support/ItemVersionFixtures.kt @@ -0,0 +1,27 @@ +package com.depromeet.piki.support + +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod +import com.depromeet.piki.item.domain.ItemSnapshot +import java.util.UUID + +// 값은 기본이 READY. 필드를 null 로 넘기면 INCOMPLETE 버전이 된다 +fun itemVersion( + itemId: Long, + name: String? = "상품", + price: Int? = 1_000, + imageUrl: String? = "https://img.example.com/p.png", + currency: String? = "KRW", + userInput: Boolean = false, + method: ExtractionMethod? = ExtractionMethod.STRUCTURED.takeUnless { userInput }, + createdBy: UUID = UUID.randomUUID(), +): ItemSnapshot = + ItemSnapshot( + itemId = itemId, + name = name, + imageUrl = imageUrl, + price = price, + currency = currency, + method = method, + userInput = userInput, + createdBy = createdBy, + ) diff --git a/src/test/kotlin/com/depromeet/piki/support/ParseOutbox.kt b/src/test/kotlin/com/depromeet/piki/support/ParseOutbox.kt index fbd74aa5c..98f47665b 100644 --- a/src/test/kotlin/com/depromeet/piki/support/ParseOutbox.kt +++ b/src/test/kotlin/com/depromeet/piki/support/ParseOutbox.kt @@ -2,37 +2,58 @@ package com.depromeet.piki.support import com.depromeet.piki.item.domain.ItemParseOutbox import com.depromeet.piki.item.domain.ItemParseOutboxStatus -import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.service.ExtractionSettlement +import com.depromeet.piki.item.service.ItemParsingService +import com.depromeet.piki.product.service.ProductSnapshot import jakarta.persistence.EntityManager import org.springframework.jdbc.core.JdbcTemplate // 엔티티 전이를 거친다. JDBC 로 상태만 바꾸면 같은 트랜잭션에서 이미 관리 중인 엔티티는 옛 상태로 남는다. -fun EntityManager.claimParseOutbox(snapshot: ItemSnapshot): Long { - val outbox = parseOutboxOf(snapshot) ?: ItemParseOutbox(itemSnapshotId = snapshot.getId()).also(::persist) +fun EntityManager.claimLatestParseOutbox(slot: ItemSlot): Long { + val outbox = latestParseOutboxOf(slot) ?: error("요청자 $slot 의 아웃박스가 없다") outbox.claim() flush() return outbox.getId() } -fun JdbcTemplate.parseOutboxStatusOf(itemId: Long): ItemParseOutboxStatus = +// 등록 뒤 파싱이 끝난 것과 같은 경로로 아이템과 버전을 만든다 +fun ItemParsingService.completeLatest( + entityManager: EntityManager, + slot: ItemSlot, + extracted: ProductSnapshot, +): ExtractionSettlement = markExtracted(entityManager.claimLatestParseOutbox(slot), extracted) + +fun ItemParsingService.failLatest( + entityManager: EntityManager, + slot: ItemSlot, +): Boolean = markFailed(entityManager.claimLatestParseOutbox(slot)) + +fun JdbcTemplate.parseOutboxStatusOf(slot: ItemSlot): ItemParseOutboxStatus = queryForObject( - "SELECT o.status FROM item_parse_outbox o JOIN item_snapshots s ON s.id = o.item_snapshot_id WHERE s.item_id = ?", + "SELECT status FROM item_parse_outbox WHERE slot_type = ? AND slot_id = ? ORDER BY id DESC LIMIT 1", String::class.java, - itemId, - )?.let(ItemParseOutboxStatus::valueOf) ?: error("item $itemId 의 아웃박스 행이 없다") + slot.type.name, + slot.id, + )?.let(ItemParseOutboxStatus::valueOf) ?: error("요청자 $slot 의 아웃박스가 없다") -// 별칭(item_links)이 남으면 다음 실행의 같은 URL 등록이 지워진 item 을 가리켜 공유 매칭이 어긋남 fun JdbcTemplate.deleteItems(itemIds: Collection) { if (itemIds.isEmpty()) return val ids = itemIds.joinToString(",") - update("DELETE FROM item_links WHERE item_id IN ($ids)") - update("DELETE o FROM item_parse_outbox o JOIN item_snapshots s ON s.id = o.item_snapshot_id WHERE s.item_id IN ($ids)") update("DELETE FROM item_snapshots WHERE item_id IN ($ids)") update("DELETE FROM items WHERE id IN ($ids)") } -private fun EntityManager.parseOutboxOf(snapshot: ItemSnapshot): ItemParseOutbox? = - createQuery("select o from ItemParseOutbox o where o.itemSnapshotId = :itemSnapshotId", ItemParseOutbox::class.java) - .setParameter("itemSnapshotId", snapshot.getId()) +fun JdbcTemplate.deleteParseOutboxOf(slots: Collection) { + slots.forEach { update("DELETE FROM item_parse_outbox WHERE slot_type = ? AND slot_id = ?", it.type.name, it.id) } +} + +private fun EntityManager.latestParseOutboxOf(slot: ItemSlot): ItemParseOutbox? = + createQuery( + "select o from ItemParseOutbox o where o.slotType = :type and o.slotId = :id order by o.id desc", + ItemParseOutbox::class.java, + ).setParameter("type", slot.type) + .setParameter("id", slot.id) + .setMaxResults(1) .resultList - .singleOrNull() + .firstOrNull() diff --git a/src/test/kotlin/com/depromeet/piki/support/RunningQueries.kt b/src/test/kotlin/com/depromeet/piki/support/RunningQueries.kt new file mode 100644 index 000000000..ce5f30835 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/support/RunningQueries.kt @@ -0,0 +1,11 @@ +package com.depromeet.piki.support + +import org.springframework.jdbc.core.JdbcTemplate + +// 테스트 계정은 PROCESS 권한이 없어 잠금 대기 표를 못 읽는다. 같은 계정의 연결은 보여 잠금을 기다리는 실행 중 쿼리로 대기를 본다 +fun JdbcTemplate.runningQueries(pattern: String): Int = + queryForObject( + "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE ID <> CONNECTION_ID() AND INFO LIKE ?", + Int::class.java, + pattern, + ) ?: 0 diff --git a/src/test/kotlin/com/depromeet/piki/support/SnapshotAccessConventionTest.kt b/src/test/kotlin/com/depromeet/piki/support/SnapshotAccessConventionTest.kt index c6686b97e..ba82e5f49 100644 --- a/src/test/kotlin/com/depromeet/piki/support/SnapshotAccessConventionTest.kt +++ b/src/test/kotlin/com/depromeet/piki/support/SnapshotAccessConventionTest.kt @@ -7,10 +7,9 @@ import kotlin.test.fail /** * item 스냅샷 원본 접근을 기계로 동결하는 메타 테스트. * - * 같은 item 에 값이 둘이다 — 포인터(위시·출전이 가리키는 버전)와 표시값(ItemDisplayService, 최신 기계 READY - * 우선 파생). 사용자가 화면에서 보고 행동한 결과에 관여하는 읽기(출전 판정·수기 수정 base·새로고침 판정)는 - * 표시값을 거쳐야 하고, 원본이 정당한 갈래는 넷뿐이다 — 쓰기 / 정체성(itemId만) / 버전 자체가 대상(알림·이력) / - * 표시값 파생의 입력. 이 구분은 타입이 같아 컴파일러가 못 잡고, 단일 사용자 테스트에선 둘이 같아 테스트도 못 + * 같은 item 에 버전이 여럿이고 화면 값은 카드(ItemCardService)가 그중 하나를 골라 계산한다. 사용자가 화면에서 보고 + * 행동한 결과에 관여하는 읽기(출전 판정·사용자 입력 base·새로고침 판정)는 카드를 거쳐야 하고, 원본이 정당한 갈래는 + * 넷뿐이다 — 쓰기 / 정체성(itemId만) / 버전 자체가 대상(알림·이력) / 카드 계산의 입력. 이 구분은 타입이 같아 컴파일러가 못 잡고, 단일 사용자 테스트에선 둘이 같아 테스트도 못 * 잡는다(#1006 담기 게이트가 실제로 밟은 함정). 그래서 소비자 집합을 여기 동결한다 — 새 소비 클래스는 이 목록에 * 자기 갈래를 적어야 하고, 그 순간 위 규칙을 읽게 된다. 갈래 판정 자체는 사람 리뷰 몫이다(오탐 없는 기계 판정 불가). */ @@ -19,20 +18,13 @@ class SnapshotAccessConventionTest { private val allowedRawConsumers = mapOf( "notification/handler/ItemParsingCompletedHandler.kt" to "버전 자체 — 방금 전이된 그 버전의 이름으로 알림 문구를 만든다", - "notification/handler/ItemParsingFailedHandler.kt" to "버전 자체 — 위와 동일", - "notification/handler/ItemParsingIncompleteHandler.kt" to "버전 자체 — 위와 동일", + "notification/handler/ItemParsingIncompleteHandler.kt" to "버전 자체 — 방금 일부만 채운 그 버전의 이름", "notification/handler/ItemParsingRecoveredHandler.kt" to "버전 자체 — 방금 성공한 그 버전의 이름. 수신자의 표시값이 곧 이 버전이라(최신 기계 READY) 파생과 결과가 같다", "notification/handler/ItemRefreshCompletedHandler.kt" to - "버전 자체 — 새로고침으로 방금 성공한 그 버전의 이름. 수신자의 포인터가 곧 이 버전이라 파생과 결과가 같다", + "버전 자체 — 새로고침으로 방금 성공한 그 버전의 이름", "notification/handler/TournamentItemDeletedHandler.kt" to "버전 자체 — 삭제된 출전 카드가 보던 버전의 이름", - "tournament/service/TournamentService.kt" to - "정체성(itemId 추출)·표시값 입력. 출전 판정은 requireEntryEligible(표시값)이 진다", - "tournament/service/TournamentItemPersistenceService.kt" to - "쓰기(PENDING·MANUAL 적재)·정체성. 수정 base 는 표시값(editBasisOf)", "wishlist/service/WishlistService.kt" to "가격 이력(버전 자체)·표시값 입력", - "wishlist/service/WishPersistenceService.kt" to - "쓰기(PENDING·MANUAL 적재)·진행 중 합류 조회. 수정 base·새로고침 판정은 표시값", ) @Test @@ -44,7 +36,7 @@ class SnapshotAccessConventionTest { if (offenders.isNotEmpty()) { fail( "ItemSnapshotRepository 를 직접 쓰는 새 클래스가 있다. 사용자 화면 행동에 관여하는 읽기면 " + - "ItemDisplayService(표시값)를 거치고, 원본이 정당한 갈래(쓰기·정체성·버전 자체·표시값 입력)면 " + + "ItemCardService(카드 값)를 거치고, 원본이 정당한 갈래(쓰기·정체성·버전 자체·카드 계산의 입력)면 " + "이 테스트의 allowedRawConsumers 에 갈래를 적고 등록하라:\n" + offenders.joinToString("\n") { it.path }, ) diff --git a/src/test/kotlin/com/depromeet/piki/support/StubExtractionModelProbe.kt b/src/test/kotlin/com/depromeet/piki/support/StubExtractionModelProbe.kt index fbdfd89eb..6f595f2d6 100644 --- a/src/test/kotlin/com/depromeet/piki/support/StubExtractionModelProbe.kt +++ b/src/test/kotlin/com/depromeet/piki/support/StubExtractionModelProbe.kt @@ -1,15 +1,15 @@ package com.depromeet.piki.support +import com.depromeet.piki.product.domain.ParseInputType import com.depromeet.piki.product.service.remote.ExtractionModelProbe -import com.depromeet.piki.product.service.remote.ExtractionTarget class StubExtractionModelProbe : ExtractionModelProbe { - var behavior: (ExtractionTarget, String) -> Unit = { _, _ -> notStubbed("behavior") } + var behavior: (ParseInputType, String) -> Unit = { _, _ -> notStubbed("behavior") } - val calls: MutableList> = mutableListOf() + val calls: MutableList> = mutableListOf() override fun verify( - target: ExtractionTarget, + target: ParseInputType, model: String, ) { calls += target to model diff --git a/src/test/kotlin/com/depromeet/piki/support/StubImageSnapshotExtractor.kt b/src/test/kotlin/com/depromeet/piki/support/StubImageSnapshotExtractor.kt index 318d325ad..d0eae123f 100644 --- a/src/test/kotlin/com/depromeet/piki/support/StubImageSnapshotExtractor.kt +++ b/src/test/kotlin/com/depromeet/piki/support/StubImageSnapshotExtractor.kt @@ -1,10 +1,11 @@ package com.depromeet.piki.support +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.image.service.ImageSnapshotExtractor import com.depromeet.piki.product.service.ProductSnapshot class StubImageSnapshotExtractor : ImageSnapshotExtractor { - var build: (String) -> ProductSnapshot = { notStubbed("build") } + var build: (ProductImageKey) -> ProductSnapshot = { notStubbed("build") } - override fun extract(imageKey: String): ProductSnapshot = build(imageKey) + override fun extract(key: ProductImageKey): ProductSnapshot = build(key) } diff --git a/src/test/kotlin/com/depromeet/piki/support/StubImageStorage.kt b/src/test/kotlin/com/depromeet/piki/support/StubImageStorage.kt index 1ce454993..b6ca58093 100644 --- a/src/test/kotlin/com/depromeet/piki/support/StubImageStorage.kt +++ b/src/test/kotlin/com/depromeet/piki/support/StubImageStorage.kt @@ -19,6 +19,8 @@ class StubImageStorage : ImageStorage { return url } + override fun urlOf(key: String): String = "$BASE_URL/$key" + val deletedPrefixes = mutableListOf() override fun deleteByPrefix(prefix: String) { diff --git a/src/test/kotlin/com/depromeet/piki/support/UserWishCleanup.kt b/src/test/kotlin/com/depromeet/piki/support/UserWishCleanup.kt new file mode 100644 index 000000000..356a9844c --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/support/UserWishCleanup.kt @@ -0,0 +1,30 @@ +package com.depromeet.piki.support + +import com.depromeet.piki.item.domain.ItemSlot +import org.springframework.jdbc.core.JdbcTemplate +import java.util.UUID + +// 커밋하는 테스트용. 위시가 요청한 아웃박스와 확정된 아이템까지 지워야 다음 실행의 같은 URL 등록이 깨끗하게 시작한다 +fun JdbcTemplate.deleteWishDataOf(userId: UUID) { + val userBytes = uuidToBytes(userId) + val wishIds = queryForList("SELECT id FROM wishes WHERE user_id = ?", Long::class.java, userBytes).filterNotNull() + val itemIds = + queryForList("SELECT item_id FROM wishes WHERE user_id = ? AND item_id IS NOT NULL", Long::class.java, userBytes) + .filterNotNull() + .distinct() + deleteParseOutboxOf(wishIds.map(ItemSlot::wish)) + update("DELETE m FROM wish_memos m JOIN wishes w ON w.id = m.wish_id WHERE w.user_id = ?", userBytes) + update("DELETE FROM wishes WHERE user_id = ?", userBytes) + deleteItems(itemIds) +} + +fun JdbcTemplate.wishItemIdOf(wishId: Long): Long? = + queryForList("SELECT item_id FROM wishes WHERE id = ?", Long::class.java, wishId).firstOrNull() + +fun JdbcTemplate.latestParseOutboxIdOf(slot: ItemSlot): Long = + queryForObject( + "SELECT id FROM item_parse_outbox WHERE slot_type = ? AND slot_id = ? ORDER BY id DESC LIMIT 1", + Long::class.java, + slot.type.name, + slot.id, + ) ?: error("요청자 $slot 의 아웃박스가 없다") diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt index 06d5744bc..ee0723ef3 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentFromPlayLinkConcurrencyIntegrationTest.kt @@ -1,12 +1,12 @@ package com.depromeet.piki.tournament.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.domain.TournamentItem import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository @@ -26,7 +26,6 @@ import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper -import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -67,7 +66,7 @@ class TournamentFromPlayLinkConcurrencyIntegrationTest : IntegrationTestSupport( val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" val clonerAuth = "Bearer ${jwtProvider.generateAccessToken(clonerId, IdentityType.GUEST)}" - // 소스 토너먼트 준비 (단일 스레드, 직렬) + val itemIds = mutableListOf() val sourceTournamentId = run { val createResult = mockMvc.perform( post("/api/v1/tournaments") @@ -77,38 +76,13 @@ class TournamentFromPlayLinkConcurrencyIntegrationTest : IntegrationTestSupport( ).andReturn() val tId = objectMapper.readTree(createResult.response.contentAsString)["data"]["tournamentId"].asLong() - // 3단계: tournament_item 은 고정 snapshot 을 참조한다. 조회·start 표시값이 snapshot 에서 오므로 함께 만들어 연결한다. - // item 은 정체성(link)만 들고, 표시값·상태는 READY snapshot 이 보유한다(4a). - val item1 = itemJpaRepository.save(Item()) - val item2 = itemJpaRepository.save(Item()) - val snapshot1 = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = item1.getId(), - name = "race-item1", - price = 10_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), - ) - val snapshot2 = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = item2.getId(), - name = "race-item2", - price = 20_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), - ) - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tId, userId = ownerId, snapshotId = snapshot1.getId()), - ) - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tId, userId = ownerId, snapshotId = snapshot2.getId()), - ) + val item1 = itemJpaRepository.save(imageItem()) + val item2 = itemJpaRepository.save(imageItem()) + itemIds += listOf(item1.getId(), item2.getId()) + itemSnapshotJpaRepository.save(itemVersion(itemId = item1.getId(), name = "race-item1", price = 10_000)) + itemSnapshotJpaRepository.save(itemVersion(itemId = item2.getId(), name = "race-item2", price = 20_000)) + tournamentItemJpaRepository.save(TournamentItem(tournamentId = tId, userId = ownerId, itemId = item1.getId())) + tournamentItemJpaRepository.save(TournamentItem(tournamentId = tId, userId = ownerId, itemId = item2.getId())) mockMvc.perform( post("/api/v1/tournaments/$tId/start") @@ -198,6 +172,7 @@ class TournamentFromPlayLinkConcurrencyIntegrationTest : IntegrationTestSupport( jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", sourceTournamentId) jdbcTemplate.update("DELETE FROM tournament_users WHERE tournament_id = ?", sourceTournamentId) jdbcTemplate.update("DELETE FROM tournaments WHERE id = ?", sourceTournamentId) + jdbcTemplate.deleteItems(itemIds) jdbcTemplate.update("DELETE FROM users WHERE id = ? OR id = ?", uuidToBytes(ownerId), uuidToBytes(clonerId)) } } diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 18c053e36..7b38824be 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -1,21 +1,27 @@ package com.depromeet.piki.tournament.controller -import jakarta.persistence.EntityManager import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.image.domain.ProductImageKey import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemErrorCode +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemParseOutboxStatus import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose import com.depromeet.piki.item.repository.ItemJpaRepository +import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.notification.handler.TournamentNotificationVariables import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubImageStorage import com.depromeet.piki.support.StubRefreshTokenStore -import com.depromeet.piki.support.claimParseOutbox +import com.depromeet.piki.support.normalizedOf +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.linkItem +import com.depromeet.piki.support.parseOutboxStatusOf import com.depromeet.piki.support.presignImages import com.depromeet.piki.tournament.controller.dto.UpdateTournamentNicknameRequest import com.depromeet.piki.tournament.domain.Tournament @@ -43,6 +49,7 @@ import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishJpaRepository import com.depromeet.piki.wishlist.repository.WishRepository import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.service.dto.UpdateWishMemoCommand import io.micrometer.core.instrument.MeterRegistry import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test @@ -50,6 +57,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.mock.web.MockMultipartFile import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.context.event.ApplicationEvents @@ -72,7 +80,6 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.UUID import kotlin.test.assertEquals -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -80,8 +87,6 @@ import kotlin.test.assertTrue @RecordApplicationEvents @Transactional class TournamentIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var entityManager: EntityManager - @Autowired private lateinit var webApplicationContext: WebApplicationContext @Autowired private lateinit var objectMapper: ObjectMapper @@ -110,8 +115,9 @@ class TournamentIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var wishPersistenceService: WishPersistenceService - @Autowired private lateinit var itemParsingService: ItemParsingService + @Autowired private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository + @Autowired private lateinit var jdbcTemplate: JdbcTemplate @Autowired private lateinit var wishJpaRepository: WishJpaRepository @@ -434,10 +440,8 @@ class TournamentIntegrationTest : IntegrationTestSupport() { fun `POST tournaments-id-items 에서 존재하지 않는 아이템 ID 이면 404 를 반환한다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - // 위시에는 등록되어 있지만 item 테이블에는 없는 ID — wish 확인 통과 후 item 존재 확인에서 404. - // itemId 단일 출처는 snapshot 이므로, item 행 없이 itemId=999999 를 가리키는 snapshot 만 시딩해 wish 가 가리키게 한다(FK 없음). - val danglingSnapshotId = itemSnapshotJpaRepository.save(ItemSnapshot.pending(999999L, requestedBy = userId)).getId() - wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = danglingSnapshotId, itemId = 999999L)) + // 위시에는 있지만 item 행이 없는 ID. wish 확인을 통과한 뒤 item 존재 확인에서 404(FK 없음) + wishJpaRepository.save(Wish(userId = userId, itemId = 999999L)) mockMvc .perform( @@ -448,26 +452,6 @@ class TournamentIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isNotFound) } - @Test - fun `POST tournaments-id-items 에서 아직 파싱 중(PENDING)인 아이템이면 409 를 반환한다`() { - val mockMvc = buildMockMvc() - val tournamentId = createTournament(mockMvc) - val processingItemId = itemJpaRepository.save(Item()).getId() - // 활성 snapshot 이 PENDING — 표시값·상태는 snapshot 소관이라 PENDING snapshot 을 만들어 wish 가 가리키게 한다. - val processingSnapshotId = - itemSnapshotJpaRepository.save(ItemSnapshot.pending(processingItemId, requestedBy = userId)).getId() - // 위시에도 등록 — wish 확인 통과 후 READY 상태 확인에서 409 - wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = processingSnapshotId, itemId = processingItemId)) - - mockMvc - .perform( - post("/api/v1/tournaments/$tournamentId/items/wish") - .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) - .contentType(MediaType.APPLICATION_JSON) - .content("""{"itemIds":[$processingItemId]}"""), - ).andExpect(status().isConflict) - } - @Test fun `POST tournaments-id-items 에서 빈 itemIds 는 400 을 반환한다`() { val mockMvc = buildMockMvc() @@ -528,19 +512,16 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `대기실은 최신 기계 버전을 보여주고 start 가 그 표시 버전을 박제해 이후 새 버전이 생겨도 겨룬 값이 유지된다`() { + fun `대기실은 최신 기계 버전을 보여주고 시작 시각 이후에 생긴 버전은 시작한 토너먼트의 출전 값에 안 들어간다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - // 포인터는 옛 기계 버전(v1)에 박힌 출전 아이템 둘. - val itemA = itemJpaRepository.save(Item(link = ProductLink.parse("https://shop.example.com/products/pin-a"))) - val itemB = itemJpaRepository.save(Item(link = ProductLink.parse("https://shop.example.com/products/pin-b"))) + val itemA = itemJpaRepository.save(linkItem("https://shop.example.com/products/pin-a")) + val itemB = itemJpaRepository.save(linkItem("https://shop.example.com/products/pin-b")) saveTournamentItemFor(tournamentId, itemA, name = "A 옛값", price = 100_000, currency = "KRW", imageUrl = "https://i.example/a1.png") saveTournamentItemFor(tournamentId, itemB, name = "B 옛값", price = 200_000, currency = "KRW", imageUrl = "https://i.example/b1.png") - // 다른 참조의 갱신이 만든 새 기계 버전(v2) — 출전 포인터는 여전히 v1 이다. - val v2a = saveMachineVersion(itemA.getId(), "A 새값", 110_000) - saveMachineVersion(itemB.getId(), "B 새값", 210_000) + saveMachineReady(itemA.getId(), "A 새값", 110_000) + saveMachineReady(itemB.getId(), "B 새값", 210_000) - // 대기실(PENDING)은 파생 — 포인터가 v1 이어도 최신 기계 버전(v2) 값을 보여준다(#857). mockMvc .perform(get("/api/v1/tournaments/$tournamentId").header(HttpHeaders.AUTHORIZATION, authHeader(userId))) .andExpect(status().isOk) @@ -548,21 +529,14 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data.pending.items[?(@.name == 'B 새값')]").exists()) .andExpect(jsonPath("$.data.pending.items[?(@.name == 'A 옛값')]").doesNotExist()) - // start = 겨루는 값 확정 — 파생 표시 버전(v2)으로 겨루고, 포인터도 v2 로 박제(repin)된다. mockMvc .perform(post("/api/v1/tournaments/$tournamentId/start").header(HttpHeaders.AUTHORIZATION, authHeader(userId))) .andExpect(status().isOk) .andExpect(jsonPath("$.data.items[0].name").value("A 새값")) .andExpect(jsonPath("$.data.items[0].price").value(110_000)) .andExpect(jsonPath("$.data.items[1].name").value("B 새값")) - val pinnedSnapshots = tournamentItemJpaRepository - .findAllByTournamentIdAndNotDeleted(tournamentId) - .map { itemSnapshotJpaRepository.findById(it.snapshotId).get() } - assertEquals(setOf("A 새값", "B 새값"), pinnedSnapshots.map { it.name }.toSet()) - assertTrue(pinnedSnapshots.any { it.getId() == v2a.getId() }, "itemA 포인터가 표시 버전(v2)으로 repin 되어야 한다") - // 시작 후 또 새 기계 버전(v3)이 생겨도 진행 화면은 박제된 값(v2) 그대로 — 겨룬 값 = 화면 값. - saveMachineVersion(itemA.getId(), "A 더새값", 120_000) + saveMachineReady(itemA.getId(), "A 더새값", 120_000) mockMvc .perform(get("/api/v1/tournaments/$tournamentId").header(HttpHeaders.AUTHORIZATION, authHeader(userId))) .andExpect(status().isOk) @@ -613,14 +587,12 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `POST tournaments-id-items-wish 는 남이 같은 링크를 담아 성공한 값이 있으면 내 포인터가 미완성이어도 담긴다`() { - // 회귀(담기·시작 판정 어긋남): 같은 링크는 한 item 을 공유하고, 카드는 포인터가 아니라 최신 기계 READY 를 - // 보여준다(displayOf). 판정만 포인터를 보면 화면엔 가격이 떠 있는데 "채운 뒤 담아 주세요" 가 나가고, - // 같은 아이템으로 시작은 되는 어긋남이 생긴다. 담기·시작이 같은 값을 보는지 이 테스트가 고정한다. + fun `POST tournaments-id-items-wish 는 남이 같은 링크를 담아 성공한 값이 있으면 내 값이 미완성이어도 담긴다`() { + // 회귀(담기·시작 판정 어긋남): 판정이 카드 표시값과 다른 값을 보면 화면엔 가격이 떠 있는데 담기가 거부된다 val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) val itemId = saveIncompleteWishItem() - saveMachineReadySnapshot(itemId) + saveMachineReady(itemId) mockMvc .perform( @@ -632,9 +604,8 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `POST tournaments-id-items-wish 는 남이 담아 성공한 값이 없으면 미완성 포인터를 그대로 거부한다`() { - // 위 테스트의 대조군 — 최신 기계 READY 가 없으면 표시값이 곧 포인터라 판정이 그대로 미완성이어야 한다. - // 이게 없으면 위 테스트만으로는 "게이트를 통째로 지웠을 때"도 초록불이 된다. + fun `POST tournaments-id-items-wish 는 남이 담아 성공한 값이 없으면 미완성인 내 값을 그대로 거부한다`() { + // 대조군. 이게 없으면 위 테스트만으로는 게이트를 통째로 지워도 초록불이 된다 val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) val itemId = saveIncompleteWishItem() @@ -656,11 +627,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) addItemsToTournament(mockMvc, tournamentId, userId, saveWishItem(name = "완성 아이템")) - val incompleteItem = itemJpaRepository.save(Item()) - val snapshot = saveSnapshot(incompleteItem.getId(), status = ItemStatus.INCOMPLETE, price = 10_000) - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId()), - ) + saveTournamentItemFor(tournamentId, itemJpaRepository.save(imageItem()), name = null, imageUrl = null, price = 10_000) mockMvc .perform( @@ -1041,9 +1008,9 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc, "썸네일 토너먼트") // 오래된 → 최신 순으로 3개 등록 (tournament_item id 가 증가 = 최근). 최근 2장만 최근순으로 담겨야 한다. - saveTournamentItemFor(tournamentId, itemJpaRepository.save(Item()), imageUrl = "https://img.example.com/1.jpg") - saveTournamentItemFor(tournamentId, itemJpaRepository.save(Item()), imageUrl = "https://img.example.com/2.jpg") - saveTournamentItemFor(tournamentId, itemJpaRepository.save(Item()), imageUrl = "https://img.example.com/3.jpg") + saveTournamentItemFor(tournamentId, itemJpaRepository.save(imageItem()), imageUrl = "https://img.example.com/1.jpg") + saveTournamentItemFor(tournamentId, itemJpaRepository.save(imageItem()), imageUrl = "https://img.example.com/2.jpg") + saveTournamentItemFor(tournamentId, itemJpaRepository.save(imageItem()), imageUrl = "https://img.example.com/3.jpg") mockMvc .perform( @@ -1060,13 +1027,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { fun `GET tournaments 에서 이미지 준비된 아이템이 없으면 thumbnailUrls 는 빈 배열이다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc, "미준비 토너먼트") - // 아직 파싱 중(PROCESSING)이라 이미지가 없는 아이템만 있는 경우 - saveTournamentItemFor( - tournamentId, - itemJpaRepository.save(Item()), - status = ItemStatus.PROCESSING, - imageUrl = null, - ) + saveParsingTournamentItem(tournamentId) mockMvc .perform( @@ -1107,15 +1068,14 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `GET tournaments 에서 FAILED 스냅샷의 이미지는 thumbnailUrls 에서 제외된다`() { + fun `GET tournaments 에서 미완성 아이템의 이미지는 thumbnailUrls 에서 제외된다`() { val mockMvc = buildMockMvc() - val tournamentId = createTournament(mockMvc, "FAILED 이미지 토너먼트") - // 추출 실패(FAILED)했지만 imageUrl 이 남아있는 stale 스냅샷 — 카드에 노출되면 안 된다. + val tournamentId = createTournament(mockMvc, "미완성 이미지 토너먼트") saveTournamentItemFor( tournamentId, - itemJpaRepository.save(Item()), - status = ItemStatus.FAILED, - imageUrl = "https://img.example.com/stale.jpg", + itemJpaRepository.save(imageItem()), + price = null, + imageUrl = "https://img.example.com/incomplete.jpg", ) mockMvc @@ -1722,11 +1682,10 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `GET tournaments-id 는 PENDING 상태에서 PROCESSING 아이템이 status=PROCESSING 으로 목록에 포함되고 name·price·imageUrl 은 없다`() { + fun `GET tournaments-id 는 PENDING 상태에서 파싱 중인 아이템이 status=PARSING 으로 목록에 포함되고 itemId·name·price·imageUrl 은 없다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val processingItem = itemJpaRepository.save(Item()) - saveTournamentItemFor(tournamentId, processingItem, status = ItemStatus.PROCESSING) + saveParsingTournamentItem(tournamentId) mockMvc .perform( @@ -1735,8 +1694,8 @@ class TournamentIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) .andExpect(jsonPath("$.data.status").value("PENDING")) .andExpect(jsonPath("$.data.pending.items.length()").value(1)) - .andExpect(jsonPath("$.data.pending.items[0].status").value("PROCESSING")) - .andExpect(jsonPath("$.data.pending.items[0].itemId").value(processingItem.getId())) + .andExpect(jsonPath("$.data.pending.items[0].status").value("PARSING")) + .andExpect(jsonPath("$.data.pending.items[0].itemId").doesNotExist()) .andExpect(jsonPath("$.data.pending.items[0].name").doesNotExist()) .andExpect(jsonPath("$.data.pending.items[0].price").doesNotExist()) .andExpect(jsonPath("$.data.pending.items[0].imageUrl").doesNotExist()) @@ -2031,10 +1990,9 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val (rootId, cloneId, rootTi) = cloneFromCompletedRoot(mockMvc) // 원본 소유자(userId)의 wish 에 memo 를 심는다. - val snapshotId = tournamentItemJpaRepository.findById(rootTi).get().snapshotId - val itemId = itemSnapshotJpaRepository.findById(snapshotId).get().itemId + val itemId = requireNotNull(tournamentItemJpaRepository.findById(rootTi).get().itemId) val wishId = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).first().getId() - wishPersistenceService.updateMemo(userId = userId, wishId = wishId, memo = "원본 메모") + wishPersistenceService.updateMemo(UpdateWishMemoCommand(userId = userId, wishId = wishId, memo = "원본 메모")) // 원본 조회(소유자)에는 memo 가 보인다. mockMvc @@ -2074,8 +2032,8 @@ class TournamentIntegrationTest : IntegrationTestSupport() { // 클론 id 로만 닿던 가드다. 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 이 사유에 도달할 수 없다. // 가드 코드(clonedTournamentCannotModifyItems)·에러코드 제거는 4-2단계(#1188 후속). - // 완료된 ROOT + 그 플레이링크로 참여한 게스트(otherUserId)를 만들어 (rootId, joinedId(=rootId), 원본 tournamentItemId) 를 준다. - private fun cloneFromCompletedRoot(mockMvc: MockMvc): Triple { + // 완료된 ROOT 에 그 플레이링크로 게스트(otherUserId)가 참여한다. 클론이 사라져 joinedId 는 rootId 와 같다 + private fun cloneFromCompletedRoot(mockMvc: MockMvc): PlayLinkJoin { saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") val (rootId, rootTi, _) = completeTournamentWith2Items(mockMvc) mockMvc.perform( @@ -2091,7 +2049,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)), ).andReturn() val cloneId = objectMapper.readTree(cloneResult.response.contentAsString)["data"].asLong() - return Triple(rootId, cloneId, rootTi) + return PlayLinkJoin(rootId = rootId, joinedId = cloneId, rootTournamentItemId = rootTi) } @Test @@ -2340,9 +2298,8 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val tournamentId = createTournament(mockMvc) val otherItem = saveWishItem(otherUserId) tournamentUserJpaRepository.save(TournamentUser(tournamentId = tournamentId, userId = otherUserId)) - // 위시 추가는 소유자 전용이므로 DB에 직접 삽입해 다른 유저가 추가한 상황을 구성. 출전 시점 고정 snapshot 을 함께 시딩한다(NOT NULL). - val otherSnapshot = saveSnapshot(otherItem, status = ItemStatus.READY) - tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = otherUserId, snapshotId = otherSnapshot.getId())) + // 위시 추가는 소유자 전용이라 다른 유저가 추가한 상황은 DB 에 직접 심는다 + tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = otherUserId, itemId = otherItem)) val itemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() mockMvc @@ -2373,13 +2330,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val tournamentId1 = createTournament(mockMvc, "토너먼트1") val tournamentId2 = createTournament(mockMvc, "토너먼트2") - // 토너먼트 소속만 검증하는 시나리오라 표시값은 무관하다. 출전 시점 고정 snapshot 만 시딩해 연결한다(NOT NULL). - val snapshotOfTournament2 = saveSnapshot(999L, status = ItemStatus.READY) - val itemOfTournament2 = - tournamentItemJpaRepository - .save( - TournamentItem(tournamentId = tournamentId2, userId = userId, snapshotId = snapshotOfTournament2.getId()), - ).getId() + val itemOfTournament2 = saveParsingTournamentItem(tournamentId2).getId() mockMvc .perform( @@ -2389,55 +2340,83 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `PATCH tournaments-id-items-itemId 는 FAILED 아이템을 수정하고 READY 로 전환한다`() { + fun `PATCH tournaments-id-items-itemId 는 파싱 실패한 아이템에 값을 채우면 요청 링크로 아이템을 확정하고 READY 가 된다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val failedItem = itemJpaRepository.save(Item()) - // imageUrl 있는 FAILED — 보정 시 imageUrl 파라미터를 보내지 않아도 apply 가 기존 값을 유지해 READY 불변식 통과 - val snapshot = saveSnapshot(failedItem.getId(), status = ItemStatus.FAILED, imageUrl = "https://img.example.com/a.png") - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId()), - ) - val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + val link = "https://shop.example.com/products/${UUID.randomUUID()}" + val tournamentItemId = saveParsingTournamentItem(tournamentId, link = link, failed = true).getId() mockMvc .perform( multipart(HttpMethod.PATCH, "/api/v1/tournaments/$tournamentId/items/$tournamentItemId") + .file(MockMultipartFile("image", "p.png", "image/png", byteArrayOf(1, 2, 3))) .param("name", "수정된 이름") .param("price", "50000") .param("currency", "KRW") .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isOk) - // 수기 수정(#825 결정 4)은 기존 행을 고치지 않고 MANUAL 새 버전으로 쌓여 pin 이 옮겨진다. - val repinned = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first() - assertNotEquals(snapshot.getId(), repinned.snapshotId) - val manual = itemSnapshotJpaRepository.findById(repinned.snapshotId).get() - assertEquals("수정된 이름", manual.name) - assertEquals(50000, manual.price) - assertEquals("KRW", manual.currency) - assertEquals(ItemStatus.READY, manual.status) - assertEquals(ItemSnapshotSource.MANUAL, manual.source) - assertEquals(userId, manual.createdBy) - // 기존 FAILED 행은 이력으로 불변. - assertEquals(ItemStatus.FAILED, itemSnapshotJpaRepository.findById(snapshot.getId()).get().status) + val itemId = requireNotNull(tournamentItemJpaRepository.findById(tournamentItemId).get().itemId) + assertEquals(normalizedOf(link).asCanonical(), itemJpaRepository.findById(itemId).get().canonicalUrl) + val userVersion = latestVersionOf(itemId) + assertEquals("수정된 이름", userVersion.name) + assertEquals(50000, userVersion.price) + assertEquals("KRW", userVersion.currency) + assertTrue(userVersion.isReady()) + assertTrue(userVersion.userInput) + assertEquals(userId, userVersion.createdBy) + + mockMvc + .perform( + get("/api/v1/tournaments/$tournamentId/items/$tournamentItemId") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.itemId").value(itemId)) + .andExpect(jsonPath("$.data.status").value("READY")) } @Test - fun `PATCH tournaments-id-items-itemId 는 남이 채운 READY 가 있으면 가격 없는 미완성 pin 에도 이름만 수정이 성공한다`() { - // 회귀(#1006 과 같은 축): PENDING 카드는 pin 이 아니라 표시값을 그린다. base 가 pin 이면 화면엔 가격이 - // 떠 있는데 안 보낸 가격이 빈 값에서 병합돼 400 으로 튕긴다. 표시값 base 로 카드에 보인 가격이 병합되는지 본다. + fun `PATCH tournaments-id-items-itemId 는 파싱 실패한 후보가 같은 상품의 후보와 겹치면 그 후보에 합치고 실패한 후보를 지운다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val incompleteItem = itemJpaRepository.save(Item()) + val link = "https://shop.example.com/products/${UUID.randomUUID()}" + val failed = saveParsingTournamentItem(tournamentId, link = link, failed = true) + val holder = saveTournamentItemFor(tournamentId, itemJpaRepository.save(linkItem(link))) + + mockMvc + .perform( + multipart(HttpMethod.PATCH, "/api/v1/tournaments/$tournamentId/items/${failed.getId()}") + .file(MockMultipartFile("image", "p.png", "image/png", byteArrayOf(1, 2, 3))) + .param("name", "수정된 이름") + .param("price", "50000") + .param("currency", "KRW") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + + mockMvc + .perform(get("/api/v1/tournaments/$tournamentId/items/${failed.getId()}").header(HttpHeaders.AUTHORIZATION, authHeader(userId))) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("TOURNAMENT-010")) + mockMvc + .perform(get("/api/v1/tournaments/$tournamentId/items/${holder.getId()}").header(HttpHeaders.AUTHORIZATION, authHeader(userId))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.name").value("수정된 이름")) + } + + @Test + fun `PATCH tournaments-id-items-itemId 는 남이 채운 READY 가 있으면 가격 없는 내 미완성 값에도 이름만 수정이 성공한다`() { + // 회귀(#1006 과 같은 축): base 가 카드 표시값이 아니면 화면엔 가격이 떠 있는데 안 보낸 가격이 빈 값에서 합쳐져 400 이 된다 + val mockMvc = buildMockMvc() + val tournamentId = createTournament(mockMvc) + val incompleteItem = itemJpaRepository.save(imageItem()) val tournamentItemId = saveTournamentItemFor( tournamentId, incompleteItem, - status = ItemStatus.INCOMPLETE, name = "미완성 이름", + price = null, imageUrl = "https://img.example.com/i.png", ).getId() - saveMachineReadySnapshot(incompleteItem.getId()) + saveMachineReady(incompleteItem.getId()) mockMvc .perform( @@ -2446,23 +2425,21 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isOk) - val repinned = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first() - val manual = itemSnapshotJpaRepository.findById(repinned.snapshotId).get() - assertEquals("내가 고친 이름", manual.name) - assertEquals(89_000, manual.price, "안 보낸 가격은 카드가 보여주던 표시값에서 병합돼야 한다") + val userVersion = latestVersionOf(incompleteItem.getId()) + assertEquals("내가 고친 이름", userVersion.name) + assertEquals(89_000, userVersion.price, "안 보낸 가격은 카드가 보여주던 표시값에서 합쳐져야 한다") } @Test - fun `PATCH tournaments-id-items-itemId 는 남이 채운 READY 가 없으면 가격 없는 미완성 pin 의 이름만 수정을 400 으로 거부한다`() { - // 위 테스트의 대조군 — 표시값이 곧 pin 이면 병합해도 가격이 비어 거부돼야 한다(입력 계약 400). + fun `PATCH tournaments-id-items-itemId 는 남이 채운 READY 가 없으면 가격 없는 내 미완성 값의 이름만 수정을 400 으로 거부한다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val incompleteItem = itemJpaRepository.save(Item()) + val incompleteItem = itemJpaRepository.save(imageItem()) val tournamentItemId = saveTournamentItemFor( tournamentId, incompleteItem, - status = ItemStatus.INCOMPLETE, name = "미완성 이름", + price = null, imageUrl = "https://img.example.com/i.png", ).getId() @@ -2472,11 +2449,11 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .param("name", "내가 고친 이름") .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value(ItemErrorCode.PRICE_REQUIRED_FOR_READY.code)) } @Test - fun `PATCH tournaments-id-items-itemId 에서 READY 아이템도 수기 수정하면 200 과 MANUAL 새 버전으로 교체된다`() { - // 수기 수정 상시 허용(#825 결정 4) — READY 는 더 이상 409 가 아니다. + fun `PATCH tournaments-id-items-itemId 에서 READY 아이템도 사용자 입력하면 200 과 사용자 입력 새 버전이 쌓인다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) val readyItemId = saveWishItem() @@ -2490,42 +2467,54 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isOk) - val repinned = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first() - val manual = itemSnapshotJpaRepository.findById(repinned.snapshotId).get() - assertEquals("수정 시도", manual.name) - assertEquals(ItemSnapshotSource.MANUAL, manual.source) + val userVersion = latestVersionOf(readyItemId) + assertEquals("수정 시도", userVersion.name) + assertTrue(userVersion.userInput) } @Test - fun `PATCH tournaments-id-items-itemId 에서 PROCESSING 아이템은 일부 필드만 보내면 병합 필수값 부재로 400 이다`() { - // 상태 충돌(409)이 아니다(#825 결정 4) — PROCESSING base 는 값이 비어 있어 병합 결과 필수값 부재(400)로 떨어질 뿐, - // 필수값을 다 채우면 진행 중이어도 수정된다(위 READY 케이스와 동일 규칙). + fun `PATCH tournaments-id-items-itemId 에 바꿀 값이 하나도 없으면 400 이고 버전이 쌓이지 않는다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val processingItem = itemJpaRepository.save(Item()) - val snapshot = saveSnapshot(processingItem.getId(), status = ItemStatus.PROCESSING) - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId()), - ) + val readyItemId = saveWishItem() + addItemsToTournament(mockMvc, tournamentId, userId, readyItemId) val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + val versionBefore = latestVersionOf(readyItemId).getId() mockMvc .perform( multipart(HttpMethod.PATCH, "/api/v1/tournaments/$tournamentId/items/$tournamentItemId") - .param("name", "수정 시도") .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("ITEM-009")) + + assertEquals(versionBefore, latestVersionOf(readyItemId).getId()) + } + + @Test + fun `PATCH tournaments-id-items-itemId 에서 파싱 중인 아이템은 값을 다 채워도 409 이고 아이템이 확정되지 않는다`() { + val mockMvc = buildMockMvc() + val tournamentId = createTournament(mockMvc) + val tournamentItemId = saveParsingTournamentItem(tournamentId).getId() + + mockMvc + .perform( + multipart(HttpMethod.PATCH, "/api/v1/tournaments/$tournamentId/items/$tournamentItemId") + .file(MockMultipartFile("image", "p.png", "image/png", byteArrayOf(1, 2, 3))) + .param("name", "직접 입력한 이름") + .param("price", "50000") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("ITEM-008")) + + assertNull(tournamentItemJpaRepository.findById(tournamentItemId).get().itemId) } @Test fun `PATCH tournaments-id-items-itemId 에서 IN_PROGRESS 토너먼트이면 409 를 반환한다`() { val mockMvc = buildMockMvc() val (tournamentId, item1Id) = startTournamentWith2Items(mockMvc) - // 토너먼트가 IN_PROGRESS 라 snapshot 검증 전 notPending(409)에서 막힌다 — 고정 snapshot 만 시딩해 연결하면 된다(NOT NULL). - val failedItem = itemJpaRepository.save(Item()) - val snapshot = saveSnapshot(failedItem.getId(), status = ItemStatus.FAILED) - tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId())) - val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).last().getId() + val tournamentItemId = saveParsingTournamentItem(tournamentId, failed = true).getId() mockMvc .perform( @@ -2535,6 +2524,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .param("currency", "KRW") .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value(TournamentErrorCode.NOT_PENDING_TOURNAMENT.code)) } @Test @@ -2542,11 +2532,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) tournamentUserJpaRepository.save(TournamentUser(tournamentId = tournamentId, userId = otherUserId)) - // 등록자(otherUserId)가 아닌 userId 의 PATCH 라 snapshot 검증 전 forbidden(403)에서 막힌다 — 고정 snapshot 만 시딩해 연결하면 된다(NOT NULL). - val failedItem = itemJpaRepository.save(Item()) - val snapshot = saveSnapshot(failedItem.getId(), status = ItemStatus.FAILED) - tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = otherUserId, snapshotId = snapshot.getId())) - val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + val tournamentItemId = saveParsingTournamentItem(tournamentId, owner = otherUserId, failed = true).getId() mockMvc .perform( @@ -2557,16 +2543,12 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `PATCH tournaments-id-items-itemId 는 이름이 있는 FAILED 아이템에 가격만 보내면 이름을 유지하며 200 을 반환한다`() { + fun `PATCH tournaments-id-items-itemId 는 이름이 있는 미완성 아이템에 가격만 보내면 이름을 유지하며 200 을 반환한다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - // 이름과 imageUrl 이 이미 있는 FAILED snapshot — 가격만 보정해도 이름과 이미지가 유지되는지 검증한다(apply 가 기존 값 보존). - val failedItem = itemJpaRepository.save(Item()) - val snapshot = saveSnapshot(failedItem.getId(), status = ItemStatus.FAILED, name = "기존 이름", imageUrl = "https://img.example.com/a.png") - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId()), - ) - val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + val incompleteItem = itemJpaRepository.save(imageItem()) + val tournamentItemId = + saveTournamentItemFor(tournamentId, incompleteItem, name = "기존 이름", price = null, imageUrl = "https://img.example.com/a.png").getId() mockMvc .perform( @@ -2576,24 +2558,17 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isOk) - // 병합은 MANUAL 새 버전에서 일어난다 — base 의 이름·이미지가 유지된 채 가격만 바뀐 새 버전이 pin 된다. - val repinned = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first() - val manual = itemSnapshotJpaRepository.findById(repinned.snapshotId).get() - assertEquals("기존 이름", manual.name) - assertEquals(50000, manual.price) - assertEquals(ItemStatus.READY, manual.status) + val userVersion = latestVersionOf(incompleteItem.getId()) + assertEquals("기존 이름", userVersion.name) + assertEquals(50000, userVersion.price) + assertTrue(userVersion.isReady()) } @Test - fun `PATCH tournaments-id-items-itemId 에서 이름 없이 수정하면 400 을 반환한다`() { + fun `PATCH tournaments-id-items-itemId 에서 파싱 실패한 아이템을 이름 없이 수정하면 400 을 반환한다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val failedItem = itemJpaRepository.save(Item()) - val snapshot = saveSnapshot(failedItem.getId(), status = ItemStatus.FAILED) - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = tournamentId, userId = userId, snapshotId = snapshot.getId()), - ) - val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + val tournamentItemId = saveParsingTournamentItem(tournamentId, failed = true).getId() mockMvc .perform( @@ -2604,7 +2579,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `POST tournaments-id-items-link 는 참여자이면 PENDING 아이템을 생성하고 tournamentItemId 를 반환한다`() { + fun `POST tournaments-id-items-link 는 처음 보는 링크면 아이템 없이 출전을 올리고 파싱을 요청한다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) @@ -2624,10 +2599,17 @@ class TournamentIntegrationTest : IntegrationTestSupport() { assertEquals(1, it.size) }.first() assertEquals(tournamentItemId, tournamentItem.getId()) - // item 정체성은 snapshot 단일 출처이므로 tournament_item 의 고정 snapshot 으로 itemId 에 도달해 최신 snapshot 을 조회한다. - val fixedSnapshot = itemSnapshotJpaRepository.findById(tournamentItem.snapshotId).get() - val snapshot = itemSnapshotJpaRepository.findFirstByItemIdAndDeletedAtIsNullOrderByIdDesc(fixedSnapshot.itemId) - assertEquals(ItemStatus.PENDING, snapshot?.status) + assertNull(tournamentItem.itemId) + assertEquals(ItemParseOutboxStatus.PENDING, jdbcTemplate.parseOutboxStatusOf(tournamentItem.slot)) + + mockMvc + .perform( + get("/api/v1/tournaments/$tournamentId/items/$tournamentItemId") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.status").value("PARSING")) + .andExpect(jsonPath("$.data.itemId").doesNotExist()) + .andExpect(jsonPath("$.data.sourceUrl").value("https://example.com/product")) } @Test @@ -2643,8 +2625,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .content("""{"url":"https://www.musinsa.com/products/4400001"}"""), ).andExpect(status().isOk) - // override 몰이라 추적 쿼리가 달라도 같은 정체성으로 정규화된다 — raw link 문자열 비교였다면 통과했을 - // 재등록이 정체성(itemId) 기준 중복 검사(#825 공유 활성화)로 막힌다. + // 파싱 중인 출전도 정규화한 요청 URL 로 겹침을 본다. raw 문자열 비교였다면 추적 쿼리만 다른 재등록이 통과한다 mockMvc .perform( post("/api/v1/tournaments/$tournamentId/items/link") @@ -2653,7 +2634,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .content("""{"url":"https://www.musinsa.com/products/4400001?utm_source=kakao"}"""), ).andExpect(status().isConflict) .andExpect(jsonPath("$.code").value("TOURNAMENT-009")) - .andExpect(jsonPath("$.detail").value("이미 담은 아이템이에요.")) + .andExpect(jsonPath("$.detail").value("이미 담은 토너먼트 후보예요.")) } @Test @@ -2801,6 +2782,40 @@ class TournamentIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isForbidden) } + @Test + fun `POST tournaments-id-items-wish 는 위시에 있는 아이템과 없는 아이템이 섞이면 403 을 반환한다`() { + val mockMvc = buildMockMvc() + val tournamentId = createTournament(mockMvc) + val wishedItemId = saveWishItem() + val othersItemId = saveWishItem(owner = otherUserId) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":[$wishedItemId,$othersItemId]}"""), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("TOURNAMENT-016")) + } + + @Test + fun `POST tournaments-id-items-wish 에서 파싱 중인 출전까지 32개를 넘으면 400 을 반환한다`() { + val mockMvc = buildMockMvc() + val tournamentId = createTournament(mockMvc) + repeat(31) { saveParsingTournamentItem(tournamentId) } + val wishItemIds = (1..2).map { saveWishItem() } + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":${wishItemIds.joinToString(",", "[", "]")}}"""), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("TOURNAMENT-008")) + } + @Test fun `POST tournaments-id-items-link 에서 32개 초과 시 400 을 반환한다`() { val mockMvc = buildMockMvc() @@ -2945,131 +2960,79 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val item2Id: Long, ) + private data class PlayLinkJoin( + val rootId: Long, + val joinedId: Long, + val rootTournamentItemId: Long, + ) + // 위시 없이 item 정체성만 저장 — wish 소유 확인 실패(403) 시나리오용. 표시값·상태는 snapshot 소관이라 여기선 빈 item 만. - private fun saveItem(): Long = itemJpaRepository.save(Item()).getId() + private fun saveItem(): Long = itemJpaRepository.save(imageItem()).getId() - // 파생(#857) 검증용 — 다른 참조의 갱신이 만든 새 기계(SERVER) READY 버전을 시딩한다. 포인터는 안 움직인다. - private fun saveMachineVersion( + // 같은 상품을 다른 카드의 요청이 파싱해 성공한 기계 READY + private fun saveMachineReady( itemId: Long, - name: String, - price: Int, + name: String = "남이 채운 이름", + price: Int = 89_000, ): ItemSnapshot = itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = itemId, - name = name, - price = price, - currency = "KRW", - imageUrl = "https://i.example/$price.png", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - source = ItemSnapshotSource.SERVER, - ), + itemVersion(itemId = itemId, name = name, price = price, imageUrl = "https://i.example/$price.png"), ) - // 조회 경로(getTournamentById·getTournamentItem)가 고정 snapshot 을 읽어 표시값·상태를 snapshot 에 담아 연결함 + // 값은 기본이 READY. 필드를 null 로 넘기면 INCOMPLETE private fun saveTournamentItemFor( tournamentId: Long, item: Item, - status: ItemStatus = ItemStatus.READY, - name: String? = null, - price: Int? = null, - currency: String? = null, - imageUrl: String? = null, + name: String? = "상품", + price: Int? = 1_000, + currency: String? = "KRW", + imageUrl: String? = "https://img.example.com/p.png", owner: UUID = userId, ): TournamentItem { - val snapshot = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = name, - price = price, - currency = currency, - imageUrl = imageUrl, - status = status, - // READY·INCOMPLETE 는 추출이 값을 남긴 상태라 추출시각이 있다(saveSnapshot 과 같은 규칙). - extractedAt = if (status == ItemStatus.READY || status == ItemStatus.INCOMPLETE) LocalDateTime.now() else null, - ), - ) - return tournamentItemJpaRepository.save( - TournamentItem( - tournamentId = tournamentId, - userId = owner, - snapshotId = snapshot.getId(), - ), + itemSnapshotJpaRepository.save( + itemVersion(itemId = item.getId(), name = name, price = price, currency = currency, imageUrl = imageUrl, createdBy = owner), ) + return tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = owner, itemId = item.getId())) } - // tournament_item 의 snapshotId 가 가리킬 고정 버전용 - private fun saveSnapshot( - itemId: Long, - status: ItemStatus, - name: String? = null, - price: Int? = null, - currency: String? = null, - imageUrl: String? = null, - ): ItemSnapshot = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = itemId, - name = name, - price = price, - currency = currency, - imageUrl = imageUrl, - status = status, - // READY·INCOMPLETE 는 추출이 값을 남긴 상태라 추출시각이 있다(ItemSnapshot 의 두 불변식). - extractedAt = if (status == ItemStatus.READY || status == ItemStatus.INCOMPLETE) LocalDateTime.now() else null, - ), - ) + // 아이템이 확정되기 전의 출전. 상태는 최근 아웃박스가 정한다(진행 중이면 PARSING, 실패면 FAILED) + private fun saveParsingTournamentItem( + tournamentId: Long, + owner: UUID = userId, + link: String = "https://shop.example.com/products/${UUID.randomUUID()}", + failed: Boolean = false, + ): TournamentItem { + val tournamentItem = tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = owner, itemId = null)) + val outbox = ItemParseOutbox.of(tournamentItem.slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(link))) + if (failed) { + outbox.claim() + outbox.fail() + } + itemParseOutboxRepository.save(outbox) + return tournamentItem + } - // 위시리스트에도 등록된 READY 아이템 생성 — /items/wish 엔드포인트용. 이미지 등록류(link 없이 sourceImageKey)라 sourceUrl 이 없다. - // 등록 API 를 타지 않고 행을 직접 심는다 — 필요한 것은 "이미지로 만들어진 READY 위시" 라는 상태뿐이고, + private fun latestVersionOf(itemId: Long): ItemSnapshot = + itemSnapshotJpaRepository.findByItemIdInAndDeletedAtIsNullOrderByIdAsc(listOf(itemId)).last() + + // 필요한 것은 "이미지로 만들어진 READY 위시" 라는 상태뿐이라 등록 API 를 타지 않고 행을 직접 심는다. // 등록 경로 자체(발급·확정)는 TournamentItemImagePresignedIntegrationTest 가 따로 덮는다. - // 적재 후 markExtracted 로 전이시켜 추출값을 채운다. 표시값·상태는 활성 snapshot 이 보유한다. private fun saveWishItem(owner: UUID = userId, name: String = "테스트 아이템", price: Int = 10_000): Long { - val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) - val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = owner)) - wishJpaRepository.save(Wish(userId = owner, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) - itemParsingService.markExtracted( - entityManager.claimParseOutbox(snapshot), - ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png"), + val item = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save( + itemVersion(itemId = item.getId(), name = name, price = price, imageUrl = "https://img.example.com/a.png", createdBy = owner), ) + wishJpaRepository.save(Wish(userId = owner, itemId = item.getId())) return item.getId() } - // 같은 item 에 기계 READY 버전을 하나 더 쌓는다 — 다른 사용자가 같은 링크를 담아 새 추출이 성공한 상황이다 - // (attachOrNull 이 미완성·실패를 재사용 대상으로 안 봐서 새 PENDING 을 만들고, 그게 성공하면 이 모양이 된다). - // source 를 markExtracted 가 채우게 두는 이유: 표시값 파생은 기계 READY(SERVER·SERVER_LLM)만 후보로 본다. - private fun saveMachineReadySnapshot( - itemId: Long, - name: String = "남이 채운 이름", - price: Int = 89_000, - ) { - val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(itemId, requestedBy = UUID.randomUUID())) - itemParsingService.markExtracted( - entityManager.claimParseOutbox(snapshot), - // extractionMethod 를 실어야 source 가 SERVER 로 남는다 - 표시값 파생은 출처가 기계인 READY 만 후보로 - // 보므로, 이걸 빼면 status 만 READY 인 "출처 불명" 버전이 되어 파생에 안 걸린다(실제 추출은 항상 싣는다). - ProductSnapshot( - name = name, - price = price, - currency = "KRW", - imageUrl = "https://img.example.com/b.png", - extractionMethod = "STRUCTURED", - ), - ) - } - - // 위시의 활성 snapshot 이 미완성(INCOMPLETE)인 아이템 — 가격만 빠진 부분 추출이라 markExtracted 가 그렇게 판정한다(#944). - // 적재는 saveWishItem 과 같은 이유로 행을 직접 심는다(등록 경로는 presigned 통합 테스트가 덮는다). + // 가격만 빠진 부분 추출(#944)이라 INCOMPLETE private fun saveIncompleteWishItem(owner: UUID = userId, name: String = "가격 없는 아이템"): Long { - val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) - val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = owner)) - wishJpaRepository.save(Wish(userId = owner, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) - itemParsingService.markExtracted( - entityManager.claimParseOutbox(snapshot), - ProductSnapshot(name = name, imageUrl = "https://img.example.com/a.png"), + val item = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save( + itemVersion(itemId = item.getId(), name = name, price = null, imageUrl = "https://img.example.com/a.png", createdBy = owner), ) + wishJpaRepository.save(Wish(userId = owner, itemId = item.getId())) return item.getId() } @@ -3102,7 +3065,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val tournamentId = createTournament(mockMvc) val sourceUrl = "https://www.nike.com/kr/t/air-max/example" // link(원본 URL)는 정체성이라 item 에, 표시값은 READY snapshot 에 둔다. - val linkItem = itemJpaRepository.save(Item(link = ProductLink.parse(sourceUrl))) + val linkItem = itemJpaRepository.save(linkItem(sourceUrl)) saveTournamentItemFor(tournamentId, linkItem, name = "나이키", price = 100_000, currency = "KRW") val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() @@ -3116,19 +3079,17 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `GET tournaments-id-items-tournamentItemId 는 PROCESSING 아이템이면 name·price·imageUrl 이 응답에 없고 status 가 PROCESSING 이다`() { + fun `GET tournaments-id-items-tournamentItemId 는 파싱이 실패한 아이템이면 name·price·imageUrl 이 응답에 없고 status 가 FAILED 이다`() { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val processingItem = itemJpaRepository.save(Item()) - saveTournamentItemFor(tournamentId, processingItem, status = ItemStatus.PROCESSING) - val tournamentItemId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + val tournamentItemId = saveParsingTournamentItem(tournamentId, failed = true).getId() mockMvc .perform( get("/api/v1/tournaments/$tournamentId/items/$tournamentItemId") .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isOk) - .andExpect(jsonPath("$.data.status").value("PROCESSING")) + .andExpect(jsonPath("$.data.status").value("FAILED")) .andExpect(jsonPath("$.data.name").doesNotExist()) .andExpect(jsonPath("$.data.price").doesNotExist()) .andExpect(jsonPath("$.data.imageUrl").doesNotExist()) @@ -3151,7 +3112,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data.memo").doesNotExist()) val wish = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).first() - wishPersistenceService.updateMemo(userId = userId, wishId = wish.getId(), memo = "생일 선물 후보") + wishPersistenceService.updateMemo(UpdateWishMemoCommand(userId = userId, wishId = wish.getId(), memo = "생일 선물 후보")) mockMvc .perform( @@ -3170,7 +3131,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val itemId = saveWishItem(name = "나이키 에어맥스", price = 129_000) addItemsToTournament(mockMvc, tournamentId, userId, itemId) val wish = wishRepository.findByItemIdsAndUserId(listOf(itemId), userId).first() - wishPersistenceService.updateMemo(userId = userId, wishId = wish.getId(), memo = "방장의 메모") + wishPersistenceService.updateMemo(UpdateWishMemoCommand(userId = userId, wishId = wish.getId(), memo = "방장의 메모")) saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") mockMvc .perform( diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt index 46237e925..162e3a242 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt @@ -1,13 +1,8 @@ package com.depromeet.piki.tournament.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus -import com.depromeet.piki.item.repository.ItemJpaRepository -import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.support.deleteItems import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.deleteParseOutboxOf import com.depromeet.piki.support.presignImages import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.domain.TournamentItem @@ -27,7 +22,6 @@ import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper -import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch @@ -52,8 +46,6 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( @Autowired private lateinit var objectMapper: ObjectMapper @Autowired private lateinit var jwtProvider: JwtProvider @Autowired private lateinit var userJpaRepository: UserJpaRepository - @Autowired private lateinit var itemJpaRepository: ItemJpaRepository - @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository @Autowired private lateinit var jdbcTemplate: JdbcTemplate @@ -73,9 +65,6 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" - // 이 테스트가 새로 만드는 item/snapshot 의 하한 — finally 에서 이보다 큰 id 만 지워 추가분(사전 27 + 성공 5)까지 정리한다. - val maxItemIdBefore = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) FROM items", Long::class.java) ?: 0L - var tournamentId = 0L try { // 토너먼트 생성 — TournamentUser(owner) 도 함께 생성된다(verifyCanAddItems 의 참여자 검증 통과). @@ -87,23 +76,9 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( ).andReturn() tournamentId = objectMapper.readTree(createResult.response.contentAsString)["data"]["tournamentId"].asLong() - // 출전 아이템 27개를 미리 채운다 — 각 요청(5장)은 27+5=32 로 단독 통과하지만, 둘이 합쳐 27+10=37>32 라 - // 직렬화되면 반드시 하나가 거부된다. 정원 카운트는 tournament_items 행 수만 보므로 snapshot 상태는 무관(READY 로 둔다). - val items = itemJpaRepository.saveAll((1..27).map { Item() }) - val snapshots = itemSnapshotJpaRepository.saveAll( - items.mapIndexed { i, item -> - ItemSnapshot( - itemId = item.getId(), - name = "race-image-item-${i + 1}", - price = 10_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ) - }, - ) + // 각 요청(5장)은 27+5=32 로 단독 통과하지만 둘이 합치면 37 이라 직렬화되면 하나가 거부된다. 정원은 행 수만 센다 tournamentItemJpaRepository.saveAll( - snapshots.map { TournamentItem(tournamentId = tournamentId, userId = ownerId, snapshotId = it.getId()) }, + (1..27).map { TournamentItem(tournamentId = tournamentId, userId = ownerId, itemId = null) }, ) val status200 = AtomicInteger(0) @@ -159,13 +134,14 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( // 상한을 넘겨 저장된 것이 없어야 한다 — 성공한 5장까지만 반영되어 정확히 32개다. assertEquals(32, tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).size) } finally { - // @Transactional 자동 롤백이 없으므로 직접 지운다. 추가된 item/snapshot 은 id 하한으로 일괄 정리한다. if (tournamentId != 0L) { + jdbcTemplate.deleteParseOutboxOf( + tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).map { it.slot }, + ) jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournament_users WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournaments WHERE id = ?", tournamentId) } - jdbcTemplate.deleteItems(jdbcTemplate.queryForList("SELECT id FROM items WHERE id > ?", Long::class.java, maxItemIdBefore).filterNotNull()) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(ownerId)) } } diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt index 20482a9b6..befb545f1 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt @@ -1,15 +1,11 @@ package com.depromeet.piki.tournament.controller -import jakarta.persistence.EntityManager import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.item.service.ItemParsingService -import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.support.claimParseOutbox +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.tournament.repository.TournamentHistoryJpaRepository import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.tournament.service.TournamentErrorCode @@ -18,6 +14,7 @@ import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserJpaRepository import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishJpaRepository +import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -36,14 +33,11 @@ import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertTrue -import org.junit.jupiter.api.Test // 매치 로직 백엔드 이관(#683)의 계약 검증. 브래킷은 서버가 파생하고 클라는 그리기만 하므로, // "GET 이 내려준 currentMatch 를 POST 에 그대로 되돌려주면 통과한다" 가 이 엔드포인트 쌍의 핵심 계약이다. @Transactional class TournamentMatchIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var entityManager: EntityManager - @Autowired private lateinit var webApplicationContext: WebApplicationContext @Autowired private lateinit var objectMapper: ObjectMapper @@ -54,9 +48,6 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository - @Autowired private lateinit var itemParsingService: ItemParsingService - - @Autowired private lateinit var itemJpaRepository: ItemJpaRepository @Autowired private lateinit var wishJpaRepository: WishJpaRepository @@ -469,13 +460,9 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { price: Int, ): Long { // 필요한 것은 "READY 인 위시 아이템" 이라는 상태뿐이라 등록 API 를 타지 않고 행을 직접 심는다. - val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) - val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) - wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) - itemParsingService.markExtracted( - entityManager.claimParseOutbox(snapshot), - ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png"), - ) + val item = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save(itemVersion(itemId = item.getId(), name = name, price = price, createdBy = userId)) + wishJpaRepository.save(Wish(userId = userId, itemId = item.getId())) return item.getId() } diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt index c929f4309..ae6f31a19 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentStartConcurrencyIntegrationTest.kt @@ -1,19 +1,20 @@ package com.depromeet.piki.tournament.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.support.deleteItems import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.runningQueries import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.domain.TournamentItem import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserJpaRepository +import org.awaitility.Awaitility.await import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders @@ -23,22 +24,22 @@ import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfig import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper -import java.time.LocalDateTime +import java.time.Duration import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.assertEquals +import kotlin.test.assertTrue // start 는 PENDING→IN_PROGRESS 상태 전이와 TournamentStarted 발행을 함께 한다. 락 없이 읽으면 동시 두 요청이 // 모두 PENDING 검증을 통과해 시작 알림을 중복 발행(참가자에게 시작 알림 2번 도달)할 수 있어, forUpdate 행 락으로 -// 직렬화한다. "정확히 1개만 200, 나머지는 409" 가 그 락 동작의 시그니처다 — 200 이 1개 = start 성공(곧 발행) 1회. -// -// 일반 통합 테스트와 달리 @Transactional 을 쓰지 않는다 — 별도 트랜잭션 동시 진행이 race 시뮬레이션의 본질이다. -// 데이터 격리는 새 UUID 를 쓰고 finally 에서 직접 정리한다. +// 직렬화한다. 200 이 정확히 1개면 start 성공(곧 발행)도 1회다. class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var webApplicationContext: WebApplicationContext @Autowired private lateinit var objectMapper: ObjectMapper @@ -48,12 +49,13 @@ class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository @Autowired private lateinit var jdbcTemplate: JdbcTemplate + @Autowired private lateinit var transactionManager: PlatformTransactionManager @Test fun `같은 주최자가 start 를 동시에 두 번 요청하면 하나만 200 나머지는 409 로 처리된다`() { val ownerId = UUID.randomUUID() userJpaRepository.save( - // 토너먼트 생성은 회원 전용(#339)이라 owner 는 MEMBER 다. 이 테스트의 관심사는 동시 start 경합이다. + // 토너먼트 생성은 회원 전용(#339) User(id = ownerId, nickname = "race-start", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), ) @@ -64,8 +66,6 @@ class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" - // PENDING 토너먼트 + READY 아이템 2개 준비 (단일 스레드, 직렬). @Transactional 이 없어 실제 커밋되므로, - // 만든 item/snapshot id 를 끝의 정리에서 쓰기 위해 메서드 스코프로 잡는다(다른 테스트의 item 카운트 오염 방지). val createResult = mockMvc.perform( post("/api/v1/tournaments") .header(HttpHeaders.AUTHORIZATION, ownerAuth) @@ -74,17 +74,12 @@ class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { ).andReturn() val tournamentId = objectMapper.readTree(createResult.response.contentAsString)["data"]["tournamentId"].asLong() - // tournament_item 은 고정 snapshot 을 참조한다. item 은 정체성(link)만 들고, 표시값·상태는 READY snapshot 이 보유한다. - val item1 = itemJpaRepository.save(Item()) - val item2 = itemJpaRepository.save(Item()) - val snapshot1 = itemSnapshotJpaRepository.save( - ItemSnapshot(itemId = item1.getId(), name = "race-item1", price = 10_000, currency = "KRW", status = ItemStatus.READY, extractedAt = LocalDateTime.now()), - ) - val snapshot2 = itemSnapshotJpaRepository.save( - ItemSnapshot(itemId = item2.getId(), name = "race-item2", price = 20_000, currency = "KRW", status = ItemStatus.READY, extractedAt = LocalDateTime.now()), - ) - tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = ownerId, snapshotId = snapshot1.getId())) - tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = ownerId, snapshotId = snapshot2.getId())) + val item1 = itemJpaRepository.save(imageItem()) + val item2 = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save(itemVersion(itemId = item1.getId(), name = "race-item1", price = 10_000)) + itemSnapshotJpaRepository.save(itemVersion(itemId = item2.getId(), name = "race-item2", price = 20_000)) + tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = ownerId, itemId = item1.getId())) + tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = ownerId, itemId = item2.getId())) val status200 = AtomicInteger(0) val status409 = AtomicInteger(0) @@ -120,12 +115,73 @@ class TournamentStartConcurrencyIntegrationTest : IntegrationTestSupport() { assertEquals(1, status200.get(), "정확히 하나만 200 이어야 한다 (start 성공 1회 = 발행 1회)") assertEquals(1, status409.get(), "나머지 하나는 락 대기 후 PENDING 아님으로 409 여야 한다") - // 정리 — @Transactional 자동 롤백이 없으므로 만든 행을 전부 직접 지운다. item/snapshot 까지 지워야 - // 다른 테스트의 전체 item 카운트가 오염되지 않는다. jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournament_users WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournaments WHERE id = ?", tournamentId) jdbcTemplate.deleteItems(listOf(item1.getId(), item2.getId())) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(ownerId)) } + + // 시작은 아이템 잠금을 기다리기 전에 첫 일반 읽기를 해 스냅샷이 새 버전의 커밋 전으로 고정된다 + @Test + fun `아이템 잠금을 기다리는 동안 커밋된 새 버전의 가격으로 시작한다`() { + val ownerId = UUID.randomUUID() + userJpaRepository.save( + User(id = ownerId, nickname = "race-lock", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), + ) + val item1 = itemJpaRepository.save(imageItem()) + val item2 = itemJpaRepository.save(imageItem()) + val versionWriter = Executors.newSingleThreadExecutor() + var tournamentId: Long? = null + try { + val mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" + val createResult = mockMvc.perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, ownerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"잠금읽기토너먼트"}"""), + ).andReturn() + val createdId = objectMapper.readTree(createResult.response.contentAsString)["data"]["tournamentId"].asLong() + tournamentId = createdId + itemSnapshotJpaRepository.save(itemVersion(itemId = item1.getId(), name = "lock-item1", price = 10_000)) + itemSnapshotJpaRepository.save(itemVersion(itemId = item2.getId(), name = "lock-item2", price = 20_000)) + tournamentItemJpaRepository.save(TournamentItem(tournamentId = createdId, userId = ownerId, itemId = item1.getId())) + tournamentItemJpaRepository.save(TournamentItem(tournamentId = createdId, userId = ownerId, itemId = item2.getId())) + + val versionInserted = CountDownLatch(1) + val writing = + versionWriter.submit { + TransactionTemplate(transactionManager).executeWithoutResult { + jdbcTemplate.queryForObject("SELECT id FROM items WHERE id = ? FOR UPDATE", Long::class.java, item1.getId()) + itemSnapshotJpaRepository.saveAndFlush(itemVersion(itemId = item1.getId(), name = "lock-item1", price = 30_000)) + versionInserted.countDown() + await().atMost(Duration.ofSeconds(5)).until { jdbcTemplate.runningQueries("%from items %for update%") > 0 } + } + } + assertTrue(versionInserted.await(5, TimeUnit.SECONDS)) + + val res = mockMvc.perform( + post("/api/v1/tournaments/$createdId/start") + .header(HttpHeaders.AUTHORIZATION, ownerAuth), + ).andReturn() + writing.get(5, TimeUnit.SECONDS) + + assertEquals(200, res.response.status) + val items = objectMapper.readTree(res.response.contentAsString)["data"]["items"] + assertEquals(listOf(20_000, 30_000), List(items.size()) { items[it]["price"].asInt() }) + } finally { + versionWriter.shutdownNow() + tournamentId?.let { + jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", it) + jdbcTemplate.update("DELETE FROM tournament_users WHERE tournament_id = ?", it) + jdbcTemplate.update("DELETE FROM tournaments WHERE id = ?", it) + } + jdbcTemplate.deleteItems(listOf(item1.getId(), item2.getId())) + jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(ownerId)) + } + } } diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt index 4a00f434c..056964fca 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentWishAddConcurrencyIntegrationTest.kt @@ -1,31 +1,43 @@ package com.depromeet.piki.tournament.controller +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.item.service.UniqueConflictRetry import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.runningQueries import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.tournament.domain.TournamentItem +import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserJpaRepository import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishJpaRepository +import org.awaitility.Awaitility.await import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper -import java.time.LocalDateTime +import java.time.Duration import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -50,6 +62,70 @@ class TournamentWishAddConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository @Autowired private lateinit var wishJpaRepository: WishJpaRepository @Autowired private lateinit var jdbcTemplate: JdbcTemplate + @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository + @Autowired private lateinit var transactionManager: PlatformTransactionManager + + // 후보 확정을 SQL 로 재현한다. 담기의 삽입이 그 확정을 기다린 뒤에 커밋해야 담기의 스냅샷이 커밋 전으로 고정된다 + @Test + fun `위시에서 담기가 같은 상품으로 확정 중인 후보와 겹치면 다시 실행해 409 를 반환한다`() { + val ownerId = UUID.randomUUID() + userJpaRepository.save(User(id = ownerId, nickname = "race-conf", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER)) + val item = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save(itemVersion(itemId = item.getId(), createdBy = ownerId)) + wishJpaRepository.save(Wish(userId = ownerId, itemId = item.getId())) + val mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).apply(springSecurity()).build() + val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" + val retryLogs = ListAppender().apply { start() } + val retryLogger = LoggerFactory.getLogger(UniqueConflictRetry::class.java) as Logger + retryLogger.addAppender(retryLogs) + val confirmer = Executors.newSingleThreadExecutor() + var tournamentId = 0L + try { + val createResult = + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, ownerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"확정동시성토너먼트"}"""), + ).andReturn() + tournamentId = objectMapper.readTree(createResult.response.contentAsString)["data"]["tournamentId"].asLong() + val confirmingEntry = tournamentItemJpaRepository.save(TournamentItem(tournamentId = tournamentId, userId = UUID.randomUUID(), itemId = null)) + val entryFilled = CountDownLatch(1) + val confirming = + confirmer.submit { + TransactionTemplate(transactionManager).executeWithoutResult { + jdbcTemplate.update("UPDATE tournament_items SET item_id = ? WHERE id = ?", item.getId(), confirmingEntry.getId()) + entryFilled.countDown() + await().atMost(Duration.ofSeconds(5)).until { jdbcTemplate.runningQueries("%insert into tournament_items%") > 0 } + } + } + assertTrue(entryFilled.await(5, TimeUnit.SECONDS)) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, ownerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("itemIds" to listOf(item.getId())))), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("TOURNAMENT-009")) + confirming.get(5, TimeUnit.SECONDS) + + assertTrue(retryLogs.list.any { it.formattedMessage.startsWith("item.confirm.retry tournament=$tournamentId") }) + } finally { + confirmer.shutdownNow() + retryLogger.detachAppender(retryLogs) + if (tournamentId != 0L) { + jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", tournamentId) + jdbcTemplate.update("DELETE FROM tournament_users WHERE tournament_id = ?", tournamentId) + jdbcTemplate.update("DELETE FROM tournaments WHERE id = ?", tournamentId) + } + jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(ownerId)) + jdbcTemplate.deleteItems(listOf(item.getId())) + jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(ownerId)) + } + } @Test fun `위시 아이템 담기를 동시에 두 번 요청하면 행 락으로 직렬화되어 32개 상한을 초과하지 않는다`() { @@ -66,31 +142,10 @@ class TournamentWishAddConcurrencyIntegrationTest : IntegrationTestSupport() { val ownerAuth = "Bearer ${jwtProvider.generateAccessToken(ownerId, IdentityType.MEMBER)}" - // 아이템 40개 + READY 스냅샷 생성 — A 요청(1-20), B 요청(21-40) 에서 나눠 씀. - // 합산 40개는 TOURNAMENT_MAX_ITEM_COUNT(32)를 초과해 두 요청이 직렬화되면 하나는 반드시 차단된다. - val items = itemJpaRepository.saveAll((1..40).map { Item() }) - val snapshots = itemSnapshotJpaRepository.saveAll( - items.mapIndexed { i, item -> - ItemSnapshot( - itemId = item.getId(), - name = "race-wish-item-${i + 1}", - price = 10_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ) - }, - ) - val snapshotIdByItemId = snapshots.associateBy({ it.itemId }, { it.getId() }) - wishJpaRepository.saveAll( - items.map { item -> - Wish( - userId = ownerId, - waitingSnapshotId = snapshotIdByItemId.getValue(item.getId()), - itemId = item.getId(), - ) - }, - ) + // A 요청(1-20), B 요청(21-40) 합산 40개는 TOURNAMENT_MAX_ITEM_COUNT(32)를 초과해 두 요청이 직렬화되면 하나는 반드시 차단된다. + val items = itemJpaRepository.saveAll((1..40).map { imageItem() }) + itemSnapshotJpaRepository.saveAll(items.mapIndexed { i, item -> itemVersion(itemId = item.getId(), name = "race-wish-item-${i + 1}") }) + wishJpaRepository.saveAll(items.map { Wish(userId = ownerId, itemId = it.getId()) }) val itemIds = items.map { it.getId() } diff --git a/src/test/kotlin/com/depromeet/piki/tournament/domain/TournamentItemTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/domain/TournamentItemTest.kt index 924f683a6..36ec83b39 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/domain/TournamentItemTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/domain/TournamentItemTest.kt @@ -1,37 +1,87 @@ package com.depromeet.piki.tournament.domain +import com.depromeet.piki.item.domain.CardQuery +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.support.withId import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import java.time.LocalDateTime import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull class TournamentItemTest { @Test fun `정상 ID 로 생성되면 필드가 그대로 보존된다`() { val userId = UUID.randomUUID() - val tournamentItem = TournamentItem(tournamentId = 1, userId = userId, snapshotId = 2) + val tournamentItem = TournamentItem(tournamentId = 1, userId = userId, itemId = 2) assertEquals(1, tournamentItem.tournamentId) - assertEquals(2, tournamentItem.snapshotId) + assertEquals(2, tournamentItem.itemId) assertEquals(userId, tournamentItem.userId) } + @Test + fun `파싱 성공 전 출전 아이템은 아이템 없이 생성된다`() { + assertNull(TournamentItem(tournamentId = 1, userId = UUID.randomUUID(), itemId = null).itemId) + } + @ParameterizedTest @ValueSource(longs = [0, -1]) fun `tournamentId 가 양수가 아니면 생성이 거부된다`(invalidId: Long) { assertFailsWith { - TournamentItem(tournamentId = invalidId, userId = UUID.randomUUID(), snapshotId = 1) + TournamentItem(tournamentId = invalidId, userId = UUID.randomUUID(), itemId = 1) } } @ParameterizedTest @ValueSource(longs = [0, -1]) - fun `snapshotId 가 양수가 아니면 생성이 거부된다`(invalidId: Long) { + fun `itemId 가 양수가 아니면 생성이 거부된다`(invalidId: Long) { assertFailsWith { - TournamentItem(tournamentId = 1, userId = UUID.randomUUID(), snapshotId = invalidId) + TournamentItem(tournamentId = 1, userId = UUID.randomUUID(), itemId = invalidId) } } + + @Test + fun `fill 은 비어 있던 아이템을 한 번만 확정한다`() { + val tournamentItem = TournamentItem(tournamentId = 1, userId = UUID.randomUUID(), itemId = null) + + tournamentItem.fill(3L) + tournamentItem.fill(3L) + + assertEquals(3L, tournamentItem.itemId) + assertFailsWith { tournamentItem.fill(4L) } + } + + @Test + fun `cardQuery 는 시작한 토너먼트의 시작 시각을 기준 시각으로 담고 시작 전이면 기준 시각이 없다`() { + val owner = UUID.randomUUID() + val tournament = tournament().withId(1L) + val tournamentItem = TournamentItem(tournamentId = 1, userId = owner, itemId = 3L).withId(9L) + + assertNull(tournamentItem.cardQuery(tournament).asOf) + tournament.start() + assertEquals( + CardQuery(slot = ItemSlot.tournamentItem(9L), itemId = 3L, owner = owner, asOf = tournament.startedAt), + tournamentItem.cardQuery(tournament), + ) + } + + @Test + fun `다른 토너먼트로 cardQuery 를 만들면 코드 버그로 본다`() { + val tournamentItem = TournamentItem(tournamentId = 1, userId = UUID.randomUUID(), itemId = 3L).withId(9L) + + assertFailsWith { tournamentItem.cardQuery(tournament().withId(2L)) } + } + + private fun tournament(): Tournament = + Tournament( + ownerTournamentUserId = 0L, + name = "테스트", + inviteCode = "ABC123", + inviteExpiresAt = LocalDateTime.now().plusMinutes(30), + ) } diff --git a/src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt index ea466e8f2..428457904 100644 --- a/src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt @@ -93,9 +93,11 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { // 매직바이트 교차검증을 통과하는 최소 유효 시그니처 (실제 픽셀 데이터는 검증과 무관). private fun jpegBytes(): ByteArray = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte(), 0, 0, 0, 0) - // 클라가 presigned 로 이미 올렸다고 가정하는 raw key. 확정 단계의 형식 검증(RAW_KEY_REGEX)을 통과해야 하므로 - // 실제 발급 형식(items/raw/{UUID}.{ext})과 같은 모양으로 만든다. - private fun rawImageKey(extension: String): String = "items/raw/${UUID.randomUUID()}.$extension" + // 클라가 presigned 로 이미 올렸다고 가정하는 key. 발급 형식이어야 확정 단계의 형식 검증을 통과한다 + private fun profileImageKey( + owner: UUID, + extension: String, + ): String = "profiles/$owner/${UUID.randomUUID()}.$extension" // PATCH /users/me 는 JSON 계약이다(이미지 바이트는 presigned 로 S3 에 직접 올라가고 여기엔 key 만 실린다). private fun patchMe( @@ -220,8 +222,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { .content("""{"contentType":"image/png","contentLength":1024}""") .header(HttpHeaders.AUTHORIZATION, "Bearer ${token(userId, IdentityType.MEMBER)}"), ).andExpect(status().isOk) - // key 는 확정 때 되돌려받아 형식 검증을 통과해야 하므로 발급 형식(items/raw/{UUID}.{ext})을 고정한다. - .andExpect(jsonPath("$.data.imageKey").value(startsWith("items/raw/"))) + .andExpect(jsonPath("$.data.imageKey").value(startsWith("profiles/$userId/"))) .andExpect(jsonPath("$.data.imageKey").value(endsWith(".png"))) .andExpect(jsonPath("$.data.uploadUrl").value(startsWith(StubImageStorage.BASE_URL))) // 서명에 박힌 값이라 클라가 PUT 할 때 그대로 써야 한다. @@ -366,7 +367,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { .build() val userId = UUID.randomUUID() insertUser(userId, nickname = "초기닉네임", identityType = IdentityType.MEMBER) - val imageKey = rawImageKey("jpg") + val imageKey = profileImageKey(userId, "jpg") stubImageStorage.downloadBehavior = { jpegBytes() } mockMvc @@ -375,16 +376,12 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) // 닉네임은 안 보냈으니 그대로, 프로필만 갱신된다. .andExpect(jsonPath("$.data.nickname").value("초기닉네임")) - // 확정본은 raw 가 아니라 profiles/{userId}/ 아래여야 탈퇴 cascade 의 prefix 파기가 닿는다. - .andExpect( - jsonPath("$.data.profileImage") - .value(startsWith("${StubImageStorage.BASE_URL}/profiles/$userId/")), - ) + .andExpect(jsonPath("$.data.profileImage").value("${StubImageStorage.BASE_URL}/$imageKey")) - // 서버가 그 key 의 원본을 실제로 읽어 검증했는지 — 검증 없이 URL 만 갈아끼우면 위장 업로드가 통과한다. + // 검증 없이 URL 만 갈아끼우면 위장 업로드가 통과한다 assertContains(stubImageStorage.downloadedKeys, imageKey) - // 확정된 뒤 raw 는 회수한다(남겨도 lifecycle 이 지우지만, PII 를 하루 방치할 이유가 없다). - assertContains(stubImageStorage.deletedKeys, imageKey) + assertFalse(stubImageStorage.uploadedKeys.contains(imageKey)) + assertFalse(stubImageStorage.deletedKeys.contains(imageKey)) } @Test @@ -401,7 +398,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { mockMvc .perform( patchMe( - """{"nickname":"새닉네임","imageKey":"${rawImageKey("jpg")}"}""", + """{"nickname":"새닉네임","imageKey":"${profileImageKey(userId, "jpg")}"}""", token(userId, IdentityType.MEMBER), ), ).andExpect(status().isOk) @@ -426,7 +423,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { mockMvc .perform( patchMe( - """{"nickname":"새닉네임","imageKey":"${rawImageKey("jpg")}"}""", + """{"nickname":"새닉네임","imageKey":"${profileImageKey(userId, "jpg")}"}""", token(userId, IdentityType.GUEST), ), ).andExpect(status().isForbidden) @@ -451,7 +448,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { .build() val userId = UUID.randomUUID() insertUser(userId, nickname = "게스트닉네임", identityType = IdentityType.GUEST) - val imageKey = rawImageKey("jpg") + val imageKey = profileImageKey(userId, "jpg") // 권한 확인이 payload 처리보다 먼저라는 계약을 고정한다(authorization before payload processing). // downloadBehavior 를 세팅하지 않았으므로, 순서가 뒤집혀 다운로드가 먼저 실행되면 stub 이 터져 500 이 난다. @@ -480,6 +477,25 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.code").value("UPLOAD-001")) } + @Test + fun `PATCH users me - 다른 회원이 올린 imageKey 는 UPLOAD-001 로 400 이 반환되고 읽지도 않는다`() { + val mockMvc = + MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + val userId = UUID.randomUUID() + insertUser(userId, identityType = IdentityType.MEMBER) + val othersKey = profileImageKey(UUID.randomUUID(), "jpg") + + mockMvc + .perform(patchMe("""{"imageKey":"$othersKey"}""", token(userId, IdentityType.MEMBER))) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("UPLOAD-001")) + + assertFalse(stubImageStorage.downloadedKeys.contains(othersKey)) + } + @Test fun `PATCH users me - 아직 올라오지 않은 imageKey 는 UPLOAD-002 로 400 이 반환된다`() { val mockMvc = @@ -494,7 +510,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { try { // 발급만 받고 PUT 을 건너뛴 채 확정을 부른 경우. mockMvc - .perform(patchMe("""{"imageKey":"${rawImageKey("jpg")}"}""", token(userId, IdentityType.MEMBER))) + .perform(patchMe("""{"imageKey":"${profileImageKey(userId, "jpg")}"}""", token(userId, IdentityType.MEMBER))) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("UPLOAD-002")) } finally { @@ -516,10 +532,15 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { // 서버가 원본을 읽어 교차검증하는 이유가 이 케이스다. stubImageStorage.downloadBehavior = { jpegBytes() } + val imageKey = profileImageKey(userId, "png") + mockMvc - .perform(patchMe("""{"imageKey":"${rawImageKey("png")}"}""", token(userId, IdentityType.MEMBER))) + .perform(patchMe("""{"imageKey":"$imageKey"}""", token(userId, IdentityType.MEMBER))) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("USER-011")) + + // 이미지가 아닌 파일이 공개 버킷에 남지 않는다 + assertContains(stubImageStorage.deletedKeys, imageKey) } @Test @@ -610,9 +631,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { } @Test - fun `PATCH users me - 영속화가 409 로 떨어지면 방금 올린 프로필 이미지를 S3 에서 회수한다`() { - // 업로드는 트랜잭션 밖에서 먼저 끝나므로, 뒤이은 영속화가 떨어지면 아무도 안 가리키는 객체가 남는다. - // 프로필 사진은 PII 라 lifecycle 만료에 맡기지 않고 즉시 회수해야 한다. + fun `PATCH users me - 영속화가 409 로 떨어져도 올린 이미지는 남아 같은 imageKey 로 다시 확정된다`() { val mockMvc = MockMvcBuilders .webAppContextSetup(webApplicationContext) @@ -623,53 +642,18 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { val myUserId = UUID.randomUUID() insertUser(myUserId, nickname = "내닉네임", identityType = IdentityType.MEMBER) stubImageStorage.downloadBehavior = { jpegBytes() } + val imageKey = profileImageKey(myUserId, "jpg") mockMvc - .perform( - patchMe( - """{"nickname":"점유닉네임","imageKey":"${rawImageKey("jpg")}"}""", - token(myUserId, IdentityType.MEMBER), - ), - ).andExpect(status().isConflict) + .perform(patchMe("""{"nickname":"점유닉네임","imageKey":"$imageKey"}""", token(myUserId, IdentityType.MEMBER))) + .andExpect(status().isConflict) .andExpect(jsonPath("$.code").value("USER-004")) + assertFalse(stubImageStorage.deletedKeys.contains(imageKey)) - // 이 요청이 올린 key 가 그대로 삭제 호출됐는지 — 회수 대상이 방금 올린 그 객체여야 한다. - val uploadedKey = stubImageStorage.uploadedKeys.last { it.startsWith("profiles/$myUserId/") } - assertContains(stubImageStorage.deletedKeys, uploadedKey) - } - - @Test - fun `PATCH users me - 업로드가 502 로 떨어져도 그 key 를 회수 시도한다`() { - // S3 응답 유실·timeout 이면 예외가 나도 객체는 올라가 있을 수 있다. key 는 우리가 만든 값이라 - // 업로드 성공 여부와 무관하게 삭제를 걸 수 있고, 객체가 없으면 no-op 이라 안전하다. - val mockMvc = - MockMvcBuilders - .webAppContextSetup(webApplicationContext) - .apply(springSecurity()) - .build() - val userId = UUID.randomUUID() - insertUser(userId, nickname = "원래닉네임", identityType = IdentityType.MEMBER) - stubImageStorage.downloadBehavior = { jpegBytes() } - // 업로드가 던져 uploadedKeys 에는 안 남으므로, 업로드에 실제로 쓰인 key 를 여기서 직접 기록해 둔다 — - // 회수가 "그 유저의 아무 key"가 아니라 "방금 시도한 바로 그 key"를 지우는지 정확히 검증하기 위해서다. - val attemptedKeys = mutableListOf() - stubImageStorage.behavior = { _, key, _ -> - attemptedKeys += key - throw ImageStorageException.uploadFailed() - } - - try { - mockMvc - .perform(patchMe("""{"imageKey":"${rawImageKey("jpg")}"}""", token(userId, IdentityType.MEMBER))) - .andExpect(status().isBadGateway) - } finally { - // 공유 컨텍스트의 stub mutable state 를 복원해 다른 테스트로 누수되지 않게 한다. - stubImageStorage.behavior = stubImageStorage.defaultBehavior - } - - // 업로드는 한 번만 시도되고, 회수는 stale key 가 아니라 그때 시도한 바로 그 key 를 지워야 한다. - assertEquals(1, attemptedKeys.size) - assertContains(stubImageStorage.deletedKeys, attemptedKeys.single()) + mockMvc + .perform(patchMe("""{"nickname":"새닉네임","imageKey":"$imageKey"}""", token(myUserId, IdentityType.MEMBER))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.profileImage").value("${StubImageStorage.BASE_URL}/$imageKey")) } @Test @@ -689,7 +673,7 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { } @Test - fun `PATCH users me - S3 업로드 실패 시 502 가 반환된다`() { + fun `PATCH users me - S3 에서 읽지 못하면 502 가 반환되고 닉네임도 반영되지 않는다`() { val mockMvc = MockMvcBuilders .webAppContextSetup(webApplicationContext) @@ -697,24 +681,16 @@ class UserControllerIntegrationTest : IntegrationTestSupport() { .build() val userId = UUID.randomUUID() insertUser(userId, nickname = "원래닉네임", identityType = IdentityType.MEMBER) - stubImageStorage.downloadBehavior = { jpegBytes() } - stubImageStorage.behavior = { _, _, _ -> throw ImageStorageException.uploadFailed() } + stubImageStorage.downloadBehavior = { throw ImageStorageException.downloadFailed() } - try { - // 닉네임을 함께 보낸다 — S3 업로드가 영속화보다 먼저라, 업로드가 깨지면 닉네임도 반영되면 안 된다(원자성). - mockMvc - .perform( - patchMe( - """{"nickname":"새닉네임","imageKey":"${rawImageKey("jpg")}"}""", - token(userId, IdentityType.MEMBER), - ), - ).andExpect(status().isBadGateway) - } finally { - // 공유 컨텍스트의 stub mutable state 를 복원해 다른 테스트로 누수되지 않게 한다. - stubImageStorage.behavior = stubImageStorage.defaultBehavior - } + mockMvc + .perform( + patchMe( + """{"nickname":"새닉네임","imageKey":"${profileImageKey(userId, "jpg")}"}""", + token(userId, IdentityType.MEMBER), + ), + ).andExpect(status().isBadGateway) - // 502 로 끝났으니 닉네임도 반영되지 않아야 한다 — 업로드(트랜잭션 밖)가 영속화 전에 깨져 부분 적용이 없다. mockMvc .perform( get("/api/v1/users/me") diff --git a/src/test/kotlin/com/depromeet/piki/user/controller/WithdrawalIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/user/controller/WithdrawalIntegrationTest.kt index 740cc5424..7187489ef 100644 --- a/src/test/kotlin/com/depromeet/piki/user/controller/WithdrawalIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/user/controller/WithdrawalIntegrationTest.kt @@ -122,8 +122,9 @@ class WithdrawalIntegrationTest : IntegrationTestSupport() { // 알림·역조회 무관한 fixture 라 PROCESSING 상태로 최소 행만 만든다. status·item_id·timestamp 가 NOT NULL. private fun insertItemSnapshot(itemId: Long): Long { jdbcTemplate.update( - "INSERT INTO item_snapshots (item_id, status, created_at, updated_at) VALUES (?, 'PROCESSING', NOW(6), NOW(6))", + "INSERT INTO item_snapshots (item_id, status, created_by, created_at, updated_at) VALUES (?, 'PROCESSING', ?, NOW(6), NOW(6))", itemId, + uuidToBytes(UUID.randomUUID()), ) return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long::class.java)!! } @@ -202,6 +203,10 @@ class WithdrawalIntegrationTest : IntegrationTestSupport() { insertUserDetail(userId) insertUserDevice(userId) val wishId = insertWish(userId) + jdbcTemplate.update( + "INSERT INTO wish_memos (wish_id, content, written_at, created_at, updated_at) VALUES (?, '메모', NOW(6), NOW(6), NOW(6))", + wishId, + ) val notificationId = insertNotification(userId) mockMvc() @@ -241,6 +246,13 @@ class WithdrawalIntegrationTest : IntegrationTestSupport() { wishId, ) assertEquals(0L, wishCount, "wish 가 하드삭제되어야 한다") + val memoCount = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM wish_memos WHERE wish_id = ?", + Long::class.java, + wishId, + ) + assertEquals(0L, memoCount, "위시 메모가 하드삭제되어야 한다") // notifications 하드삭제 val notiCount = diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishDisplayIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishDisplayIntegrationTest.kt index a384cd117..b15dbbf52 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishDisplayIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishDisplayIntegrationTest.kt @@ -1,18 +1,25 @@ package com.depromeet.piki.wishlist.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.completeLatest +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.linkItem import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishRepository +import jakarta.persistence.EntityManager import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders @@ -26,12 +33,9 @@ import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext -import java.time.LocalDateTime import java.util.UUID -// 카드 표시값 파생(#857·#1051)의 위시 계약 검증 — 화면값은 포인터가 아니라 "내 맥락의 값 vs 공유 기계 READY" 로 계산한다. -// 분기 망라는 ItemVersionsTest(단위)가 맡고, 여기는 목록·단건 API 가 그 규칙을 실제로 타는지와 포인터가 판정에 -// 끼어들지 않는지를 HTTP 경계에서 고정한다. 시딩은 저장소 직접 적재 — @Transactional 롤백 격리. +// 분기 망라는 ItemVersionsTest(단위)가 맡고, 여기는 목록·단건 API 가 표시값 규칙(#857·#1051)을 실제로 타는지 HTTP 경계에서 고정한다 @Transactional class WishDisplayIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var webApplicationContext: WebApplicationContext @@ -40,26 +44,32 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository + @Autowired private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository + @Autowired private lateinit var wishRepository: WishRepository + @Autowired private lateinit var itemParsingService: ItemParsingService + + @Autowired private lateinit var entityManager: EntityManager + @Autowired private lateinit var jdbcTemplate: JdbcTemplate @Autowired private lateinit var jwtProvider: JwtProvider @Test - fun `포인터가 옛 버전이어도 위시 목록·단건은 그 상품의 마지막 기계 READY 값을 보여준다`() { + fun `남이 만든 새 기계 READY 가 있으면 위시 목록·단건은 그 값을 보여준다`() { val mockMvc = buildMockMvc() val userA = UUID.randomUUID() insertMember(userA) val itemId = saveItem("https://shop.example.com/products/display-1") - val v1 = saveVersion(itemId, "옛 기계값", 100_000, ItemSnapshotSource.SERVER, by = userA) - val wishId = saveWish(userA, itemId, v1) - // 다른 참조(다른 위시·갱신)가 만든 새 기계 버전 — A 의 포인터는 여전히 v1 이다. - saveVersion(itemId, "새 기계값", 90_000, ItemSnapshotSource.SERVER, by = UUID.randomUUID()) + saveVersion(itemId, "옛 기계값", 100_000, false, by = userA) + val wishId = saveWish(userA, itemId) + saveVersion(itemId, "새 기계값", 90_000, false, by = UUID.randomUUID()) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].item.id").value(itemId)) .andExpect(jsonPath("$.data[0].item.name").value("새 기계값")) .andExpect(jsonPath("$.data[0].item.price").value(90_000)) .andExpect(jsonPath("$.data[0].item.sourcePlatform").value("example")) @@ -71,17 +81,17 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() { } @Test - fun `수기가 최신이면 수기를 놓은 위시만 수기값을 보고 같은 상품의 다른 위시는 기계값을 본다`() { + fun `사용자 입력이 최신이면 입력한 위시만 입력값을 보고 같은 상품의 다른 위시는 기계값을 본다`() { val mockMvc = buildMockMvc() val userA = UUID.randomUUID() val userB = UUID.randomUUID() insertMember(userA) insertMember(userB) val itemId = saveItem("https://shop.example.com/products/display-2") - val machine = saveVersion(itemId, "기계값", 100_000, ItemSnapshotSource.SERVER, by = userB) - val manual = saveVersion(itemId, "A의 수기값", 80_000, ItemSnapshotSource.MANUAL, by = userA) - saveWish(userA, itemId, manual) - saveWish(userB, itemId, machine) + saveVersion(itemId, "기계값", 100_000, false, by = userB) + saveVersion(itemId, "A의 수기값", 80_000, true, by = userA) + saveWish(userA, itemId) + saveWish(userB, itemId) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) @@ -96,16 +106,15 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() { } @Test - fun `수기 뒤에 새 기계 READY 가 생기면 수기를 놓은 위시도 기계값으로 돌아간다`() { + fun `사용자 입력 뒤에 새 기계 READY 가 생기면 입력한 위시도 기계값으로 돌아간다`() { val mockMvc = buildMockMvc() val userA = UUID.randomUUID() insertMember(userA) val itemId = saveItem("https://shop.example.com/products/display-3") - saveVersion(itemId, "기계값", 100_000, ItemSnapshotSource.SERVER, by = userA) - val manual = saveVersion(itemId, "A의 수기값", 80_000, ItemSnapshotSource.MANUAL, by = userA) - saveWish(userA, itemId, manual) - // 수기보다 새로운 기계 READY(누가 새로고침했든) — 수기 존중은 여기서 끝난다. - saveVersion(itemId, "더 새 기계값", 95_000, ItemSnapshotSource.SERVER, by = UUID.randomUUID()) + saveVersion(itemId, "기계값", 100_000, false, by = userA) + saveVersion(itemId, "A의 수기값", 80_000, true, by = userA) + saveWish(userA, itemId) + saveVersion(itemId, "더 새 기계값", 95_000, false, by = UUID.randomUUID()) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) @@ -115,59 +124,68 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() { } @Test - fun `내가 시작한 갱신은 기계 READY 가 있어도 진행 중 상태를 보인다`() { + fun `내 새로고침이 진행 중이면 refreshing 이 켜지고 값과 상태는 새로고침 전 그대로다`() { val mockMvc = buildMockMvc() val userA = UUID.randomUUID() insertMember(userA) - val itemId = saveItem("https://shop.example.com/products/display-5") - saveVersion(itemId, "기계값", 100_000, ItemSnapshotSource.SERVER, by = userA) - val inProgress = itemSnapshotRepository.save(ItemSnapshot.pending(itemId, requestedBy = userA)) - saveWish(userA, itemId, inProgress.getId()) + val url = "https://shop.example.com/products/display-5" + val itemId = saveItem(url) + saveVersion(itemId, "기계값", 100_000, false, by = userA) + val wishId = saveWish(userA, itemId) + itemParseOutboxRepository.save(ItemParseOutbox.of(ItemSlot.wish(wishId), ParsePurpose.REFRESH, ParseInput.Link(ProductLink.parse(url)))) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) .andExpect(status().isOk) - .andExpect(jsonPath("$.data[0].item.status").value("PENDING")) - .andExpect(jsonPath("$.data[0].item.name").doesNotExist()) + .andExpect(jsonPath("$.data[0].item.status").value("READY")) + .andExpect(jsonPath("$.data[0].item.refreshing").value(true)) + .andExpect(jsonPath("$.data[0].item.name").value("기계값")) + .andExpect(jsonPath("$.data[0].item.price").value(100_000)) } @Test - fun `남이 시작한 갱신은 내 카드에 보이지 않는다 - 내 카드는 완성 값을 그대로 본다`() { - // 불변식(#1051): 남의 새로고침 진행 중·남의 미완은 내 카드에 새어 들어오지 않는다. 포인터가 아니라 행의 만든 사람으로 가른다. + fun `남의 새로고침 진행과 남의 미완은 내 카드에 보이지 않는다`() { val mockMvc = buildMockMvc() val userA = UUID.randomUUID() val userB = UUID.randomUUID() insertMember(userA) - val itemId = saveItem("https://shop.example.com/products/display-6") - val machine = saveVersion(itemId, "기계값", 100_000, ItemSnapshotSource.SERVER, by = userA) - saveWish(userA, itemId, machine) - itemSnapshotRepository.save(ItemSnapshot.pending(itemId, requestedBy = userB)) - saveVersion(itemId, "B의 미완", null, ItemSnapshotSource.SERVER, by = userB, status = ItemStatus.INCOMPLETE) + val url = "https://shop.example.com/products/display-6" + val itemId = saveItem(url) + saveVersion(itemId, "기계값", 100_000, false, by = userA) + saveWish(userA, itemId) + val wishOfB = saveWish(userB, itemId) + itemParseOutboxRepository.save(ItemParseOutbox.of(ItemSlot.wish(wishOfB), ParsePurpose.REFRESH, ParseInput.Link(ProductLink.parse(url)))) + saveVersion(itemId, "B의 미완", null, false, by = userB) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) .andExpect(status().isOk) .andExpect(jsonPath("$.data[0].item.status").value("READY")) + .andExpect(jsonPath("$.data[0].item.refreshing").value(false)) .andExpect(jsonPath("$.data[0].item.name").value("기계값")) } @Test - fun `새로고침이 실패해도 내 수기값은 그대로다 - 포인터가 FAILED 로 옮겨 가도 카드는 새로고침 전과 같다`() { + fun `새로고침이 실패해도 내 입력값과 READY 는 그대로다`() { val mockMvc = buildMockMvc() val userA = UUID.randomUUID() insertMember(userA) - val itemId = saveItem("https://shop.example.com/products/display-7") - // 추출 실패를 수기로 복구해 수기값이 유일한 값인 상품 — 새로고침이 실패하면 옛 규칙에선 빈 카드가 됐다. - saveVersion(itemId, "수기 복구값", 70_000, ItemSnapshotSource.MANUAL, by = userA) - val failed = - itemSnapshotRepository.save( - ItemSnapshot.pending(itemId, requestedBy = userA).apply { markFailed() }, - ) - saveWish(userA, itemId, failed.getId()) + val url = "https://shop.example.com/products/display-7" + val itemId = saveItem(url) + saveVersion(itemId, "수기 복구값", 70_000, true, by = userA) + val wishId = saveWish(userA, itemId) + val failedRefresh = + ItemParseOutbox.of(ItemSlot.wish(wishId), ParsePurpose.REFRESH, ParseInput.Link(ProductLink.parse(url))).apply { + claim() + fail() + } + itemParseOutboxRepository.save(failedRefresh) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].item.status").value("READY")) + .andExpect(jsonPath("$.data[0].item.refreshing").value(false)) .andExpect(jsonPath("$.data[0].item.name").value("수기 복구값")) .andExpect(jsonPath("$.data[0].item.price").value(70_000)) } @@ -178,10 +196,9 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() { val userA = UUID.randomUUID() insertMember(userA) val itemId = saveItem("https://shop.example.com/products/display-8") - saveVersion(itemId, "기계값", 100_000, ItemSnapshotSource.SERVER, by = userA) - val incomplete = - saveVersion(itemId, "일부만", null, ItemSnapshotSource.SERVER, by = userA, status = ItemStatus.INCOMPLETE) - saveWish(userA, itemId, incomplete) + saveVersion(itemId, "기계값", 100_000, false, by = userA) + saveVersion(itemId, "일부만", null, false, by = userA) + saveWish(userA, itemId) mockMvc .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userA)}")) @@ -191,36 +208,72 @@ class WishDisplayIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data[0].item.price").doesNotExist()) } - private fun saveItem(url: String): Long = itemRepository.save(Item(link = ProductLink.parse(url))).getId() + @Test + fun `단축 링크로 담은 위시는 파싱 전엔 등록 링크로, 성공 뒤엔 최종 상품 주소로 출처 주소와 몰 이름을 함께 바꾼다`() { + val mockMvc = buildMockMvc() + val userA = UUID.randomUUID() + insertMember(userA) + val shortUrl = "https://link.shorty.io/display-9" + val finalUrl = "https://shop.example.com/products/display-9" + val wish = wishRepository.save(Wish(userId = userA, itemId = null)) + itemParseOutboxRepository.save(ItemParseOutbox.of(wish.slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(shortUrl)))) + + expectSource(mockMvc, userA, wish.getId(), shortUrl, "shorty") + + itemParsingService.completeLatest( + entityManager, + wish.slot, + ProductSnapshot(name = "상품", price = 10_000, currency = "KRW", imageUrl = "https://cdn.example.com/p/9.jpg", finalUrl = ProductLink.parse(finalUrl)), + ) + + expectSource(mockMvc, userA, wish.getId(), finalUrl, "example") + } + + private fun expectSource( + mockMvc: MockMvc, + userId: UUID, + wishId: Long, + sourceUrl: String, + sourcePlatform: String, + ) { + mockMvc + .perform(get("/api/v1/wishlists").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].item.sourceUrl").value(sourceUrl)) + .andExpect(jsonPath("$.data[0].item.sourcePlatform").value(sourcePlatform)) + mockMvc + .perform(get("/api/v1/wishlists/$wishId").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.sourceUrl").value(sourceUrl)) + .andExpect(jsonPath("$.data.item.sourcePlatform").value(sourcePlatform)) + } + + private fun saveItem(url: String): Long = itemRepository.save(linkItem(url)).getId() + // 가격이 없으면 이미지도 비워 INCOMPLETE 버전이 된다 private fun saveVersion( itemId: Long, name: String, price: Int?, - source: ItemSnapshotSource?, - by: UUID?, - status: ItemStatus = ItemStatus.READY, + userInput: Boolean, + by: UUID, ): Long = itemSnapshotRepository .save( - ItemSnapshot( + itemVersion( itemId = itemId, name = name, price = price, - currency = "KRW", imageUrl = price?.let { "https://cdn.example.com/p/$it.jpg" }, - status = status, - extractedAt = LocalDateTime.now(), - source = source, - createdBy = by, + userInput = userInput, + createdBy = by, ), ).getId() private fun saveWish( userId: UUID, itemId: Long, - snapshotId: Long, - ): Long = wishRepository.save(Wish(userId = userId, waitingSnapshotId = snapshotId, itemId = itemId)).getId() + ): Long = wishRepository.save(Wish(userId = userId, itemId = itemId)).getId() private fun buildMockMvc(): MockMvc = MockMvcBuilders diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishPriceHistoryIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishPriceHistoryIntegrationTest.kt index b2542ebb8..c3aad9a07 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishPriceHistoryIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishPriceHistoryIntegrationTest.kt @@ -1,18 +1,24 @@ package com.depromeet.piki.wishlist.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.linkItem import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishRepository +import jakarta.persistence.EntityManager +import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders @@ -29,13 +35,7 @@ import org.springframework.web.context.WebApplicationContext import java.time.LocalDateTime import java.util.UUID -// 상세 조회(GET /wishlists/{wishId})가 함께 내려주는 가격 이력의 계약을 고정한다. 동기 조회라 @Transactional 자동 롤백으로 격리하고, -// 한 item 에 버전을 직접 쌓아 "갱신·새로고침·수기 수정이 누적된 상태"를 시딩한다. -// -// 고정하는 계약: 출처가 남은 READY 버전(서버 추출·수기 모두, 편집자 무관)을 최신순으로, 출처 미상(null)· -// 미완성(PENDING/FAILED)·soft-delete 는 제외, 상한 50건. 수기는 editedByMe 로 본인 것인지만 구분하고 -// 편집자 식별자는 내리지 않는다. 그리고 **item 과 priceHistory 가 별개의 축**이라는 것 — item 은 맥락 스코프를 거친 -// 표시값이고 이력은 그 필터를 타지 않아, 이력 첫 항목이 표시값과 다를 수 있다. +// item 은 맥락 스코프를 거친 표시값이고 priceHistory 는 그 필터를 타지 않아, 이력 첫 항목이 표시값과 다를 수 있다 @Transactional class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { @Autowired @@ -47,6 +47,9 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotRepository: ItemSnapshotRepository + @Autowired + private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository + @Autowired private lateinit var wishRepository: WishRepository @@ -56,6 +59,9 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var jwtProvider: JwtProvider + @Autowired + private lateinit var entityManager: EntityManager + @Test fun `상세 조회하면 표시값과 가격 이력이 함께 내려가고 이력은 최신순이다`() { val mockMvc = buildMockMvc() @@ -64,8 +70,8 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { val itemId = saveItem("https://shop.example.com/products/history") saveMachineReady(itemId, "옛 상품", 119_000, LocalDateTime.of(2026, 5, 1, 10, 0)) saveMachineReady(itemId, "중간 상품", 99_000, LocalDateTime.of(2026, 5, 15, 10, 0)) - val active = saveMachineReady(itemId, "현재 상품", 109_000, LocalDateTime.of(2026, 6, 1, 10, 0)) - val wishId = saveWish(userId, active) + saveMachineReady(itemId, "현재 상품", 109_000, LocalDateTime.of(2026, 6, 1, 10, 0)) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -75,84 +81,71 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data.wish.id").value(wishId)) .andExpect(jsonPath("$.data.item.id").value(itemId)) .andExpect(jsonPath("$.data.item.status").value("READY")) - .andExpect(jsonPath("$.data.item.source").value("SERVER")) + .andExpect(jsonPath("$.data.item.method").value("STRUCTURED")) .andExpect(jsonPath("$.data.item.name").value("현재 상품")) .andExpect(jsonPath("$.data.item.price").value(109_000)) .andExpect(jsonPath("$.data.item.sourceUrl").value("https://shop.example.com/products/history")) // 백오피스(source_platforms) 미등록 도메인 — host 에서 유도한 임시 표시명(등록 가능 도메인의 첫 라벨)이 나간다. .andExpect(jsonPath("$.data.item.sourcePlatform").value("example")) .andExpect(jsonPath("$.data.priceHistory.length()").value(3)) - // 최신순(id desc). 항목은 가격과 추출시각 둘뿐이다. .andExpect(jsonPath("$.data.priceHistory[0].price").value(109_000)) - // 시각은 UTC wall-clock 으로 저장하고 응답에서 KST(+09:00)로 변환한다(JacksonConfig). - // KST 는 DST 가 없는 고정 오프셋이라 이 단언은 실행 환경 타임존과 무관하게 결정적이다. + // UTC 로 저장하고 응답에서 KST 로 바꾼다(JacksonConfig). KST 는 DST 가 없어 실행 환경 타임존과 무관하다 .andExpect(jsonPath("$.data.priceHistory[0].extractedAt").value("2026-06-01T19:00:00+09:00")) - .andExpect(jsonPath("$.data.priceHistory[0].source").value("SERVER")) + .andExpect(jsonPath("$.data.priceHistory[0].method").value("STRUCTURED")) .andExpect(jsonPath("$.data.priceHistory[1].price").value(99_000)) .andExpect(jsonPath("$.data.priceHistory[2].price").value(119_000)) - // 이력이 별도 API 로 갈리지 않게 한 결과 — 상세 응답 하나로 끝난다. .andExpect(jsonPath("$.data.priceHistory[0].snapshotId").doesNotExist()) .andExpect(jsonPath("$.data.priceHistory[0].isActive").doesNotExist()) .andExpect(jsonPath("$.data.activeSnapshotId").doesNotExist()) } @Test - fun `가격 이력에는 타인의 수기도 담기고 editedByMe 로 구분된다 - 출처 미상만 빠진다`() { - // 이력은 그 상품의 가격 기록이라 편집자를 가리지 않는다. 다만 타인의 값은 어떤 조건에서 본 것인지 알 수 없어 - // editedByMe 로 구분해 주고, 편집자 식별자(UUID)는 개인정보라 응답에 싣지 않는다. - // 출처 미상(도입 전 행)만 빠진다 — 서버 추출인지 사용자 입력인지 소급 판정할 수 없기 때문이다. - // - // 동시에 이 케이스는 **item 과 priceHistory 가 별개의 축**임을 고정한다. 이력은 편집자를 가리지 않는 그 상품의 - // 기록이고, 표시값은 내 맥락의 값 vs 공유 기계 READY 의 최신순(#1051)이다 — 포인터가 기계 버전을 가리켜도 내 수기 - // (99,000원)가 LLM 버전(97,000원)보다 새로우므로 카드는 내 수기값이다. + fun `가격 이력에는 타인의 입력도 담기고 editedByMe 로 구분된다 - 출처 미상만 빠진다`() { + // 출처 미상(도입 전 행)은 서버 추출인지 사용자 입력인지 소급 판정할 수 없어 뺀다. 편집자 UUID 는 개인정보라 싣지 않는다 val mockMvc = buildMockMvc() val me = UUID.randomUUID() val other = UUID.randomUUID() insertMember(me) val itemId = saveItem("https://shop.example.com/products/source-labels") - saveVersion(itemId, "출처 미상", 90_000, source = null, createdBy = null) - val machine = saveVersion(itemId, "기계 추출", 95_000, source = ItemSnapshotSource.SERVER, createdBy = null) - saveVersion(itemId, "타인 수기", 80_000, source = ItemSnapshotSource.MANUAL, createdBy = other) - saveVersion(itemId, "LLM 추출", 97_000, source = ItemSnapshotSource.SERVER_LLM, createdBy = null) - saveVersion(itemId, "내 수기", 99_000, source = ItemSnapshotSource.MANUAL, createdBy = me) - val wishId = saveWish(me, machine) + saveVersion(itemId, "출처 미상", 90_000, method = null, userInput = false, createdBy = UUID.randomUUID()) + saveVersion(itemId, "기계 추출", 95_000, method = ExtractionMethod.STRUCTURED, userInput = false, createdBy = UUID.randomUUID()) + saveVersion(itemId, "타인 수기", 80_000, method = null, userInput = true, createdBy = other) + saveVersion(itemId, "LLM 추출", 97_000, method = ExtractionMethod.LLM, userInput = false, createdBy = UUID.randomUUID()) + saveVersion(itemId, "내 수기", 99_000, method = null, userInput = true, createdBy = me) + val wishId = saveWish(me, itemId) mockMvc .perform( get("/api/v1/wishlists/$wishId") .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(me)}"), ).andExpect(status().isOk) - // 다섯 버전 중 넷이 남는다 — 출처 미상만 빠진다. .andExpect(jsonPath("$.data.priceHistory.length()").value(4)) .andExpect(jsonPath("$.data.priceHistory[0].price").value(99_000)) - .andExpect(jsonPath("$.data.priceHistory[0].source").value("MANUAL")) + .andExpect(jsonPath("$.data.priceHistory[0].userInput").value(true)) .andExpect(jsonPath("$.data.priceHistory[0].editedByMe").value(true)) .andExpect(jsonPath("$.data.priceHistory[1].price").value(97_000)) - .andExpect(jsonPath("$.data.priceHistory[1].source").value("SERVER_LLM")) - // 기계 추출값은 "누가 넣었나" 가 의미 없어 editedByMe 를 내리지 않는다. + .andExpect(jsonPath("$.data.priceHistory[1].method").value("LLM")) .andExpect(jsonPath("$.data.priceHistory[1].editedByMe").doesNotExist()) .andExpect(jsonPath("$.data.priceHistory[2].price").value(80_000)) - .andExpect(jsonPath("$.data.priceHistory[2].source").value("MANUAL")) + .andExpect(jsonPath("$.data.priceHistory[2].userInput").value(true)) .andExpect(jsonPath("$.data.priceHistory[2].editedByMe").value(false)) .andExpect(jsonPath("$.data.priceHistory[3].price").value(95_000)) - .andExpect(jsonPath("$.data.priceHistory[3].source").value("SERVER")) + .andExpect(jsonPath("$.data.priceHistory[3].method").value("STRUCTURED")) .andExpect(jsonPath("$.data.item.id").value(itemId)) - // 표시값은 파생 규칙(#1051)대로 내 최신 수기 — 그보다 새로운 서버 READY 가 없으므로 포인터와 무관하게 이긴다. + // 내 입력보다 새로운 기계 READY 가 없어 표시값은 내 입력이다(#1051) .andExpect(jsonPath("$.data.item.price").value(99_000)) - .andExpect(jsonPath("$.data.item.source").value("MANUAL")) + .andExpect(jsonPath("$.data.item.userInput").value(true)) } @Test fun `본인이 고친 값은 표시값이 되고 이력에도 남는다`() { - // 포인터가 내 수기를 가리키면 표시값이 그 값이 된다(수기 존중, 맥락 스코프). 이력에도 함께 남는다. val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/manual-display") saveMachineReady(itemId, "기계 값", 109_000, LocalDateTime.of(2026, 6, 1, 10, 0)) - // 기계 READY 보다 뒤에 쌓인 내 수기 — 표시값이 되고(수기 존중) 이력에도 맨 앞에 온다. - val myEdit = saveVersion(itemId, "내가 고친 값", 99_000, source = ItemSnapshotSource.MANUAL, createdBy = userId) - val wishId = saveWish(userId, myEdit) + saveVersion(itemId, "내가 고친 값", 99_000, method = null, userInput = true, createdBy = userId) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -160,27 +153,25 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isOk) .andExpect(jsonPath("$.data.item.price").value(99_000)) - .andExpect(jsonPath("$.data.item.source").value("MANUAL")) + .andExpect(jsonPath("$.data.item.userInput").value(true)) .andExpect(jsonPath("$.data.priceHistory.length()").value(2)) .andExpect(jsonPath("$.data.priceHistory[0].price").value(99_000)) - .andExpect(jsonPath("$.data.priceHistory[0].source").value("MANUAL")) + .andExpect(jsonPath("$.data.priceHistory[0].userInput").value(true)) .andExpect(jsonPath("$.data.priceHistory[0].editedByMe").value(true)) .andExpect(jsonPath("$.data.priceHistory[1].price").value(109_000)) - .andExpect(jsonPath("$.data.priceHistory[1].source").value("SERVER")) + .andExpect(jsonPath("$.data.priceHistory[1].method").value("STRUCTURED")) } @Test fun `추출이 한 번도 성공하지 못한 상품도 본인이 입력했다면 이력이 남는다`() { - // 차단·403 몰처럼 파싱이 계속 실패하는 상품에서는 본인 입력이 유일한 가격 기록이다. - // 이것까지 빼면 그 상품은 가격 추적 자체가 불가능해진다. + // 파싱이 계속 실패하는 몰에서는 본인 입력이 유일한 가격 기록이다 val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/never-parsed") - itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, status = ItemStatus.FAILED)) - saveVersion(itemId, "직접 입력 1차", 120_000, source = ItemSnapshotSource.MANUAL, createdBy = userId) - val second = saveVersion(itemId, "직접 입력 2차", 110_000, source = ItemSnapshotSource.MANUAL, createdBy = userId) - val wishId = saveWish(userId, second) + saveVersion(itemId, "직접 입력 1차", 120_000, method = null, userInput = true, createdBy = userId) + saveVersion(itemId, "직접 입력 2차", 110_000, method = null, userInput = true, createdBy = userId) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -189,21 +180,20 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) .andExpect(jsonPath("$.data.priceHistory.length()").value(2)) .andExpect(jsonPath("$.data.priceHistory[0].price").value(110_000)) - .andExpect(jsonPath("$.data.priceHistory[0].source").value("MANUAL")) + .andExpect(jsonPath("$.data.priceHistory[0].userInput").value(true)) .andExpect(jsonPath("$.data.priceHistory[1].price").value(120_000)) } @Test - fun `가격 이력에는 READY 버전만 포함되고 PENDING·FAILED 는 제외된다`() { + fun `가격 이력에는 이름·가격·이미지가 다 있는 버전만 포함되고 INCOMPLETE 는 제외된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/mixed") - val ready = saveMachineReady(itemId, "완성 버전", 50_000, LocalDateTime.now()) - // 같은 item 에 가격 없는 버전들을 섞어 둔다 — 이력에서 빠져야 한다. - itemSnapshotRepository.save(ItemSnapshot.pending(itemId, requestedBy = userId)) - itemSnapshotRepository.save(ItemSnapshot(itemId = itemId, status = ItemStatus.FAILED)) - val wishId = saveWish(userId, ready) + saveMachineReady(itemId, "완성 버전", 50_000, LocalDateTime.now()) + itemSnapshotRepository.save(itemVersion(itemId, name = null, price = 40_000, createdBy = userId)) + itemSnapshotRepository.save(itemVersion(itemId, name = "이미지 없음", price = 45_000, imageUrl = null, createdBy = userId)) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -216,25 +206,15 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { @Test fun `soft-delete 된 READY snapshot 은 가격 이력에서 제외된다`() { - // production 쿼리의 deletedAt IS NULL 필터 회귀 방지 — status·source 필터와 별개로 삭제된 행이 새지 않는지 고정한다. val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/soft-deleted") - val live = saveMachineReady(itemId, "살아있는 버전", 30_000, LocalDateTime.now()) + saveMachineReady(itemId, "살아있는 버전", 30_000, LocalDateTime.now()) itemSnapshotRepository.save( - ItemSnapshot( - itemId = itemId, - name = "삭제된 버전", - price = 99_000, - currency = "KRW", - imageUrl = "https://cdn.example.com/p/deleted.jpg", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - source = ItemSnapshotSource.SERVER, - ).apply { deletedAt = LocalDateTime.now() }, + itemVersion(itemId, name = "삭제된 버전", price = 99_000).apply { deletedAt = LocalDateTime.now() }, ) - val wishId = saveWish(userId, live) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -246,38 +226,55 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { } @Test - fun `아직 추출 성공 이력이 없으면 빈 배열이고 status 로 대기와 실패를 가른다`() { + fun `아이템이 확정되기 전이면 이력은 빈 배열이고 status 로 파싱 중과 실패를 가른다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) - val itemId = saveItem("https://shop.example.com/products/pending-only") - val pending = itemSnapshotRepository.save(ItemSnapshot.pending(itemId, requestedBy = userId)).getId() - val wishId = saveWish(userId, pending) + val parsingUrl = "https://shop.example.com/products/parsing-only" + val failedUrl = "https://shop.example.com/products/failed-only" + val parsingWishId = saveUnresolvedWish(userId) + val failedWishId = saveUnresolvedWish(userId) + itemParseOutboxRepository.save( + ItemParseOutbox.of(ItemSlot.wish(failedWishId), ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(failedUrl))).apply { + claim() + fail() + }, + ) + itemParseOutboxRepository.save( + ItemParseOutbox.of(ItemSlot.wish(parsingWishId), ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(parsingUrl))), + ) mockMvc .perform( - get("/api/v1/wishlists/$wishId") + get("/api/v1/wishlists/$parsingWishId") .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isOk) - // 값이 비어 있을 때 "기다린다"와 "그만 기다린다"를 가르는 유일한 수단이 status 다. - .andExpect(jsonPath("$.data.item.status").value("PENDING")) + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("PARSING")) + .andExpect(jsonPath("$.data.item.sourceUrl").value(parsingUrl)) .andExpect(jsonPath("$.data.item.price").doesNotExist()) .andExpect(jsonPath("$.data.priceHistory.length()").value(0)) + mockMvc + .perform( + get("/api/v1/wishlists/$failedWishId") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("FAILED")) + .andExpect(jsonPath("$.data.priceHistory.length()").value(0)) } @Test fun `가격 이력은 최신 50건까지만 내려간다`() { - // item 을 여러 사용자가 공유해 새로고침이 누적되므로 상한이 없으면 응답이 시간에 비례해 자란다. + // 여러 사용자의 새로고침이 누적되므로 상한이 없으면 응답이 시간에 비례해 자란다 val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/many-versions") - // 55건을 1,000원씩 올려가며 쌓는다 — 최신 50건이면 가장 오래된 5건(1,000~5,000원)이 잘린다. - var last = 0L repeat(55) { i -> - last = saveMachineReady(itemId, "버전 $i", (i + 1) * 1_000, LocalDateTime.now()) + saveMachineReady(itemId, "버전 $i", (i + 1) * 1_000, LocalDateTime.now()) } - val wishId = saveWish(userId, last) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -285,20 +282,18 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isOk) .andExpect(jsonPath("$.data.priceHistory.length()").value(50)) - // 최신순이라 맨 앞이 마지막에 쌓은 55,000원, 맨 뒤가 6,000원(1~5,000원은 상한에 잘림) .andExpect(jsonPath("$.data.priceHistory[0].price").value(55_000)) .andExpect(jsonPath("$.data.priceHistory[49].price").value(6_000)) } @Test fun `옛 가격 히스토리 엔드포인트는 더 이상 존재하지 않는다`() { - // 상세 조회로 흡수됐다. 남아 있으면 표시값 파생을 타지 않는 옛 계약이 함께 사는 것이라 회귀로 고정한다. val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/gone") - val snapshot = saveMachineReady(itemId, "상품", 10_000, LocalDateTime.now()) - val wishId = saveWish(userId, snapshot) + saveMachineReady(itemId, "상품", 10_000, LocalDateTime.now()) + val wishId = saveWish(userId, itemId) mockMvc .perform( @@ -309,14 +304,13 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { @Test fun `탈퇴한 회원의 살아있는 access token 으로 상세 조회하면 409 가 반환된다`() { - // 소유권 검증(verifyOwnedBy)만으로는 탈퇴 여부를 못 본다 — 본인 위시이므로 그대로 통과해버린다. - // 회원 가드가 활성 조회로 끊어야 탈퇴 회원이 자기 가격 이력을 계속 읽는 것을 막는다 (#691). + // 본인 위시라 소유권 검증은 통과하므로 회원 가드가 활성 조회로 끊어야 한다(#691) val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val itemId = saveItem("https://shop.example.com/products/withdrawn") - val snapshot = saveMachineReady(itemId, "내 상품", 10_000, LocalDateTime.now()) - val wishId = saveWish(userId, snapshot) + saveMachineReady(itemId, "내 상품", 10_000, LocalDateTime.now()) + val wishId = saveWish(userId, itemId) jdbcTemplate.update("UPDATE users SET deleted_at = NOW(6) WHERE id = ?", uuidToBytes(userId)) mockMvc @@ -328,60 +322,59 @@ class WishPriceHistoryIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.detail").value("탈퇴한 계정이에요.")) } - private fun saveItem(url: String): Long = itemRepository.save(Item(ProductLink.parse(url))).getId() + private fun saveItem(url: String): Long = itemRepository.save(linkItem(url)).getId() - // 출처를 명시해 쌓는 READY 시딩. 순서(최신순)는 id 단조증가에 맡긴다. + // 최신순은 id 단조증가에 맡긴다 private fun saveVersion( itemId: Long, name: String, price: Int, - source: ItemSnapshotSource?, - createdBy: UUID?, + method: ExtractionMethod?, + userInput: Boolean, + createdBy: UUID, ): Long = itemSnapshotRepository .save( - ItemSnapshot( + itemVersion( itemId = itemId, name = name, price = price, - currency = "KRW", imageUrl = "https://cdn.example.com/p/$price.jpg", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - source = source, + method = method, + userInput = userInput, createdBy = createdBy, ), ).getId() - // 기계 추출(SERVER) READY 시딩 — 출처가 기록돼 있어 가격 이력에 실린다. - // 수기(MANUAL)도 편집자와 무관하게 이력에 실리므로(그쪽은 saveVersion 을 쓴다) 이 헬퍼는 기계 출처 케이스 전용이다. + // created_at 은 auditing 이 채우므로 저장 뒤 SQL 로 바꾸고 영속성 컨텍스트를 비운다 private fun saveMachineReady( itemId: Long, name: String, price: Int, - extractedAt: LocalDateTime, - ): Long = - itemSnapshotRepository - .save( - ItemSnapshot( - itemId = itemId, - name = name, - price = price, - currency = "KRW", - imageUrl = "https://cdn.example.com/p/$price.jpg", - status = ItemStatus.READY, - extractedAt = extractedAt, - source = ItemSnapshotSource.SERVER, - ), - ).getId() + createdAt: LocalDateTime, + ): Long { + val id = + itemSnapshotRepository + .save( + itemVersion( + itemId = itemId, + name = name, + price = price, + imageUrl = "https://cdn.example.com/p/$price.jpg", + method = ExtractionMethod.STRUCTURED, + ), + ).getId() + jdbcTemplate.update("UPDATE item_snapshots SET created_at = ? WHERE id = ?", createdAt, id) + entityManager.clear() + return id + } private fun saveWish( userId: UUID, - snapshotId: Long, - ): Long { - val itemId = requireNotNull(itemSnapshotRepository.findById(snapshotId)) { "snapshot $snapshotId 없음" }.itemId - return wishRepository.save(Wish(userId = userId, waitingSnapshotId = snapshotId, itemId = itemId)).getId() - } + itemId: Long, + ): Long = wishRepository.save(Wish(userId = userId, itemId = itemId)).getId() + + private fun saveUnresolvedWish(userId: UUID): Long = wishRepository.save(Wish(userId = userId, itemId = null)).getId() private fun buildMockMvc(): MockMvc = MockMvcBuilders diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishRefreshConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishRefreshConcurrencyIntegrationTest.kt index 74cb3a471..ee139eeed 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishRefreshConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishRefreshConcurrencyIntegrationTest.kt @@ -1,15 +1,23 @@ package com.depromeet.piki.wishlist.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.domain.ItemParseOutbox import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose import com.depromeet.piki.item.repository.ItemJpaRepository +import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository +import com.depromeet.piki.item.service.ExtractionSettlement +import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.deleteWishDataOf +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.linkItem import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.support.wishItemIdOf import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserJpaRepository @@ -25,22 +33,16 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.context.WebApplicationContext -import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue -// 새로고침이 wish 행 락(findByIdForUpdate) 없이 돌면, 동시 요청 두 개가 각각 활성=READY(진행 중 아님)를 읽어 -// 둘 다 새 PENDING 버전을 만든다 → 같은 item 에 진행 중 snapshot 이 2개가 돼 멱등이 깨지고 추출이 중복된다. -// findByIdForUpdate 로 직렬화하면 두 번째 요청은 첫 번째 커밋 후 활성=PENDING(진행 중)을 보고 멱등 no-op 으로 끝난다. -// "새 snapshot 행이 정확히 1개만 늘어 총 2개(옛 READY 1 + 새 PENDING 1)" 가 그 직렬화의 시그니처다. -// -// 일반 통합 테스트와 달리 @Transactional 을 쓰지 않는다 — 별도 트랜잭션 동시 진행이 race 시뮬레이션의 본질이다. -// 데이터 격리는 새 UUID 를 쓰고 finally 에서 직접 정리한다. +// 별도 트랜잭션의 동시 진행이 본질이라 @Transactional 없이 새 UUID 로 격리하고 finally 에서 정리한다 class WishRefreshConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var webApplicationContext: WebApplicationContext @@ -52,32 +54,24 @@ class WishRefreshConcurrencyIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository + @Autowired private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository + + @Autowired private lateinit var itemParsingService: ItemParsingService + @Autowired private lateinit var wishJpaRepository: WishJpaRepository @Autowired private lateinit var jdbcTemplate: JdbcTemplate @Test - fun `같은 위시를 동시에 두 번 새로고침해도 행 락으로 직렬화되어 새 추출 버전이 하나만 생긴다`() { + fun `같은 위시를 동시에 두 번 새로고침해도 행 락으로 직렬화되어 파싱 요청이 하나만 쌓인다`() { + // 락이 없으면 두 요청이 모두 진행 중 요청 없음을 읽고 각자 아웃박스를 쌓아 추출이 중복된다 val userId = UUID.randomUUID() userJpaRepository.save( User(id = userId, nickname = "refresh", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), ) - - // 기존 READY 위시 시딩 — 활성 snapshot 1개(옛 READY). - val item = itemJpaRepository.save(Item(ProductLink.parse("https://shop.example.com/products/race-refresh"))) - val oldSnapshot = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = "옛 상품", - price = 10_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), - ) - val wish = wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = oldSnapshot.getId(), itemId = oldSnapshot.itemId)) - val itemId = item.getId() + val item = itemJpaRepository.save(linkItem("https://shop.example.com/products/race-refresh")) + itemSnapshotJpaRepository.save(itemVersion(item.getId(), name = "옛 상품", price = 10_000, createdBy = userId)) + val wish = wishJpaRepository.save(Wish(userId = userId, itemId = item.getId())) val mockMvc = MockMvcBuilders @@ -94,74 +88,64 @@ class WishRefreshConcurrencyIntegrationTest : IntegrationTestSupport() { val start = CountDownLatch(1) val done = CountDownLatch(2) - repeat(2) { - executor.submit { - ready.countDown() - start.await() - try { - val res = - mockMvc - .perform( - post("/api/v1/wishlists/${wish.getId()}/refresh") - .header(HttpHeaders.AUTHORIZATION, auth), - ).andReturn() - when (res.response.status) { - 200 -> status200.incrementAndGet() - else -> statusOther.incrementAndGet() + try { + repeat(2) { + executor.submit { + ready.countDown() + start.await() + try { + val res = + mockMvc + .perform( + post("/api/v1/wishlists/${wish.getId()}/refresh") + .header(HttpHeaders.AUTHORIZATION, auth), + ).andReturn() + when (res.response.status) { + 200 -> status200.incrementAndGet() + else -> statusOther.incrementAndGet() + } + } finally { + done.countDown() } - } finally { - done.countDown() } } - } - ready.await() - start.countDown() - assertTrue(done.await(10, TimeUnit.SECONDS), "동시 요청이 10초 안에 완료되어야 한다") - executor.shutdown() + assertTrue(ready.await(10, TimeUnit.SECONDS), "작업 스레드가 10초 안에 시작되어야 한다") + start.countDown() + assertTrue(done.await(10, TimeUnit.SECONDS), "동시 요청이 10초 안에 완료되어야 한다") + } finally { + executor.shutdownNow() + executor.awaitTermination(5, TimeUnit.SECONDS) + } - // 첫 요청은 새 PENDING 생성, 둘째는 락 대기 후 진행 중을 보고 멱등 no-op — 둘 다 200. assertEquals(2, status200.get(), "두 요청 모두 200 이어야 한다(생성 + 멱등)") assertEquals(0, statusOther.get()) - // 핵심 단언: 새 snapshot 행이 정확히 1개만 늘어 총 2개. 락이 없으면 동시 생성으로 3개 이상이 된다. - val snapshotCount = + val outboxCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM item_snapshots WHERE item_id = ?", + "SELECT COUNT(*) FROM item_parse_outbox WHERE slot_type = ? AND slot_id = ?", Int::class.java, - itemId, + wish.slot.type.name, + wish.slot.id, ) - assertEquals(2, snapshotCount, "옛 READY 1 + 새 PENDING 1 = 2 (락이 없으면 동시 생성으로 3+)") + assertEquals(1, outboxCount, "락이 없으면 동시 생성으로 2") } finally { - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - jdbcTemplate.deleteItems(listOf(itemId)) + jdbcTemplate.deleteWishDataOf(userId) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) } } @Test - fun `같은 위시에 새로고침과 메모 수정을 동시에 보내도 행 락으로 직렬화되어 스왑과 메모가 모두 보존된다`() { - // memo 경로가 락 없이 읽으면, UPDATE 가 전 컬럼을 쓰므로(dynamic update 아님) 동시 refresh 가 커밋한 - // snapshot_id 스왑을 memo flush 가 읽어 둔 옛 값으로 되덮는다(lost update). 행 락 직렬화가 그 손실을 막는다 — - // 어느 순서로 잡히든 "스왑된 포인터 + 저장된 메모" 둘 다 남는 것이 직렬화의 시그니처다. + fun `파싱 성공과 메모 수정이 동시에 와도 확정된 아이템과 메모가 모두 보존된다`() { val userId = UUID.randomUUID() userJpaRepository.save( User(id = userId, nickname = "memo-race", profileImage = "https://cdn.example.com/o.jpg", identityType = IdentityType.MEMBER), ) - - val item = itemJpaRepository.save(Item(ProductLink.parse("https://shop.example.com/products/race-memo"))) - val oldSnapshot = - itemSnapshotJpaRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = "옛 상품", - price = 10_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), + val url = "https://shop.example.com/products/race-memo-${UUID.randomUUID()}" + val wish = wishJpaRepository.save(Wish(userId = userId, itemId = null)) + val outbox = + itemParseOutboxRepository.save( + ItemParseOutbox.of(wish.slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(url))).apply { claim() }, ) - val wish = wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = oldSnapshot.getId(), itemId = oldSnapshot.itemId)) - val itemId = item.getId() val mockMvc = MockMvcBuilders @@ -171,38 +155,49 @@ class WishRefreshConcurrencyIntegrationTest : IntegrationTestSupport() { val auth = "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}" try { - val status200 = AtomicInteger(0) - val statusOther = AtomicInteger(0) + val succeeded = AtomicInteger(0) + val failed = AtomicInteger(0) val executor = Executors.newFixedThreadPool(2) val ready = CountDownLatch(2) val start = CountDownLatch(1) val done = CountDownLatch(2) - val requests = + val tasks: List<() -> Boolean> = listOf( - { post("/api/v1/wishlists/${wish.getId()}/refresh").header(HttpHeaders.AUTHORIZATION, auth) }, { - multipart("/api/v1/wishlists/${wish.getId()}") - .param("memo", "동시 메모") - .with { - it.method = "PATCH" - it - }.header(HttpHeaders.AUTHORIZATION, auth) + itemParsingService.markExtracted( + outbox.getId(), + ProductSnapshot( + name = "확정 상품", + price = 10_000, + currency = "KRW", + imageUrl = "https://img.example.com/a.png", + ), + ) == ExtractionSettlement.Applied(ItemStatus.READY) + }, + { + mockMvc + .perform( + multipart("/api/v1/wishlists/${wish.getId()}") + .param("memo", "동시 메모") + .with { + it.method = "PATCH" + it + }.header(HttpHeaders.AUTHORIZATION, auth), + ).andReturn() + .response.status == 200 }, ) - // 단언 실패(락 회귀로 요청 스레드 정지 등)에도 executor 를 반드시 종료한다 — non-daemon 스레드가 - // 남으면 테스트 JVM 전체가 타임아웃까지 잡힌다. + // 단언 실패에도 executor 를 종료해야 non-daemon 스레드가 테스트 JVM 을 붙잡지 않는다 try { - requests.forEach { request -> + tasks.forEach { task -> executor.submit { ready.countDown() start.await() try { - val res = mockMvc.perform(request()).andReturn() - when (res.response.status) { - 200 -> status200.incrementAndGet() - else -> statusOther.incrementAndGet() - } + if (task()) succeeded.incrementAndGet() else failed.incrementAndGet() + } catch (e: Exception) { + failed.incrementAndGet() } finally { done.countDown() } @@ -217,18 +212,13 @@ class WishRefreshConcurrencyIntegrationTest : IntegrationTestSupport() { executor.awaitTermination(5, TimeUnit.SECONDS) } - assertEquals(2, status200.get(), "두 요청 모두 200 이어야 한다") - assertEquals(0, statusOther.get()) - val row = jdbcTemplate.queryForMap("SELECT snapshot_id, memo FROM wishes WHERE id = ?", wish.getId()) - assertEquals("동시 메모", row["memo"], "메모가 저장되어야 한다") - val finalSnapshotId = (row["snapshot_id"] as Number).toLong() - assertTrue( - finalSnapshotId != oldSnapshot.getId(), - "refresh 가 스왑한 포인터가 memo 갱신의 전 컬럼 UPDATE 에 되덮이면 안 된다", - ) + assertEquals(2, succeeded.get()) + assertEquals(0, failed.get()) + val memos = jdbcTemplate.queryForList("SELECT content FROM wish_memos WHERE wish_id = ?", String::class.java, wish.getId()) + assertEquals(listOf("동시 메모"), memos, "메모가 저장되어야 한다") + assertNotNull(jdbcTemplate.wishItemIdOf(wish.getId()), "파싱이 확정한 아이템이 남아야 한다") } finally { - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - jdbcTemplate.deleteItems(listOf(itemId)) + jdbcTemplate.deleteWishDataOf(userId) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) } } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt index 859976b95..87c465145 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt @@ -1,21 +1,17 @@ package com.depromeet.piki.wishlist.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item -import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository -import com.depromeet.piki.item.service.ItemParsingService -import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.support.claimParseOutbox +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User import com.depromeet.piki.user.repository.UserJpaRepository import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishJpaRepository -import jakarta.persistence.EntityManager import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders @@ -48,10 +44,6 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository - @Autowired private lateinit var itemParsingService: ItemParsingService - - @Autowired private lateinit var entityManager: EntityManager - @Autowired private lateinit var itemJpaRepository: ItemJpaRepository @Autowired private lateinit var wishJpaRepository: WishJpaRepository @@ -327,28 +319,19 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { .findAllByTournamentIdAndNotDeleted(tournamentId) .map { it.getId() } - // 출전 카드는 상품을 버전(snapshot)으로 가리키므로 itemId 로 되찾으려면 그 단계를 거친다. private fun cardIdOf( cards: List, itemId: Long, - ): Long = - cards.first { cardId -> - val card = tournamentItemJpaRepository.findById(cardId).orElseThrow() - itemSnapshotJpaRepository.findById(card.snapshotId).orElseThrow().itemId == itemId - } + ): Long = cards.first { cardId -> tournamentItemJpaRepository.findById(cardId).orElseThrow().itemId == itemId } private fun saveWishItem( userId: UUID, name: String, ): Long { // 필요한 것은 "READY 인 위시 아이템" 이라는 상태뿐이라 등록 API 를 타지 않고 행을 직접 심는다. - val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) - val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) - wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) - itemParsingService.markExtracted( - entityManager.claimParseOutbox(snapshot), - ProductSnapshot(name = name, price = 10_000, currency = "KRW", imageUrl = "https://img.example.com/a.png"), - ) + val item = itemJpaRepository.save(imageItem()) + itemSnapshotJpaRepository.save(itemVersion(itemId = item.getId(), name = name, price = 10_000, createdBy = userId)) + wishJpaRepository.save(Wish(userId = userId, itemId = item.getId())) return item.getId() } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt index c642eb622..c0797585f 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt @@ -1,19 +1,23 @@ package com.depromeet.piki.wishlist.controller -import jakarta.persistence.EntityManager import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.common.storage.ImageStorageException -import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemErrorCode +import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubImageStorage -import com.depromeet.piki.support.claimParseOutbox +import com.depromeet.piki.support.completeLatest +import com.depromeet.piki.support.failLatest +import com.depromeet.piki.support.itemVersion import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.wishlist.controller.dto.WishlistUpdateRequest import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.service.dto.RegisterWishFromUrlCommand +import jakarta.persistence.EntityManager import org.hamcrest.Matchers.nullValue import org.hamcrest.Matchers.startsWith import org.junit.jupiter.api.Test @@ -38,9 +42,7 @@ import tools.jackson.databind.ObjectMapper import java.util.UUID import kotlin.test.assertEquals -// 조회·수정·삭제 contract 검증. 이 시나리오들의 본질은 "완성된 위시가 있을 때의 동작"이라 -// 등록(비동기) 경로를 거치지 않고 seedReadyWish 로 READY 상태를 시딩한다. -// 등록 PENDING 응답·파싱 전이는 WishlistRegisterAsyncIntegrationTest 가 검증한다. +// 등록 직후 응답과 dispatch 전이는 WishlistRegisterAsyncIntegrationTest 가 맡고, 여기는 파싱 결과를 직접 반영해 시딩한다 @Transactional class WishlistCrudIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var entityManager: EntityManager @@ -57,6 +59,9 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemParsingService: ItemParsingService + @Autowired + private lateinit var itemSnapshotRepository: ItemSnapshotRepository + @Autowired private lateinit var stubImageStorage: StubImageStorage @@ -104,19 +109,16 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { price: Int? = 10_000, currency: String? = "KRW", imageUrl: String? = "https://img.example.com/a.png", - // 기본 null(출처 미기록) — 표시값 파생 후보(기계 READY)로 만들려면 "STRUCTURED" 를 명시한다. - extractionMethod: String? = null, ): Long { - val result = wishPersistenceService.persist(userId, ProductLink.parse(url)) - itemParsingService.markExtracted( - entityManager.claimParseOutbox(result.snapshot), + val result = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(url))) + itemParsingService.completeLatest( + entityManager, + result.wish.slot, ProductSnapshot( - link = ProductLink.parse(url), name = name, price = price, currency = currency, imageUrl = imageUrl, - extractionMethod = extractionMethod, ), ) return result.wish.getId() @@ -126,15 +128,15 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { userId: UUID, url: String, ): Long { - val result = wishPersistenceService.persist(userId, ProductLink.parse(url)) - itemParsingService.markFailed(entityManager.claimParseOutbox(result.snapshot)) + val result = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(url))) + itemParsingService.failLatest(entityManager, result.wish.slot) return result.wish.getId() } private fun seedPendingWish( userId: UUID, url: String, - ): Long = wishPersistenceService.persist(userId, ProductLink.parse(url)).wish.getId() + ): Long = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse(url))).wish.getId() @Test fun `게스트가 URL 로 위시 등록을 시도하면 403 과 회원 전용 안내가 반환된다`() { @@ -437,8 +439,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `이미 등록 완료(READY)된 위시 item 도 수기 수정하면 200 과 수정된 값으로 표시된다`() { - // 수기 수정 상시 허용(#825 결정 4) — 기계 버전은 불변이고 수정은 MANUAL 새 버전으로 쌓여 표시가 바뀐다. + fun `이미 등록 완료(READY)된 위시 item 도 사용자 입력하면 200 과 입력한 값으로 표시된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -459,9 +460,8 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `남이 같은 링크를 담아 채운 READY 가 있으면 가격 없는 미완성 위시도 이름만 수정이 성공한다`() { - // 회귀(#1006 과 같은 축): 카드는 표시값(최신 기계 READY)을 그리는데 병합 base 만 포인터면, 화면엔 가격이 - // 떠 있는데 안 보낸 가격이 빈 값에서 병합돼 400 으로 튕긴다. 카드에 보인 가격이 병합되는 것까지 본다. + fun `남이 같은 상품에 채운 READY 가 있으면 가격 없는 미완성 위시도 이름만 수정이 성공한다`() { + // 회귀(#1006): 병합 base 가 카드에 보인 값이 아니면 화면엔 가격이 있는데 400 으로 튕긴다 val mockMvc = buildMockMvc() val userId = UUID.randomUUID() val otherUserId = UUID.randomUUID() @@ -470,10 +470,8 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { val authHeader = "Bearer ${memberToken(userId)}" val url = "https://shop.example.com/products/shared-incomplete" - // 내 등록은 부분 추출(가격 없음)로 미완성 종결. 남의 등록은 같은 링크라 같은 item 에 붙고 - // (미완성은 재사용 대상이 아니라 새 PENDING), 기계 추출(STRUCTURED)이 성공해 표시값 후보가 된다. val myWishId = seedReadyWish(userId, url, name = "미완성 이름", price = null, currency = null) - seedReadyWish(otherUserId, url, name = "남이 채운 이름", price = 89_000, extractionMethod = "STRUCTURED") + itemSnapshotRepository.save(itemVersion(itemIdOf(myWishId), name = "남이 채운 이름", price = 89_000, createdBy = otherUserId)) mockMvc .perform( @@ -494,7 +492,6 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { @Test fun `남이 채운 READY 가 없으면 가격 없는 미완성 위시의 이름만 수정은 400 으로 거부된다`() { - // 위 테스트의 대조군 — 표시값이 곧 포인터면 병합해도 가격이 비어 거부돼야 한다(입력 계약 400). val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -512,54 +509,83 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { it }.header(HttpHeaders.AUTHORIZATION, authHeader), ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value(ItemErrorCode.PRICE_REQUIRED_FOR_READY.code)) } @Test - fun `파싱 중(PENDING)인 위시 item 도 필수값을 채워 수기 수정하면 200 이다 - 병합할 base 가 비면 400`() { - // 상태 제한이 없다(#825 결정 4). 단 PENDING base 는 값이 비어 있어, 일부 필드만 보내면 - // 병합 결과에 필수값이 없어 400(ITEM-003 등)이다 — 상태 충돌(409)이 아니라 입력 계약의 문제다. + fun `바꿀 값 없이 보낸 사용자 입력은 400 으로 거부되고 버전이 쌓이지 않는다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val authHeader = "Bearer ${memberToken(userId)}" - val wishId = seedPendingWish(userId, "https://shop.example.com/products/1") + val wishId = seedReadyWish(userId, "https://shop.example.com/products/empty-patch", name = "그대로인 이름") + val versionsBefore = itemSnapshotRepository.findAllByItemIds(listOf(itemIdOf(wishId))).size - // 일부 필드만 — 병합해도 가격·이미지가 없어 400. mockMvc .perform( multipart("/api/v1/wishlists/$wishId") - .param("name", "이름만 수정") .with { it.method = "PATCH" it }.header(HttpHeaders.AUTHORIZATION, authHeader), ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("ITEM-004")) + .andExpect(jsonPath("$.code").value(ItemErrorCode.EMPTY_USER_INPUT.code)) + .andExpect(jsonPath("$.detail").value(ItemErrorCode.EMPTY_USER_INPUT.message)) + assertEquals(versionsBefore, itemSnapshotRepository.findAllByItemIds(listOf(itemIdOf(wishId))).size) + } + + @Test + fun `파싱 중인 위시는 상품 정보 입력이 업로드 전에 409 로 거부되고 메모만 바꾸는 것은 된다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertMember(userId) + val authHeader = "Bearer ${memberToken(userId)}" + val wishId = seedPendingWish(userId, "https://shop.example.com/products/1") + val uploadsBefore = stubImageStorage.uploadedKeys.size - // 필수값을 다 채우면 진행 중이어도 수정된다. val image = MockMultipartFile("image", "p.png", "image/png", byteArrayOf(1, 2, 3)) mockMvc .perform( multipart("/api/v1/wishlists/$wishId") .file(image) - .param("name", "수기 입력") + .param("name", "직접 입력한 이름") .param("price", "5000") .with { it.method = "PATCH" it }.header(HttpHeaders.AUTHORIZATION, authHeader), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("ITEM-008")) + assertEquals(uploadsBefore, stubImageStorage.uploadedKeys.size) + + mockMvc + .perform( + multipart("/api/v1/wishlists/$wishId") + .param("memo", "결과 기다리는 중") + .with { + it.method = "PATCH" + it + }.header(HttpHeaders.AUTHORIZATION, authHeader), ).andExpect(status().isOk) - .andExpect(jsonPath("$.data.item.name").value("수기 입력")) - .andExpect(jsonPath("$.data.item.status").value("READY")) + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("PARSING")) } @Test - fun `FAILED 상태인 위시 item 을 직접 수정하면 200 과 함께 status 가 READY 로 복구된다`() { + fun `실패 위시에 사용자 입력을 채우면 아이템이 확정되고 READY 로 복구된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val authHeader = "Bearer ${memberToken(userId)}" - val wishId = seedFailedWish(userId, "https://shop.example.com/products/1") + val url = "https://shop.example.com/products/1" + val wishId = seedFailedWish(userId, url) + + mockMvc + .perform(get("/api/v1/wishlists/$wishId").header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("FAILED")) + .andExpect(jsonPath("$.data.item.sourceUrl").value(url)) val image = MockMultipartFile("image", "p.png", "image/png", byteArrayOf(1, 2, 3)) mockMvc @@ -575,13 +601,56 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isOk) .andExpect(jsonPath("$.data.item.name").value("직접 입력한 이름")) .andExpect(jsonPath("$.data.item.price").value(50_000)) - // 추출 실패(FAILED) 항목을 직접 보정하면 정상 항목이 된 것이므로 READY 로 복구된다. + .andExpect(jsonPath("$.data.item.id").value(itemIdOf(wishId))) + .andExpect(jsonPath("$.data.item.sourceUrl").value(url)) .andExpect(jsonPath("$.data.item.status").value("READY")) + .andExpect(jsonPath("$.data.item.userInput").value(true)) + } + + @Test + fun `실패 위시를 사용자 입력으로 살릴 때 같은 상품의 위시가 있으면 그 위시에 합쳐지고 그 위시를 응답한다`() { + val mockMvc = buildMockMvc() + val userId = UUID.randomUUID() + insertMember(userId) + val authHeader = "Bearer ${memberToken(userId)}" + val url = "https://shop.example.com/products/merge-input" + val failedWishId = seedFailedWish(userId, url) + val holder = wishPersistenceService.persist(RegisterWishFromUrlCommand(userId, ProductLink.parse("https://short.example.com/merge-input"))).wish + itemParsingService.completeLatest( + entityManager, + holder.slot, + ProductSnapshot(name = "먼저 확정된 상품", price = 10_000, currency = "KRW", imageUrl = "https://img.example.com/m.png", finalUrl = ProductLink.parse(url)), + ) + + val image = MockMultipartFile("image", "p.png", "image/png", byteArrayOf(1, 2, 3)) + mockMvc + .perform( + multipart("/api/v1/wishlists/$failedWishId") + .file(image) + .param("name", "직접 입력한 이름") + .param("price", "50000") + .param("memo", "같이 저장") + .with { + it.method = "PATCH" + it + }.header(HttpHeaders.AUTHORIZATION, authHeader), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.wish.id").value(holder.getId())) + .andExpect(jsonPath("$.data.item.id").value(itemIdOf(holder.getId()))) + .andExpect(jsonPath("$.data.item.name").value("직접 입력한 이름")) + + mockMvc + .perform(get("/api/v1/wishlists/$failedWishId").header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("WISH-004")) + mockMvc + .perform(get("/api/v1/wishlists/${holder.getId()}").header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.memo").value("같이 저장")) } @Test fun `이름 없이 FAILED 위시 item 을 복구하려 하면 400 BAD_REQUEST 가 반환된다`() { - // 이름 없는 보정은 쓸 수 없는 상품을 READY 로 승격시키므로 막는다 (READY ⟹ name 불변식). val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -659,12 +728,15 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.detail").value(WishlistUpdateRequest.PRICE_MIN_MESSAGE)) } - private fun itemIdOf(wishId: Long): Long = - jdbcTemplate.queryForObject( - "SELECT s.item_id FROM wishes w JOIN item_snapshots s ON s.id = w.snapshot_id WHERE w.id = ?", + // 파싱 결과가 위시에 단 아이템은 아직 flush 전이라 JDBC 로 읽기 전에 내린다 + private fun itemIdOf(wishId: Long): Long { + entityManager.flush() + return jdbcTemplate.queryForObject( + "SELECT item_id FROM wishes WHERE id = ?", Long::class.java, wishId, ) ?: error("wish $wishId 의 item 이 없다") + } private fun countSnapshots(itemId: Long): Long = jdbcTemplate.queryForObject( @@ -692,7 +764,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { it }.header(HttpHeaders.AUTHORIZATION, authHeader), ).andExpect(status().isOk) - // memo 만 온 요청은 MANUAL 버전을 만들지 않는다 — 표시값(서버 추출)이 그대로다. + // memo 만 온 요청은 사용자 입력 버전을 만들지 않는다 — 표시값(서버 추출)이 그대로다. .andExpect(jsonPath("$.data.item.name").value("상품")) .andExpect(jsonPath("$.data.item.status").value("READY")) // 노출 계약: memo 는 상세 응답 전용 — PATCH 응답에는 없다. @@ -715,22 +787,24 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `빈 문자열 memo 를 보내면 메모가 삭제된다`() { + fun `빈 문자열 memo 를 보내면 메모가 지워지고 이전 메모는 이력에 남는다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) val authHeader = "Bearer ${memberToken(userId)}" val wishId = seedReadyWish(userId, "https://shop.example.com/products/1", "상품") - mockMvc - .perform( - multipart("/api/v1/wishlists/$wishId") - .param("memo", "지울 메모") - .with { - it.method = "PATCH" - it - }.header(HttpHeaders.AUTHORIZATION, authHeader), - ).andExpect(status().isOk) + repeat(2) { + mockMvc + .perform( + multipart("/api/v1/wishlists/$wishId") + .param("memo", "지울 메모") + .with { + it.method = "PATCH" + it + }.header(HttpHeaders.AUTHORIZATION, authHeader), + ).andExpect(status().isOk) + } mockMvc .perform( @@ -746,10 +820,18 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { .perform(get("/api/v1/wishlists/$wishId").header(HttpHeaders.AUTHORIZATION, authHeader)) .andExpect(status().isOk) .andExpect(jsonPath("$.data.memo").value(nullValue())) + entityManager.flush() + val contents = + jdbcTemplate.queryForList( + "SELECT content FROM wish_memos WHERE wish_id = ? ORDER BY written_at, id", + String::class.java, + wishId, + ) + assertEquals(listOf("지울 메모", null), contents, "같은 메모를 다시 보내면 쌓지 않는다") } @Test - fun `이름과 memo 를 함께 수정하면 MANUAL 새 버전이 쌓이고 메모도 반영된다`() { + fun `이름과 memo 를 함께 보내면 사용자 입력 새 버전이 쌓이고 메모도 반영된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -769,7 +851,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { }.header(HttpHeaders.AUTHORIZATION, authHeader), ).andExpect(status().isOk) .andExpect(jsonPath("$.data.item.name").value("바꾼 이름")) - .andExpect(jsonPath("$.data.item.source").value("MANUAL")) + .andExpect(jsonPath("$.data.item.userInput").value(true)) assertEquals(snapshotCountBefore + 1, countSnapshots(itemId)) mockMvc @@ -836,7 +918,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `FAILED 위시 item 을 이미지와 함께 보정하면 200 과 갱신된 imageUrl 로 복구된다`() { + fun `FAILED 위시 item 에 이미지와 함께 사용자 입력을 채우면 200 과 갱신된 imageUrl 로 복구된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -862,7 +944,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `이미지 보정 시 지원하지 않는 형식을 보내면 400 BAD_REQUEST 가 반환된다`() { + fun `사용자 입력 이미지가 지원하지 않는 형식이면 400 BAD_REQUEST 가 반환된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -883,7 +965,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `이미지 보정 시 빈 이미지를 보내면 400 BAD_REQUEST 가 반환된다`() { + fun `사용자 입력 이미지가 비어 있으면 400 BAD_REQUEST 가 반환된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -904,7 +986,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { } @Test - fun `이미지 보정 중 S3 업로드가 실패하면 502 BAD_GATEWAY 가 반환된다`() { + fun `사용자 입력 이미지의 S3 업로드가 실패하면 502 BAD_GATEWAY 가 반환된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistImagePresignedIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistImagePresignedIntegrationTest.kt index 6dcefb0ad..791853f77 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistImagePresignedIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistImagePresignedIntegrationTest.kt @@ -2,17 +2,20 @@ package com.depromeet.piki.wishlist.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.image.domain.UploadSize -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ItemParseOutboxStatus +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemParsingScheduler import com.depromeet.piki.product.service.ProductSnapshot -import com.depromeet.piki.support.awaitTicking -import com.depromeet.piki.support.deleteItems import com.depromeet.piki.support.IntegrationTestSupport -import com.depromeet.piki.support.presignImages import com.depromeet.piki.support.StubImageSnapshotExtractor import com.depromeet.piki.support.StubImageStorage +import com.depromeet.piki.support.awaitTicking +import com.depromeet.piki.support.deleteWishDataOf +import com.depromeet.piki.support.parseOutboxStatusOf +import com.depromeet.piki.support.presignImages import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.support.wishItemIdOf import com.depromeet.piki.user.domain.IdentityType import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test @@ -33,9 +36,7 @@ import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertTrue -// 이미지 등록 v2(presigned) 흐름 — 발급(POST /images/presigned) → 클라 직접 업로드(stub) → 확정(POST /images/confirm). -// confirm 은 PENDING 을 실제 커밋하므로 WishlistRegisterAsyncIntegrationTest 와 같은 @Transactional 없는 비동기 -// 패턴을 따른다. 격리 userId + cleanup 으로 공유 컨텍스트를 정리한다. +// confirm 이 아웃박스를 커밋해야 dispatch 가 집으므로 @Transactional 없이 격리 userId 와 cleanup 으로 정리한다 class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var webApplicationContext: WebApplicationContext @@ -249,7 +250,7 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { @Test - fun `발급받은 key 로 confirm 하면 PENDING 위시가 201 로 생성된다`() { + fun `발급받은 key 로 confirm 하면 파싱 중 위시가 201 로 생성된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) @@ -265,8 +266,8 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { .content(body), ).andExpect(status().isCreated) .andExpect(jsonPath("$.data.length()").value(2)) - .andExpect(jsonPath("$.data[0].item.status").value("PENDING")) - // 이미지 등록은 원본 URL 이 없어 sourceUrl 도, 거기서 유도하는 sourcePlatform 도 null 이다. + .andExpect(jsonPath("$.data[0].item.status").value("PARSING")) + .andExpect(jsonPath("$.data[0].item.id").value(nullValue())) .andExpect(jsonPath("$.data[0].item.sourceUrl").value(nullValue())) .andExpect(jsonPath("$.data[0].item.sourcePlatform").value(nullValue())) @@ -290,7 +291,6 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { val keys = presignAndGetKeys(mockMvc, userId, listOf("image/png", "image/jpeg")) val body = objectMapper.writeValueAsString(mapOf("imageKeys" to keys)) - // 1차 confirm — pending 을 claim(삭제)하며 2개 등록. mockMvc .perform( post("/api/v1/wishlists/images/confirm") @@ -300,8 +300,7 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isCreated) .andExpect(jsonPath("$.data.length()").value(2)) - // 2차 confirm(같은 key) — raw 는 S3 에 남아 형식·존재 검증은 통과하지만 pending 이 이미 claim 돼 등록할 게 없다. - // 멱등 no-op 으로 빈 목록 201 이고, 위시는 중복 생기지 않는다. + // raw 는 S3 에 남아 검증은 통과한다. 아웃박스의 이미지 키 유일 제약이 중복 등록을 거른다 mockMvc .perform( post("/api/v1/wishlists/images/confirm") @@ -370,13 +369,13 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { } @Test - fun `confirm 후 dispatch 로 파싱이 성공하면 item 이 READY 로 전이한다`() { + fun `confirm 후 dispatch 로 파싱이 성공하면 위시에 아이템이 확정되고 READY 가 된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubImageSnapshotExtractor.build = { - ProductSnapshot(link = null, name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/af.png") + ProductSnapshot(name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/af.png") } val keys = presignAndGetKeys(mockMvc, userId, listOf("image/png")) val body = objectMapper.writeValueAsString(mapOf("imageKeys" to keys)) @@ -391,26 +390,27 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { .andReturn() .response .getContentAsString(Charsets.UTF_8) - val itemId = + val wishId = objectMapper .readTree(response) .path("data") .path(0) - .path("item") + .path("wish") .path("id") .asLong() itemParsingScheduler.awaitTicking { - itemSnapshotRepository.findLatestByItemId(itemId)?.status == ItemStatus.READY + jdbcTemplate.parseOutboxStatusOf(ItemSlot.wish(wishId)) == ItemParseOutboxStatus.SUCCEEDED } - val snapshot = itemSnapshotRepository.findLatestByItemId(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals("나이키 에어포스", snapshot.name) + val itemId = jdbcTemplate.wishItemIdOf(wishId) ?: error("wish $wishId 에 아이템이 확정되지 않았다") + val version = itemSnapshotRepository.findAllByItemIds(listOf(itemId)).single() + assertEquals("나이키 에어포스", version.name) + assertTrue(version.isReady()) } finally { cleanup(userId) } } - private fun presignAndGetKeys( mockMvc: MockMvc, userId: UUID, @@ -459,14 +459,7 @@ class WishlistImagePresignedIntegrationTest : IntegrationTestSupport() { private fun guestToken(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.GUEST) private fun cleanup(userId: UUID) { - val itemIds = - jdbcTemplate.queryForList( - "SELECT s.item_id FROM wishes w JOIN item_snapshots s ON s.id = w.snapshot_id WHERE w.user_id = ?", - Long::class.java, - uuidToBytes(userId), - ) - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - jdbcTemplate.deleteItems(itemIds.filterNotNull()) + jdbcTemplate.deleteWishDataOf(userId) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) } } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRefreshIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRefreshIntegrationTest.kt index 80ee3849c..131c0edbb 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRefreshIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRefreshIntegrationTest.kt @@ -1,16 +1,16 @@ package com.depromeet.piki.wishlist.controller -import org.springframework.transaction.support.TransactionTemplate -import jakarta.persistence.EntityManager import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider -import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemParseOutbox +import com.depromeet.piki.item.domain.ItemParseOutboxStatus +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemSnapshotSource -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ParseInput +import com.depromeet.piki.item.domain.ParsePurpose +import com.depromeet.piki.item.repository.ItemParseOutboxRepository import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemParsingScheduler -import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.notification.domain.NotificationType import com.depromeet.piki.notification.repository.NotificationRepository import com.depromeet.piki.product.domain.ProductLink @@ -18,41 +18,38 @@ import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubLinkSnapshotExtractor import com.depromeet.piki.support.awaitTicking -import com.depromeet.piki.support.deleteItems -import com.depromeet.piki.support.claimParseOutbox +import com.depromeet.piki.support.deleteWishDataOf +import com.depromeet.piki.support.imageItem +import com.depromeet.piki.support.itemVersion +import com.depromeet.piki.support.linkItem +import com.depromeet.piki.support.parseOutboxStatusOf import com.depromeet.piki.support.uuidToBytes -import com.depromeet.piki.tournament.domain.TournamentItem -import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishRepository -import com.depromeet.piki.wishlist.service.WishPersistenceService +import org.awaitility.Awaitility.await +import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.context.WebApplicationContext -import org.awaitility.Awaitility.await import java.time.Duration -import java.time.LocalDateTime import java.util.UUID import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNull +import kotlin.test.assertTrue // @Async 파서는 별도 트랜잭션이라 커밋 후 awaitTicking 으로 기다림 class WishlistRefreshIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var entityManager: EntityManager - - @Autowired private lateinit var transactionTemplate: TransactionTemplate - @Autowired private lateinit var webApplicationContext: WebApplicationContext @@ -66,10 +63,10 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { private lateinit var itemSnapshotRepository: ItemSnapshotRepository @Autowired - private lateinit var wishRepository: WishRepository + private lateinit var itemParseOutboxRepository: ItemParseOutboxRepository @Autowired - private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository + private lateinit var wishRepository: WishRepository @Autowired private lateinit var jdbcTemplate: JdbcTemplate @@ -77,76 +74,74 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var jwtProvider: JwtProvider - @Autowired - private lateinit var itemParsingService: ItemParsingService - @Autowired private lateinit var itemParsingScheduler: ItemParsingScheduler - @Autowired - private lateinit var wishPersistenceService: WishPersistenceService - @Autowired private lateinit var notificationRepository: NotificationRepository @Test - fun `READY 위시를 새로고침하면 새 PENDING 버전이 활성이 되고 추출 성공 시 READY 로 전이한다`() { + fun `READY 위시를 새로고침하면 값은 그대로 refreshing 이 켜지고 성공하면 같은 아이템에 새 버전이 쌓인다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubLinkSnapshotExtractor.build = { - ProductSnapshot(link = it, name = "새 상품", price = 20_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") + ProductSnapshot(name = "새 상품", price = 20_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") } - val (wishId, itemId, oldSnapshotId) = seedReadyWish(userId, "https://shop.example.com/products/refresh", "옛 상품", 10_000) + val (wishId, itemId, oldVersionId) = saveReadyWish(userId, "https://shop.example.com/products/refresh", "옛 상품", 10_000) - // 새로고침 — 같은 item 의 새 PENDING 버전이 즉시 활성이 된다(등록과 동일 비동기 전이 모델: 완료는 SSE 알림으로 통보). mockMvc .perform( post("/api/v1/wishlists/$wishId/refresh") .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isOk) .andExpect(jsonPath("$.data.item.id").value(itemId)) - .andExpect(jsonPath("$.data.item.status").value("PENDING")) + .andExpect(jsonPath("$.data.item.status").value("READY")) + .andExpect(jsonPath("$.data.item.refreshing").value(true)) + .andExpect(jsonPath("$.data.item.name").value("옛 상품")) + .andExpect(jsonPath("$.data.item.price").value(10_000)) - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.READY } - val active = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals("새 상품", active.name) - assertEquals(20_000, active.price) - assertNotEquals(oldSnapshotId, active.getId()) // 새 버전 행 + val slot = ItemSlot.wish(wishId) + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.SUCCEEDED } - // 옛 snapshot 행은 보존된다(토너먼트 출전 격리의 근거) — 여전히 READY, 옛 값. - val preserved = itemSnapshotRepository.findById(oldSnapshotId) ?: error("옛 snapshot 이 사라졌다") - assertEquals(ItemStatus.READY, preserved.status) - assertEquals("옛 상품", preserved.name) + val versions = versionsOf(itemId) + assertEquals(2, versions.size) + assertEquals(listOf("옛 상품", "새 상품"), versions.sortedBy { it.getId() }.map { it.name }) + assertEquals(oldVersionId, versions.minBy { it.getId() }.getId()) + assertEquals(itemId, wishRepository.findById(wishId)?.itemId) - assertEquals(active.getId(), wishRepository.findById(wishId)?.waitingSnapshotId) + mockMvc + .perform(get("/api/v1/wishlists/$wishId").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.refreshing").value(false)) + .andExpect(jsonPath("$.data.item.name").value("새 상품")) + .andExpect(jsonPath("$.data.item.price").value(20_000)) } finally { cleanup(userId) } } @Test - fun `이미 진행 중인 위시를 새로고침하면 새 추출을 만들지 않고 현재 진행 상태를 그대로 반환한다`() { + fun `새로고침이 진행 중인 위시를 다시 새로고침하면 요청을 더 쌓지 않고 진행 상태를 그대로 반환한다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - val item = itemRepository.save(Item(ProductLink.parse("https://shop.example.com/products/inprogress"))) - val snapshot = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) - val wish = wishRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) - val itemId = item.getId() - val before = countSnapshots(itemId) - - mockMvc - .perform( - post("/api/v1/wishlists/${wish.getId()}/refresh") - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.data.item.status").value("PENDING")) + val (wishId, itemId, _) = saveReadyWish(userId, "https://shop.example.com/products/inprogress", "옛 상품", 10_000) + repeat(2) { + mockMvc + .perform( + post("/api/v1/wishlists/$wishId/refresh") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(itemId)) + .andExpect(jsonPath("$.data.item.refreshing").value(true)) + .andExpect(jsonPath("$.data.item.name").value("옛 상품")) + } - assertEquals(before, countSnapshots(itemId)) - assertEquals(snapshot.getId(), wishRepository.findById(wish.getId())?.waitingSnapshotId) + assertEquals(1, countParseOutbox(ItemSlot.wish(wishId))) + assertEquals(1, versionsOf(itemId).size) } finally { cleanup(userId) } @@ -158,28 +153,17 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { val userId = UUID.randomUUID() insertMember(userId) try { - // 이미지로 등록한 item 은 link 가 없어 재추출 입력이 없다. - val item = itemRepository.save(Item(link = null)) - val snapshot = - itemSnapshotRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = "이미지 상품", - price = 5_000, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), - ) - val wish = wishRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) + val item = itemRepository.save(imageItem()) + itemSnapshotRepository.save(itemVersion(item.getId(), name = "이미지 상품", price = 5_000, createdBy = userId)) + val wish = wishRepository.save(Wish(userId = userId, itemId = item.getId())) mockMvc .perform( post("/api/v1/wishlists/${wish.getId()}/refresh") .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("WISH-007")) - .andExpect(jsonPath("$.detail").value("링크가 없는 항목은 새로고침할 수 없습니다.")) + .andExpect(jsonPath("$.code").value("ITEM-007")) + .andExpect(jsonPath("$.detail").value("링크로 등록한 상품만 새로고침할 수 있어요.")) } finally { cleanup(userId) } @@ -190,12 +174,11 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val owner = UUID.randomUUID() insertMember(owner) - // 회원 가드(requireMember)가 findById 로 identityType 을 보므로 attacker 도 실제 회원 행이 있어야 - // 가드를 통과해 소유권 검증(403)까지 닿는다. 행이 없으면 가드가 먼저 404(notFound)로 막는다. + // 회원 가드가 먼저 회원 행을 보므로 attacker 도 회원이어야 소유권 검증(403)까지 닿는다 val attacker = UUID.randomUUID() insertMember(attacker) try { - val (wishId, _, _) = seedReadyWish(owner, "https://shop.example.com/products/owned", "옛 상품", 10_000) + val (wishId, _, _) = saveReadyWish(owner, "https://shop.example.com/products/owned", "옛 상품", 10_000) mockMvc .perform( @@ -233,14 +216,15 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { } @Test - fun `추출 실패(FAILED) 위시를 새로고침하면 409 로 거부된다`() { - // 표시값까지 FAILED(같은 item 에 기계 READY 부재)면 재추출도 결정론적으로 재실패한다 — 보정(수기 - // 수정)으로 유도한다. 아래 "남이 채운 READY" 테스트의 대조군: 이게 없으면 판정을 통째로 지워도 초록불이다. + fun `아이템이 확정되지 못한 실패 위시를 새로고침하면 409 로 거부되고 요청이 쌓이지 않는다`() { + // 재추출해도 결정론적으로 다시 실패해 사용자 입력으로 유도한다 val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - val (wish, _, _) = seedFailedWish(userId, "https://shop.example.com/products/failed") + val url = "https://shop.example.com/products/failed" + val wish = wishRepository.save(Wish(userId = userId, itemId = null)) + itemParseOutboxRepository.save(failedOutbox(wish.slot, url)) mockMvc .perform( @@ -249,103 +233,52 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isConflict) .andExpect(jsonPath("$.code").value("WISH-008")) .andExpect(jsonPath("$.detail").value("추출에 실패한 항목은 새로고침 대신 정보를 직접 입력해 복구해 주세요.")) - } finally { - cleanup(userId) - } - } - - @Test - fun `포인터가 FAILED 여도 같은 item 에 남이 채운 기계 READY 가 있으면 새로고침된다`() { - // 회귀(#1006 과 같은 축): 카드는 표시값(최신 기계 READY)을 그리는데 판정만 포인터를 보면, 화면엔 값이 - // 다 떠 있는데 "실패한 항목이라 안 된다"(409)가 나간다. item 이 추출 가능하다는 증거(남의 성공)가 있으므로 - // 새로고침을 허용하고 새 PENDING 으로 스왑되는 것까지 본다. - val mockMvc = buildMockMvc() - val userId = UUID.randomUUID() - insertMember(userId) - try { - val (wish, item, failed) = seedFailedWish(userId, "https://shop.example.com/products/failed-shared") - // 남이 같은 링크를 담아 추출이 성공한 상황 — source 가 기계(SERVER)여야 표시값 파생 후보에 든다. - itemSnapshotRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = "남이 채운 이름", - price = 89_000, - currency = "KRW", - imageUrl = "https://img.example.com/b.png", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - source = ItemSnapshotSource.SERVER, - ), - ) - mockMvc - .perform( - post("/api/v1/wishlists/${wish.getId()}/refresh") - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), - ).andExpect(status().isOk) - - val swapped = wishRepository.findById(wish.getId())!! - assertNotEquals(failed.getId(), swapped.waitingSnapshotId, "활성 포인터가 새 버전으로 스왑돼야 한다") - assertEquals(ItemStatus.PENDING, itemSnapshotRepository.findById(swapped.waitingSnapshotId)?.status) + assertEquals(1, countParseOutbox(wish.slot)) } finally { cleanup(userId) } } @Test - fun `토너먼트에 출전한 위시를 새로고침해도 출전 시점 snapshot 은 바뀌지 않는다`() { - // #362 의 핵심 회귀 — 위시 갱신이 tournament_item 의 고정 snapshot 을 침범하면 출전 공정성이 깨진다. + fun `직전 새로고침이 실패했어도 아이템이 있는 위시는 다시 새로고침된다`() { + // 409 는 아이템이 없는 실패 위시에만 걸린다. 위 테스트의 대조군 val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - stubLinkSnapshotExtractor.build = { - ProductSnapshot(link = it, name = "새 상품", price = 20_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") - } - val (wishId, itemId, oldSnapshotId) = seedReadyWish(userId, "https://shop.example.com/products/inplay", "옛 상품", 10_000) - // 출전 시점 고정 — tournament_item 이 옛 snapshot 을 가리킨다(FK 없으니 tournament 행 없이도 격리만 검증). - val tournamentItem = - tournamentItemJpaRepository.save( - TournamentItem(tournamentId = 999_999L, userId = userId, snapshotId = oldSnapshotId), - ) + val url = "https://shop.example.com/products/refresh-failed-before" + val (wishId, itemId, _) = saveReadyWish(userId, url, "옛 상품", 10_000) + val slot = ItemSlot.wish(wishId) + itemParseOutboxRepository.save(failedOutbox(slot, url)) mockMvc .perform( post("/api/v1/wishlists/$wishId/refresh") .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(itemId)) + .andExpect(jsonPath("$.data.item.status").value("READY")) + .andExpect(jsonPath("$.data.item.refreshing").value(true)) - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.READY } - - assertNotEquals(oldSnapshotId, wishRepository.findById(wishId)?.waitingSnapshotId) - val fixedSnapshotId = - jdbcTemplate.queryForObject( - "SELECT snapshot_id FROM tournament_items WHERE id = ?", - Long::class.java, - tournamentItem.getId(), - ) - assertEquals(oldSnapshotId, fixedSnapshotId) - val preserved = itemSnapshotRepository.findById(oldSnapshotId) ?: error("옛 snapshot 이 사라졌다") - assertEquals("옛 상품", preserved.name) - assertEquals(ItemStatus.READY, preserved.status) + assertEquals(2, countParseOutbox(slot)) + assertEquals(ItemParseOutboxStatus.PENDING, jdbcTemplate.parseOutboxStatusOf(slot)) } finally { cleanup(userId) } } @Test - fun `위시를 새로고침해 파싱이 완료되면 본인에게 새로고침 완료 알림이 발행된다`() { - // refresh 도 등록과 동일한 markExtracted→ItemParsingCompleted 경로를 타지만, 위시가 새 버전보다 먼저 있었다는 - // 사실로 등록과 갈려(#1036) 위시 주인(본인)에게 ITEM_REFRESH_COMPLETED 가 발행되고 등록 완료 알림은 오지 않는다. - // 그 갈림을 실제 HTTP→디스패처→리스너 경로로 end-to-end 고정한다(수신자 해석 단위는 NotificationRecipientResolutionIntegrationTest). + fun `위시를 새로고침해 파싱이 완료되면 본인에게 새로고침 완료 알림만 발행된다`() { + // 등록 완료와 새로고침 완료는 요청 시점에 아이템이 있었는지로 갈린다(#1036). HTTP 에서 리스너까지 고정한다 val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubLinkSnapshotExtractor.build = { - ProductSnapshot(link = it, name = "새 상품", price = 20_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") + ProductSnapshot(name = "새 상품", price = 20_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") } - val (wishId, itemId, _) = seedReadyWish(userId, "https://shop.example.com/products/refresh-notify", "옛 상품", 10_000) + val (wishId, _, _) = saveReadyWish(userId, "https://shop.example.com/products/refresh-notify", "옛 상품", 10_000) mockMvc .perform( @@ -353,14 +286,15 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), ).andExpect(status().isOk) - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.READY } - // 새로고침 완료 알림이 본인에게 저장됐다 (markExtracted 의 AFTER_COMMIT 리스너 → 비동기 저장이라 await). + itemParsingScheduler.awaitTicking { + jdbcTemplate.parseOutboxStatusOf(ItemSlot.wish(wishId)) == ItemParseOutboxStatus.SUCCEEDED + } + // AFTER_COMMIT 리스너가 비동기로 저장한다 await().atMost(Duration.ofSeconds(5)).until { notificationRepository .findPage(userId, cursor = null, limit = 10) .any { it.type == NotificationType.ITEM_REFRESH_COMPLETED } } - // 등록 완료 알림은 오지 않는다 — 둘은 수신자가 배타적이다. assertEquals( listOf(NotificationType.ITEM_REFRESH_COMPLETED), notificationRepository.findPage(userId, cursor = null, limit = 10).map { it.type }, @@ -371,112 +305,88 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { } @Test - fun `markExtracted 는 claim 한 snapshot 만 전이하고 같은 item 의 다른 진행 중 버전은 건드리지 않는다`() { - // 갱신은 한 item 에 여러 진행 중 snapshot 을 만든다. 전이가 findLatestByItemId(최신)가 아니라 - // claim 한 작업의 snapshotId 를 짚어야 다른(새) 버전을 오전이하지 않는다. + fun `새로고침 중인 위시는 사용자 입력이 409 이고 버전과 진행 중인 새로고침이 그대로다`() { + val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) - val item = itemRepository.save(Item(ProductLink.parse("https://shop.example.com/products/two-versions"))) try { - val v1 = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) - val v2 = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) + val url = "https://shop.example.com/products/input-while-refreshing" + val (wishId, itemId, _) = saveReadyWish(userId, url, "기계 값", 10_000) + val slot = ItemSlot.wish(wishId) + itemParseOutboxRepository.save(ItemParseOutbox.of(slot, ParsePurpose.REFRESH, ParseInput.Link(ProductLink.parse(url)))) - // v1(더 낮은 id, 최신 아님)을 지정해 전이 — findLatest 였다면 v2 가 전이됐을 것이다. - itemParsingService.markExtracted( - transactionTemplate.execute { entityManager.claimParseOutbox(v1) }!!, - ProductSnapshot(link = null, name = "버전1", price = 100, currency = "KRW", imageUrl = "https://img.example.com/a.png"), - ) + mockMvc + .perform( + multipart("/api/v1/wishlists/$wishId") + .param("name", "직접 입력한 이름") + .with { + it.method = "PATCH" + it + }.header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("ITEM-008")) - assertEquals(ItemStatus.READY, itemSnapshotRepository.findById(v1.getId())?.status) - assertEquals("버전1", itemSnapshotRepository.findById(v1.getId())?.name) - // 최신(v2)은 그대로 PENDING — claim 한 행만 정확히 전이됐다. - assertEquals(ItemStatus.PENDING, itemSnapshotRepository.findById(v2.getId())?.status) - assertNull(itemSnapshotRepository.findById(v2.getId())?.name) + assertEquals(listOf("기계 값"), versionsOf(itemId).map { it.name }) + assertEquals(ItemParseOutboxStatus.PENDING, jdbcTemplate.parseOutboxStatusOf(slot)) } finally { - jdbcTemplate.deleteItems(listOf(item.getId())) - jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) + cleanup(userId) } } @Test - fun `manualEdit 은 위시 활성 버전을 base 로 MANUAL 새 버전을 쌓아 스왑하고 기존 행들은 건드리지 않는다`() { - // 수기 수정(#825 결정 4) 회귀 — 활성(FAILED) 버전이 최신이 아니어도 base 는 wish 활성 포인터가 결정하고, - // 수정은 기존 행을 고치지 않으므로 FAILED 행도 같은 item 의 최신(PENDING) 행도 그대로 남는다. + fun `등록 파싱 중인 위시를 새로고침하면 409 없이 아이템 없는 PARSING 응답을 그대로 돌려준다`() { + val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) - val item = itemRepository.save(Item(ProductLink.parse("https://shop.example.com/products/manual-version"))) try { - val failed = - itemSnapshotRepository.save( - ItemSnapshot.pending(item.getId(), requestedBy = userId).apply { markFailed() }, - ) - val newer = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) - val wish = wishRepository.save(Wish(userId, failed.getId(), failed.itemId)) - - val result = - wishPersistenceService.manualEdit( - userId = userId, - wishId = wish.getId(), - name = "보정", - price = 200, - imageUrl = "https://img.example.com/a.png", - currency = "KRW", - memo = null, - ) - - // 새 MANUAL 버전이 활성으로 스왑됐다 — 편집자·출처가 박힌다. - val active = - itemSnapshotRepository.findById(wishRepository.findById(wish.getId())!!.waitingSnapshotId) - ?: error("활성 snapshot 이 없다") - assertEquals(result.snapshot.getId(), active.getId()) - assertEquals(ItemStatus.READY, active.status) - assertEquals("보정", active.name) - assertEquals(ItemSnapshotSource.MANUAL, active.source) - assertEquals(userId, active.createdBy) - // 기존 행들은 불변 — FAILED 는 FAILED 로(이력), 최신 PENDING 은 파싱 계속. - assertEquals(ItemStatus.FAILED, itemSnapshotRepository.findById(failed.getId())?.status) - assertEquals(ItemStatus.PENDING, itemSnapshotRepository.findById(newer.getId())?.status) + val url = "https://shop.example.com/products/refresh-parsing" + val wish = wishRepository.save(Wish(userId = userId, itemId = null)) + itemParseOutboxRepository.save(ItemParseOutbox.of(wish.slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(url)))) + + mockMvc + .perform( + post("/api/v1/wishlists/${wish.getId()}/refresh") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("PARSING")) + .andExpect(jsonPath("$.data.item.refreshing").value(false)) + .andExpect(jsonPath("$.data.item.sourceUrl").value(url)) + + assertEquals(1, countParseOutbox(wish.slot)) } finally { - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - jdbcTemplate.deleteItems(listOf(item.getId())) - jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) + cleanup(userId) } } - // READY 상태의 link 보유 위시를 직접 시딩한다. (wishId, itemId, snapshotId) 반환. - // FAILED 위시 시딩 — 재추출 입력(link)이 있는 item 에 실패로 종결된 활성 포인터를 단다. - private fun seedFailedWish( - userId: UUID, - url: String, - ): Triple { - val item = itemRepository.save(Item(ProductLink.parse(url))) - val failed = itemSnapshotRepository.save(ItemSnapshot(itemId = item.getId(), status = ItemStatus.FAILED)) - val wish = wishRepository.save(Wish(userId = userId, waitingSnapshotId = failed.getId(), itemId = failed.itemId)) - return Triple(wish, item, failed) - } - - private fun seedReadyWish( + // 파싱 경로를 거치지 않고 행만 저장한다 + private fun saveReadyWish( userId: UUID, url: String, name: String, price: Int, - ): Triple { - val item = itemRepository.save(Item(ProductLink.parse(url))) - val snapshot = - itemSnapshotRepository.save( - ItemSnapshot( - itemId = item.getId(), - name = name, - price = price, - currency = "KRW", - status = ItemStatus.READY, - extractedAt = LocalDateTime.now(), - ), - ) - val wish = wishRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) - return Triple(wish.getId(), item.getId(), snapshot.getId()) + ): SavedWish { + val item = itemRepository.save(linkItem(url)) + val version = itemSnapshotRepository.save(itemVersion(item.getId(), name = name, price = price, createdBy = userId)) + val wish = wishRepository.save(Wish(userId = userId, itemId = item.getId())) + return SavedWish(wishId = wish.getId(), itemId = item.getId(), versionId = version.getId()) } + private data class SavedWish( + val wishId: Long, + val itemId: Long, + val versionId: Long, + ) + + private fun failedOutbox( + slot: ItemSlot, + url: String, + ): ItemParseOutbox = + ItemParseOutbox.of(slot, ParsePurpose.REGISTER, ParseInput.Link(ProductLink.parse(url))).apply { + claim() + fail() + } + private fun buildMockMvc(): MockMvc = MockMvcBuilders .webAppContextSetup(webApplicationContext) @@ -494,26 +404,18 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { private fun memberToken(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.MEMBER) - private fun latestSnapshot(itemId: Long): ItemSnapshot? = itemSnapshotRepository.findLatestByItemId(itemId) + private fun versionsOf(itemId: Long): List = itemSnapshotRepository.findAllByItemIds(listOf(itemId)) - private fun countSnapshots(itemId: Long): Int = - jdbcTemplate.queryForObject("SELECT COUNT(*) FROM item_snapshots WHERE item_id = ?", Int::class.java, itemId) ?: 0 + private fun countParseOutbox(slot: ItemSlot): Int = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM item_parse_outbox WHERE slot_type = ? AND slot_id = ?", + Int::class.java, + slot.type.name, + slot.id, + ) ?: 0 - // @Transactional 자동 롤백이 없으므로 이 테스트가 만든 행을 직접 정리한다. wishes 는 snapshot_id 만 들어 - // item_snapshots 를 조인해 itemId 에 도달하고, tournament_item 은 snapshot_id 로 그 itemId 의 버전들을 가리킨다. private fun cleanup(userId: UUID) { - val itemIds = - jdbcTemplate.queryForList( - "SELECT s.item_id FROM wishes w JOIN item_snapshots s ON s.id = w.snapshot_id WHERE w.user_id = ?", - Long::class.java, - uuidToBytes(userId), - ) - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - itemIds.takeIf { it.isNotEmpty() }?.let { - val ph = it.joinToString(",") - jdbcTemplate.update("DELETE FROM tournament_items WHERE snapshot_id IN (SELECT id FROM item_snapshots WHERE item_id IN ($ph))") - jdbcTemplate.deleteItems(it.filterNotNull()) - } + jdbcTemplate.deleteWishDataOf(userId) jdbcTemplate.update("DELETE FROM notifications WHERE user_id = ?", uuidToBytes(userId)) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt index ea47e5334..c765a65b1 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -6,11 +6,12 @@ import ch.qos.logback.core.read.ListAppender import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider import com.depromeet.piki.item.domain.ItemParseOutboxStatus import com.depromeet.piki.item.domain.ItemSnapshot -import com.depromeet.piki.item.domain.ItemStatus +import com.depromeet.piki.item.domain.ItemSlot import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemParser import com.depromeet.piki.item.service.ItemParsingScheduler +import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductSnapshot import com.depromeet.piki.product.service.ProductSnapshotException import com.depromeet.piki.product.service.remote.ProductExtractorException @@ -19,10 +20,13 @@ import com.depromeet.piki.support.StubImageSnapshotExtractor import com.depromeet.piki.support.StubImageStorage import com.depromeet.piki.support.StubLinkSnapshotExtractor import com.depromeet.piki.support.awaitTicking -import com.depromeet.piki.support.deleteItems +import com.depromeet.piki.support.normalizedOf +import com.depromeet.piki.support.deleteWishDataOf +import com.depromeet.piki.support.latestParseOutboxIdOf import com.depromeet.piki.support.parseOutboxStatusOf import com.depromeet.piki.support.presignImages import com.depromeet.piki.support.uuidToBytes +import com.depromeet.piki.support.wishItemIdOf import com.depromeet.piki.user.domain.IdentityType import io.micrometer.core.instrument.MeterRegistry import org.awaitility.Awaitility.await @@ -35,6 +39,7 @@ import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -45,7 +50,6 @@ import tools.jackson.databind.ObjectMapper import java.time.Duration import java.util.UUID import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -91,12 +95,12 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { private lateinit var stubImageStorage: StubImageStorage @Test - fun `등록하면 추출을 기다리지 않고 PENDING 상태로 201 이 즉시 반환된다`() { + fun `등록하면 추출을 기다리지 않고 아이템 없이 PARSING 으로 201 이 즉시 반환된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - stubLinkSnapshotExtractor.build = { ProductSnapshot(link = it, name = "나이키 에어포스", price = 99_000) } + stubLinkSnapshotExtractor.build = { ProductSnapshot(name = "나이키 에어포스", price = 99_000) } val body = objectMapper.writeValueAsString(mapOf("url" to "https://shop.example.com/products/42")) mockMvc @@ -107,8 +111,9 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { .content(body), ).andExpect(status().isCreated) .andExpect(jsonPath("$.data.wish.id").isNumber) - .andExpect(jsonPath("$.data.item.status").value("PENDING")) - // 파싱 전이라 추출 결과는 아직 비어 있고, 입력으로 받은 sourceUrl 만 채워진다. + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("PARSING")) + .andExpect(jsonPath("$.data.item.refreshing").value(false)) .andExpect(jsonPath("$.data.item.name").value(nullValue())) .andExpect(jsonPath("$.data.item.price").value(nullValue())) .andExpect(jsonPath("$.data.item.sourceUrl").value("https://shop.example.com/products/42")) @@ -120,59 +125,70 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `등록 후 파싱이 성공하면 item 이 READY 로 전이하며 추출 결과가 채워진다`() { + fun `등록 후 파싱이 성공하면 위시에 아이템이 확정되고 READY 가 된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubLinkSnapshotExtractor.build = { - ProductSnapshot(link = it, name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") + ProductSnapshot(name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/a.png") } val readyBefore = parseCount("ready", "none") - val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/42") + val slot = registerAndGetSlot(mockMvc, userId, "https://shop.example.com/products/42") - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.READY } - // 결과 메트릭(#506): 성공은 result=ready,reason=none 으로 +1 (파서 비동기라 메트릭 증가도 await). + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.SUCCEEDED } + // 파서 비동기라 메트릭 증가도 기다린다 await().atMost(Duration.ofSeconds(2)).until { parseCount("ready", "none") - readyBefore >= 1.0 } - // 표시값·상태는 활성 snapshot 이 보유한다. item 은 정체성(link)만 든다. - val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals("나이키 에어포스", snapshot.name) - assertEquals(99_000, snapshot.price) - assertEquals("KRW", snapshot.currency) - assertEquals(ItemParseOutboxStatus.SUCCEEDED, jdbcTemplate.parseOutboxStatusOf(itemId)) + val itemId = jdbcTemplate.wishItemIdOf(slot.id) ?: error("wish ${slot.id} 에 아이템이 확정되지 않았다") + val version = versionsOf(itemId).single() + assertTrue(version.isReady()) + assertEquals("나이키 에어포스", version.name) + assertEquals(99_000, version.price) + assertEquals("KRW", version.currency) + assertEquals(normalizedOf("https://shop.example.com/products/42").asCanonical(), itemRepository.findById(itemId)?.canonicalUrl) + + mockMvc + .perform(get("/api/v1/wishlists/${slot.id}").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(itemId)) + .andExpect(jsonPath("$.data.item.status").value("READY")) + .andExpect(jsonPath("$.data.item.refreshing").value(false)) + .andExpect(jsonPath("$.data.item.name").value("나이키 에어포스")) } finally { cleanup(userId) } } @Test - fun `등록 후 상품 페이지가 아니라고 판정되면 item 이 FAILED 로 전이한다`() { + fun `등록 후 상품 페이지가 아니라고 판정되면 아이템 없이 FAILED 로 종결한다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) - // 실패 원장 라인도 latency 를 갖는 계약(#916)을 라인 모양으로 고정한다 — 성공만 latency 를 가지면 - // 타임아웃형(느린) 실패가 로그 기반 소요 통계에서 통째로 사라진다. + // 실패 라인도 latency 를 가져야 타임아웃형 실패가 로그 기반 소요 통계에서 빠지지 않는다(#916) val parserLogs = ListAppender().apply { start() } val parserLogger = LoggerFactory.getLogger(ItemParser::class.java) as Logger parserLogger.addAppender(parserLogs) try { - // 파싱 결과 실패는 동기 400 이 아니라 FAILED 상태로 남는다 (등록 응답은 이미 201 로 끝났으므로). stubLinkSnapshotExtractor.build = { throw ProductSnapshotException.notProductPage() } val notProductBefore = parseCount("failed", "not_product") - val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/not-a-product") + val slot = registerAndGetSlot(mockMvc, userId, "https://shop.example.com/products/not-a-product") - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.FAILED } - // 결과 메트릭(#506): 상품 아님 확정 실패는 result=failed,reason=not_product 로 +1. + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.FAILED } await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "not_product") - notProductBefore >= 1.0 } - val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals(ItemStatus.FAILED, snapshot.status) - assertNull(snapshot.name) - assertEquals(ItemParseOutboxStatus.FAILED, jdbcTemplate.parseOutboxStatusOf(itemId)) - // item 을 고정해 앞선 테스트의 잔류 비동기 로그에 오염되지 않게 하고, latency 는 값 형식(\d+ms)까지 조인다. + assertNull(jdbcTemplate.wishItemIdOf(slot.id)) + mockMvc + .perform(get("/api/v1/wishlists/${slot.id}").header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.item.id").value(nullValue())) + .andExpect(jsonPath("$.data.item.status").value("FAILED")) + .andExpect(jsonPath("$.data.item.sourceUrl").value("https://shop.example.com/products/not-a-product")) + .andExpect(jsonPath("$.data.priceHistory.length()").value(0)) + // 아웃박스를 고정해 앞선 테스트의 잔류 비동기 로그에 오염되지 않게 한다 + val outboxId = jdbcTemplate.latestParseOutboxIdOf(slot) val expectedResultLog = - Regex("""item\.parse\.result item=$itemId result=failed reason=not_product latency=\d+ms""") + Regex("""item\.parse\.result outbox=$outboxId result=failed reason=not_product latency=\d+ms""") assertTrue( parserLogs.list.any { expectedResultLog.containsMatchIn(it.formattedMessage) }, "확정 실패의 item.parse.result 구조화 로그도 latency 를 남겨야 한다", @@ -184,39 +200,36 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `추출이 이름을 못 얻으면 item 이 INCOMPLETE 로 전이하고 얻은 값은 남는다`() { + fun `추출이 이름을 못 얻으면 아이템이 확정되고 INCOMPLETE 버전에 얻은 값이 남는다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - // isProductPage=true 라도 이름을 못 뽑으면 name 이 비어 온다. 예전에는 READY 불변식(name 필수)에 걸려 - // FAILED 로 떨어졌지만, 이제는 채운 만큼을 남기고 INCOMPLETE 로 안착해 사용자가 나머지를 채운다(#944). - stubLinkSnapshotExtractor.build = { ProductSnapshot(link = it, price = 99_000) } + stubLinkSnapshotExtractor.build = { ProductSnapshot(price = 99_000) } val incompleteBefore = parseCount("incomplete", "none") - val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/no-name") + val slot = registerAndGetSlot(mockMvc, userId, "https://shop.example.com/products/no-name") - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.INCOMPLETE } - // 결과 메트릭(#506): 부분 성공은 실패에 섞지 않고 result=incomplete,reason=none 으로 +1 한다. + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.SUCCEEDED } + // 부분 성공은 실패에 섞지 않는다(#506) await().atMost(Duration.ofSeconds(2)).until { parseCount("incomplete", "none") - incompleteBefore >= 1.0 } - val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals(ItemStatus.INCOMPLETE, snapshot.status) - assertNull(snapshot.name, "못 얻은 필드는 비어 있어 사용자가 채운다") - assertEquals(99_000, snapshot.price, "얻은 값은 버려지지 않는다") - assertNotNull(snapshot.extractedAt) + val itemId = jdbcTemplate.wishItemIdOf(slot.id) ?: error("wish ${slot.id} 에 아이템이 확정되지 않았다") + val version = versionsOf(itemId).single() + assertTrue(version.isIncomplete()) + assertNull(version.name, "못 얻은 필드는 비어 있어 사용자가 채운다") + assertEquals(99_000, version.price, "얻은 값은 버려지지 않는다") } finally { cleanup(userId) } } @Test - fun `같은 URL 재등록은 공유 정체성 기준 409 로 막혀 wish 가 1건만 남는다`() { + fun `같은 사용자가 같은 URL 을 다시 등록하면 파싱 중에도 확정 뒤에도 409 로 막힌다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - stubLinkSnapshotExtractor.build = { ProductSnapshot(link = it, name = "기본 상품") } - // 이 파일의 다른 테스트와 URL 을 공유하지 않는다 — 정체성 매칭(별칭)이 테스트 간 상태가 되므로 전용 URL 로 격리. + stubLinkSnapshotExtractor.build = { ProductSnapshot(name = "기본 상품") } val body = objectMapper.writeValueAsString(mapOf("url" to "https://shop.example.com/products/9942")) val auth = "Bearer ${memberToken(userId)}" @@ -227,10 +240,24 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, auth) .content(body), ).andExpect(status().isCreated) - .andExpect(jsonPath("$.data.item.status").value("PENDING")) + .andExpect(jsonPath("$.data.item.status").value("PARSING")) + + mockMvc + .perform( + post("/api/v1/wishlists") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, auth) + .content(body), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("WISH-009")) + + val wishId = + jdbcTemplate.queryForObject("SELECT id FROM wishes WHERE user_id = ?", Long::class.java, uuidToBytes(userId)) + ?: error("위시가 없다") + itemParsingScheduler.awaitTicking { + jdbcTemplate.parseOutboxStatusOf(ItemSlot.wish(wishId)) == ItemParseOutboxStatus.SUCCEEDED + } - // 공유 정체성(#825 활성화) — 같은 사용자가 같은 상품을 다시 담으면 새 카드 대신 409. - // (옛 dedup 없는 multi-record 모델을 뒤집은 새 계약이다.) mockMvc .perform( post("/api/v1/wishlists") @@ -253,55 +280,54 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `이미지로 등록하면 PENDING 으로 201 즉시 반환 후 파싱 성공 시 READY 로 전이한다`() { + fun `이미지로 등록하면 파싱 성공 시 링크 없는 아이템이 확정되고 READY 가 된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubImageSnapshotExtractor.build = { - ProductSnapshot(link = null, name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/af.png") + ProductSnapshot(name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/af.png") } - val itemId = registerImageAndGetItemId(mockMvc, userId) - - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.READY } - val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals("나이키 에어포스", snapshot.name) - assertEquals(99_000, snapshot.price) - assertEquals("KRW", snapshot.currency) - // 이미지 등록은 link(원본 URL)가 없다 — link 는 정체성이라 item 에서 읽는다. + val (slot, imageKey) = registerImage(mockMvc, userId) + + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.SUCCEEDED } + val itemId = jdbcTemplate.wishItemIdOf(slot.id) ?: error("wish ${slot.id} 에 아이템이 확정되지 않았다") + val version = versionsOf(itemId).single() + assertTrue(version.isReady()) + assertEquals("나이키 에어포스", version.name) + assertEquals(99_000, version.price) + assertEquals("KRW", version.currency) val item = itemRepository.findById(itemId) ?: error("item $itemId 가 없다") - assertNull(item.link) + assertNull(item.sourceLink()) } finally { cleanup(userId) } } @Test - fun `이미지 파싱이 확정 실패(상품 아님)면 item 이 FAILED 로 전이한다`() { + fun `이미지 파싱이 확정 실패(상품 아님)면 아이템 없이 FAILED 로 종결한다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubImageSnapshotExtractor.build = { throw ProductSnapshotException.notProductPage() } - val itemId = registerImageAndGetItemId(mockMvc, userId) + val (slot, _) = registerImage(mockMvc, userId) - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.FAILED } - val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals(ItemStatus.FAILED, snapshot.status) - assertNull(snapshot.name) + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.FAILED } + assertNull(jdbcTemplate.wishItemIdOf(slot.id)) } finally { cleanup(userId) } } @Test - fun `이미지 5개를 등록하면 모두 PENDING 으로 반환되고 각각 READY 로 전이한다`() { + fun `이미지 5개를 등록하면 모두 PARSING 으로 반환되고 각각 아이템이 확정된다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { stubImageSnapshotExtractor.build = { - ProductSnapshot(link = null, name = "상품", price = 1_000, imageUrl = "https://img.example.com/p.png") + ProductSnapshot(name = "상품", price = 1_000, imageUrl = "https://img.example.com/p.png") } val presignResponse = mockMvc @@ -325,23 +351,28 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { .content(objectMapper.writeValueAsString(mapOf("imageKeys" to imageKeys))), ).andExpect(status().isCreated) .andExpect(jsonPath("$.data.length()").value(5)) - // 이미지도 link 처럼 등록 직후 READY 를 내리는 회귀를 잡음 - .andExpect(jsonPath("$.data[0].item.status").value("PENDING")) - .andExpect(jsonPath("$.data[4].item.status").value("PENDING")) + .andExpect(jsonPath("$.data[0].item.status").value("PARSING")) + .andExpect(jsonPath("$.data[4].item.status").value("PARSING")) .andReturn() .response .getContentAsString(Charsets.UTF_8) val dataNode = objectMapper.readTree(response).path("data") - val itemIds = + val slots = (0 until dataNode.size()).map { i -> - dataNode - .path(i) - .path("item") - .path("id") - .asLong() + ItemSlot.wish( + dataNode + .path(i) + .path("wish") + .path("id") + .asLong(), + ) } - itemParsingScheduler.awaitTicking { itemIds.all { latestSnapshot(it)?.status == ItemStatus.READY } } + itemParsingScheduler.awaitTicking { + slots.all { jdbcTemplate.parseOutboxStatusOf(it) == ItemParseOutboxStatus.SUCCEEDED } + } + val itemIds = slots.map { jdbcTemplate.wishItemIdOf(it.id) ?: error("wish ${it.id} 에 아이템이 확정되지 않았다") } + assertEquals(5, itemIds.distinct().size) } finally { cleanup(userId) } @@ -355,16 +386,13 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { try { stubLinkSnapshotExtractor.build = { throw ProductExtractorException.permanentFailure() } val internalErrorBefore = parseCount("failed", "internal_error") - val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/blocked") + val slot = registerAndGetSlot(mockMvc, userId, "https://shop.example.com/products/blocked") - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.FAILED } - // 결과 메트릭(#506·#936): 우리 방어(SSRF 가드)로 확정 실패한 건은 result=failed,reason=internal_error 로 +1. - // reason 이 예외에서 파생되므로, 이 단언이 파서에서 메트릭까지 배선을 함께 고정한다. + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.FAILED } + // reason 이 예외에서 파생되므로 파서에서 메트릭까지 배선을 함께 고정한다 await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "internal_error") - internalErrorBefore >= 1.0 } - val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals(ItemStatus.FAILED, snapshot.status) - assertNull(snapshot.name) + assertNull(jdbcTemplate.wishItemIdOf(slot.id)) } finally { cleanup(userId) } @@ -378,12 +406,12 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { try { stubLinkSnapshotExtractor.build = { throw ProductExtractorException.transientFailure(null) } val internalErrorBefore = parseCount("failed", "internal_error") - val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/transient") + val slot = registerAndGetSlot(mockMvc, userId, "https://shop.example.com/products/transient") - itemParsingScheduler.awaitTicking { latestSnapshot(itemId)?.status == ItemStatus.FAILED } + itemParsingScheduler.awaitTicking { jdbcTemplate.parseOutboxStatusOf(slot) == ItemParseOutboxStatus.FAILED } await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "internal_error") - internalErrorBefore >= 1.0 } - assertEquals(ItemParseOutboxStatus.FAILED, jdbcTemplate.parseOutboxStatusOf(itemId)) + assertNull(jdbcTemplate.wishItemIdOf(slot.id)) } finally { cleanup(userId) } @@ -445,7 +473,7 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { Triple("example.com/products/1", "LINK-002", "https 링크만 등록할 수 있어요."), Triple("http://example.com/products/1", "LINK-002", "https 링크만 등록할 수 있어요."), Triple("", "COMMON-INVALID-INPUT", "링크를 입력해 주세요."), - Triple("https://a.com/" + "x".repeat(2048), "COMMON-INVALID-INPUT", "링크가 너무 길어요."), + Triple("https://a.com/" + "x".repeat(ProductLink.MAX_LENGTH), "COMMON-INVALID-INPUT", "링크가 너무 길어요."), ) cases.forEach { (url, code, detail) -> mockMvc @@ -463,11 +491,11 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } } - private fun registerAndGetItemId( + private fun registerAndGetSlot( mockMvc: MockMvc, userId: UUID, url: String, - ): Long { + ): ItemSlot { val body = objectMapper.writeValueAsString(mapOf("url" to url)) val response = mockMvc @@ -480,20 +508,21 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { .andReturn() .response .getContentAsString(Charsets.UTF_8) - return objectMapper - .readTree(response) - .path("data") - .path("item") - .path("id") - .asLong() + return ItemSlot.wish( + objectMapper + .readTree(response) + .path("data") + .path("wish") + .path("id") + .asLong(), + ) } - // 이미지 한 장을 등록하고 그 item id 를 돌려준다 — 발급(presigned) → 확정(confirm) 2단계를 그대로 탄다. - // 업로드 자체는 클라가 S3 에 직접 하므로 여기선 재현하지 않는다(StubImageStorage.exists 기본값이 "올라왔다"). - private fun registerImageAndGetItemId( + // 업로드는 클라가 S3 에 직접 하므로 재현하지 않는다(StubImageStorage.exists 기본값이 "올라왔다") + private fun registerImage( mockMvc: MockMvc, userId: UUID, - ): Long { + ): Pair { val presignResponse = mockMvc .perform( @@ -524,13 +553,15 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { .andReturn() .response .getContentAsString(Charsets.UTF_8) - return objectMapper - .readTree(response) - .path("data") - .path(0) - .path("item") - .path("id") - .asLong() + val wishId = + objectMapper + .readTree(response) + .path("data") + .path(0) + .path("wish") + .path("id") + .asLong() + return ItemSlot.wish(wishId) to imageKey } private fun buildMockMvc(): MockMvc = @@ -550,8 +581,7 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { private fun memberToken(userId: UUID): String = jwtProvider.generateAccessToken(userId, IdentityType.MEMBER) - // 표시값·상태는 item 의 활성(최신) snapshot 이 보유한다. 폴링·단언이 이 snapshot 을 읽는다. - private fun latestSnapshot(itemId: Long): ItemSnapshot? = itemSnapshotRepository.findLatestByItemId(itemId) + private fun versionsOf(itemId: Long): List = itemSnapshotRepository.findAllByItemIds(listOf(itemId)) // item.parsing 카운터의 현재 값. 공유 컨텍스트라 누적되므로 호출 전후 증가분(delta)으로 단언한다(#468 패턴). private fun parseCount( @@ -559,17 +589,8 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { reason: String, ): Double = meterRegistry.find("item.parsing").tags("result", result, "reason", reason).counter()?.count() ?: 0.0 - // @Transactional 자동 롤백이 없으므로 이 테스트가 만든 user·wish·item·snapshot 을 직접 정리한다. private fun cleanup(userId: UUID) { - // wishes 는 item_id 를 더 들지 않아 snapshot_id 로 item_snapshots 를 조인해 itemId 에 도달한다. - val itemIds = - jdbcTemplate.queryForList( - "SELECT s.item_id FROM wishes w JOIN item_snapshots s ON s.id = w.snapshot_id WHERE w.user_id = ?", - Long::class.java, - uuidToBytes(userId), - ) - jdbcTemplate.update("DELETE FROM wishes WHERE user_id = ?", uuidToBytes(userId)) - jdbcTemplate.deleteItems(itemIds.filterNotNull()) + jdbcTemplate.deleteWishDataOf(userId) jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(userId)) } } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishMemoTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishMemoTest.kt new file mode 100644 index 000000000..49c4a943e --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishMemoTest.kt @@ -0,0 +1,49 @@ +package com.depromeet.piki.wishlist.domain + +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WishMemoTest { + @Test + fun `written 은 앞뒤 공백을 정리해 메모를 만든다`() { + assertEquals("생일 선물 후보", WishMemo.written(1L, " 생일 선물 후보 ").content) + } + + @Test + fun `written 에 빈 문자열이나 공백만 주면 content 없는 지운 기록이 된다`() { + assertNull(WishMemo.written(1L, "").content) + assertNull(WishMemo.written(1L, " ").content) + } + + @Test + fun `written 은 100자까지 허용하고 초과하면 실패한다`() { + assertEquals(100, WishMemo.written(1L, "가".repeat(100)).content?.length) + assertFailsWith { WishMemo.written(1L, "가".repeat(101)) } + } + + @Test + fun `공백뿐인 content 로는 만들 수 없다`() { + assertFailsWith { WishMemo(wishId = 1L, content = " ", writtenAt = LocalDateTime.now()) } + } + + @Test + fun `sameContentAs 는 현재 메모와 내용이 같은지 본다`() { + val current = WishMemo.written(1L, "선물용") + + assertTrue(WishMemo.written(1L, " 선물용 ").sameContentAs(current)) + assertFalse(WishMemo.written(1L, "사이즈 M").sameContentAs(current)) + assertFalse(WishMemo.written(1L, "").sameContentAs(current)) + } + + @Test + fun `메모가 없을 때 지우면 현재와 같은 것으로 본다`() { + assertTrue(WishMemo.written(1L, "").sameContentAs(null)) + assertTrue(WishMemo.written(1L, "").sameContentAs(WishMemo.written(1L, ""))) + assertFalse(WishMemo.written(1L, "선물용").sameContentAs(null)) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishTest.kt index bd3c4f90f..f37402f24 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/domain/WishTest.kt @@ -1,5 +1,8 @@ package com.depromeet.piki.wishlist.domain +import com.depromeet.piki.item.domain.CardQuery +import com.depromeet.piki.item.domain.ItemSlot +import com.depromeet.piki.support.withId import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -8,66 +11,67 @@ import kotlin.test.assertNull class WishTest { @Test - fun `waitFor 는 기다리는 행을 새 snapshot id 로 바꾼다`() { - // 수동 새로고침(5단계) — 새 추출 버전으로 활성 포인터를 교체한다. 옛 snapshot 행은 유지(토너먼트 출전 격리). - val wish = Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = 1L) - wish.waitFor(20L) - assertEquals(20L, wish.waitingSnapshotId) + fun `파싱 성공 전 위시는 아이템 없이 생성된다`() { + assertNull(Wish(userId = UUID.randomUUID(), itemId = null).itemId) } @Test - fun `waitFor 에 0 이하 id 를 주면 실패한다`() { - val wish = Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = 1L) - assertFailsWith { wish.waitFor(0L) } - assertFailsWith { wish.waitFor(-1L) } + fun `itemId 가 0 이하면 생성에 실패한다`() { + assertFailsWith { Wish(userId = UUID.randomUUID(), itemId = 0L) } + assertFailsWith { Wish(userId = UUID.randomUUID(), itemId = -1L) } } @Test - fun `itemId 가 0 이하면 생성에 실패한다`() { - assertFailsWith { Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = 0L) } - assertFailsWith { Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = -1L) } + fun `fill 은 비어 있던 아이템을 확정한다`() { + val wish = Wish(userId = UUID.randomUUID(), itemId = null) + + wish.fill(3L) + + assertEquals(3L, wish.itemId) } @Test - fun `waitingSnapshotId 가 0 이하면 생성에 실패한다`() { - assertFailsWith { Wish(userId = UUID.randomUUID(), waitingSnapshotId = 0L, itemId = 1L) } - assertFailsWith { Wish(userId = UUID.randomUUID(), waitingSnapshotId = -1L, itemId = 1L) } + fun `같은 아이템으로 다시 확정하면 멱등이다`() { + val wish = Wish(userId = UUID.randomUUID(), itemId = 3L) + + wish.fill(3L) + + assertEquals(3L, wish.itemId) } @Test - fun `verifyOwnedBy 는 소유자가 아니면 WishException 을 던진다`() { - val owner = UUID.randomUUID() - val wish = Wish(userId = owner, waitingSnapshotId = 10L, itemId = 1L) - assertFailsWith { wish.verifyOwnedBy(UUID.randomUUID()) } + fun `이미 확정된 위시를 다른 아이템으로 확정하면 코드 버그다`() { + val wish = Wish(userId = UUID.randomUUID(), itemId = 3L) + + assertFailsWith { wish.fill(4L) } } @Test - fun `verifyOwnedBy 는 소유자가 호출하면 예외 없이 통과한다`() { - val owner = UUID.randomUUID() - val wish = Wish(userId = owner, waitingSnapshotId = 10L, itemId = 1L) - wish.verifyOwnedBy(owner) // 소유자면 예외 없이 반환된다 + fun `fill 에 0 이하 id 를 주면 실패한다`() { + val wish = Wish(userId = UUID.randomUUID(), itemId = null) + + assertFailsWith { wish.fill(0L) } } @Test - fun `updateMemo 는 앞뒤 공백을 정리해 메모를 저장한다`() { - val wish = Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = 1L) - wish.updateMemo(" 생일 선물 후보 ") - assertEquals("생일 선물 후보", wish.memo) + fun `cardQuery 는 이 위시를 요청자로 주인과 아이템을 담는다`() { + val owner = UUID.randomUUID() + val wish = Wish(userId = owner, itemId = 3L).withId(9L) + + assertEquals(CardQuery(slot = ItemSlot.wish(9L), itemId = 3L, owner = owner), wish.cardQuery()) } @Test - fun `updateMemo 에 빈 문자열이나 공백만 주면 메모가 삭제된다`() { - val wish = Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = 1L) - wish.updateMemo("메모") - wish.updateMemo(" ") - assertNull(wish.memo) + fun `verifyOwnedBy 는 소유자가 아니면 WishException 을 던진다`() { + val wish = Wish(userId = UUID.randomUUID(), itemId = 1L) + + assertFailsWith { wish.verifyOwnedBy(UUID.randomUUID()) } } @Test - fun `updateMemo 는 100자까지 허용하고 초과하면 실패한다`() { - val wish = Wish(userId = UUID.randomUUID(), waitingSnapshotId = 10L, itemId = 1L) - wish.updateMemo("가".repeat(100)) - assertEquals(100, wish.memo?.length) - assertFailsWith { wish.updateMemo("가".repeat(101)) } + fun `verifyOwnedBy 는 소유자가 호출하면 예외 없이 통과한다`() { + val owner = UUID.randomUUID() + + Wish(userId = owner, itemId = 1L).verifyOwnedBy(owner) } } diff --git a/terraform/s3.tf b/terraform/s3.tf index c9c3358fd..0ce85f41a 100644 --- a/terraform/s3.tf +++ b/terraform/s3.tf @@ -104,7 +104,7 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "dev_images" { # cross-origin 이미지의 픽셀을 읽도록 S3 가 Access-Control-Allow-Origin 헤더를 내려줘야 한다. # CORS 는 접근 제어가 아니다(버킷은 이미 public-read) — 브라우저 JS 의 read 허용 헤더일 뿐이라 # 노출 표면이 늘지 않는다. origin 은 piki.day + 로컬 개발로 한정한다. -# PUT 은 브라우저가 presigned URL 로 raw 원본(items/raw/)을 직접 올리는 경로(이미지 등록 v2)다. +# PUT 은 브라우저가 presigned URL 로 raw 원본(items/raw/·profiles/raw/)을 직접 올리는 경로다. # 쓰기 권한은 여전히 서명이 결정하고, CORS 는 브라우저가 그 요청을 보내게 허용할 뿐이다. # 외부 쇼핑몰 CDN 이미지(msscdn·pstatic·kakaocdn 등)는 우리 버킷이 아니라 여기서 못 푼다 — # 그건 별도 이미지 프록시 API 로 처리한다. diff --git a/tournament-match-bracket-spec.md b/tournament-match-bracket-spec.md index bc597a4bb..3700396dd 100644 --- a/tournament-match-bracket-spec.md +++ b/tournament-match-bracket-spec.md @@ -23,6 +23,8 @@ return isMounted ? shufflePairs(sorted) : sorted; // Math.random Fisher-Yates, ### 서버에 이미 있는 것 (`TournamentService.kt`) +> 아래 표는 작성 당시(#683) 기준이다. 지금은 클론 토너먼트를 새로 만들지 않고(#1027), 시작한 토너먼트의 가격은 고정 snapshot 이 아니라 시작 시각까지의 버전으로 계산한다. 줄 번호도 그때 기준이다. + | 자산 | 위치 | |---|---| | 가격 정렬 (`price ASC, tournamentItemId ASC`) | `:377` |