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 79727f57..89427698 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshot.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemSnapshot.kt @@ -62,7 +62,7 @@ class ItemSnapshot( var currency: String? = currency protected set - // 이 버전의 추출 생애주기. PENDING(대기)→PROCESSING(추출 중)→READY(완료)/FAILED(실패). + // 이 버전의 추출 생애주기. PENDING(대기)→PROCESSING(추출 중)→READY(완료)/INCOMPLETE(일부만 채움)/FAILED(실패). // 상태는 되돌리지 않는다 — 수기 수정은 이 행을 고치지 않고 MANUAL 새 버전을 쌓는다(#825 결정 4). @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 16) @@ -140,11 +140,18 @@ class ItemSnapshot( status = ItemStatus.FAILED } - // PROCESSING → READY. 백그라운드 파싱이 성공해 추출 결과(snapshot)를 채우며 전이한다. + // PROCESSING → READY / INCOMPLETE / FAILED. 백그라운드 파싱이 끝나 추출 결과(snapshot)를 채우며 전이한다. // 전이 가능 상태가 아닌데 호출되면 워커가 잘못된 버전을 집은 코드 버그이므로 check(500). // extractedAt 은 전이 시점의 now() — Wish.delete() 등 도메인이 시간을 만드는 프로젝트 관례를 따른다. - fun markReady(snapshot: ProductSnapshot) { - check(status == ItemStatus.PROCESSING) { "PROCESSING 이 아닌 snapshot(status=$status)은 READY 로 전이할 수 없다" } + // + // 결과는 **추출이 무엇을 건졌는지**로만 갈린다 (#944): + // - 세 필드(name·price·imageUrl)를 다 얻음 → READY + // - 일부만 얻음 → INCOMPLETE. 사용자가 나머지를 채워 완성한다. 사진에 가격이 없는 것은 정상 입력이라, + // 여기서 실패로 끝내면 "쇼핑몰 화면을 캡처한 것"만 통과하는 계약이 된다. + // - 하나도 못 얻음 → FAILED. 사용자에게 무엇을 채우라 할 근거조차 없다. + // 반환값은 확정된 상태다 — 호출부(서비스)가 이 값으로 발행할 이벤트를, 워커가 로그·메트릭을 가른다. + fun markExtracted(snapshot: ProductSnapshot): ItemStatus { + check(status == ItemStatus.PROCESSING) { "PROCESSING 이 아닌 snapshot(status=$status)은 추출 결과로 전이할 수 없다" } apply( name = snapshot.name, price = snapshot.price, @@ -153,12 +160,22 @@ class ItemSnapshot( ) // 출처(#825 결정 4) — 어느 기계가 뽑았는지를 버전에 박는다. 구버전 extractor 응답(method 없음)은 null(미기록). this.source = ItemSnapshotSource.fromWireMethod(snapshot.extractionMethod) - // 추출 결과와 추출시각을 채운 뒤 불변식을 검사한다 — READY 가 보장하는 네 필드(name·price·imageUrl·extractedAt)를 - // 한 자리에서 확정하려고 set 을 검사 앞에 둔다. 추출이 이름을 못 얻었으면 READY 부적격 — - // 워커가 이 예외를 받아 FAILED 로 흡수한다(PROCESSING 방치 방지). + // 건진 값이 없으면 추출시각도 남기지 않는다 — 추출한 값이 없는데 "언제 추출했나"는 의미가 없다. + if (hasNoExtractedValue()) { + status = ItemStatus.FAILED + return status + } + // 추출 결과와 추출시각을 채운 뒤 불변식을 검사한다 — 각 상태가 보장하는 필드를 한 자리에서 확정하려고 + // set 을 검사 앞에 둔다. this.extractedAt = LocalDateTime.now() + if (!hasAllReadyFields()) { + requireIncompleteInvariant() + status = ItemStatus.INCOMPLETE + return status + } requireReadyInvariant() status = ItemStatus.READY + return status } // PROCESSING → FAILED. 파싱 실패(상품 아님·신뢰 불가·타임아웃)를 동기 400 대신 상태로 남긴다. @@ -169,8 +186,12 @@ class ItemSnapshot( // 파싱이 끝나 추출 결과가 채워진 버전인지. 토너먼트 출전·목록 노출처럼 "완성된 버전만" 요구하는 게이트에서 쓴다. // PROCESSING(파싱 중)·FAILED(실패)는 false — 이름·가격이 비어 출전에 부적합하다. + // INCOMPLETE 도 false 다 — 사용자가 나머지를 채우기 전까지는 같은 이유로 부적합하다(#944). fun isReady(): Boolean = status == ItemStatus.READY + // 파싱은 끝났으나 사용자 입력을 기다리는 버전인지. 클라이언트가 "나머지를 채워 주세요" 화면으로 유도하는 근거다. + fun isIncomplete(): Boolean = status == ItemStatus.INCOMPLETE + // 추출이 실패로 종결된 버전인지. 새로고침의 FAILED 차단(수기 수정 유도) 등 상태 분기에서 쓴다. fun isFailed(): Boolean = status == ItemStatus.FAILED @@ -190,6 +211,28 @@ class ItemSnapshot( requireNotNull(extractedAt) { "READY snapshot 은 extractedAt 이 있어야 한다" } } + // INCOMPLETE 불변식 — 사용자가 나머지를 채워 완성할 수 있는 버전이라, 추출이 최소 하나는 건졌고 그 시각이 남아 있어야 + // 한다. 하나도 못 건졌으면 FAILED 로 끝냈어야 하는 행이므로 여기 닿으면 markExtracted 의 분기가 깨진 코드 버그다. + private fun requireIncompleteInvariant() { + require(!hasNoExtractedValue()) { "INCOMPLETE snapshot 은 추출값이 최소 하나 있어야 한다" } + requireNotNull(extractedAt) { "INCOMPLETE snapshot 은 extractedAt 이 있어야 한다" } + } + + // READY 세 필드를 다 채웠는지 (extractedAt 은 전이가 직접 채우므로 여기서 보지 않는다). + private fun hasAllReadyFields(): Boolean { + if (name.isNullOrBlank()) return false + price ?: return false + imageUrl ?: return false + return true + } + + // 추출값을 하나도 못 얻었는지 — 사용자에게 무엇을 채우라 할 근거조차 없는 상태. currency 는 READY 필수가 아니라 + // 단독으로는 "건졌다"의 근거가 되지 못하므로 세지 않는다. + private fun hasNoExtractedValue(): Boolean { + val extracted = listOfNotNull(name?.takeIf { it.isNotBlank() }, price, imageUrl) + return extracted.isEmpty() + } + private fun validate( name: String?, price: Int?, 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 d9c29fc0..462c493f 100644 --- a/src/main/kotlin/com/depromeet/piki/item/domain/ItemStatus.kt +++ b/src/main/kotlin/com/depromeet/piki/item/domain/ItemStatus.kt @@ -13,6 +13,12 @@ enum class ItemStatus { // 프로세스가 죽어 반납조차 못 한 채 여기 갇힌 행은 recover 가 재실행으로 되살린다(execution at-least-once, #461). PROCESSING, + // 파싱은 끝났지만 READY 세 필드(name·price·imageUrl) 중 일부만 채워진 상태. 사용자가 나머지를 채워야 쓸 수 있다. + // 사진에는 가격이 찍혀 있지 않은 것이 정상이라, "하나라도 비면 등록 거부" 대신 채운 만큼 내려주고 나머지를 사용자에게 + // 맡긴다(#944). READY 취급을 받지 못하므로 토너먼트 출전 등 "완성된 버전만" 요구하는 게이트에서는 걸러진다. + // 사용자가 빈 필드를 채우면 수기 수정(manual)이 이 버전을 base 로 새 READY 버전을 쌓는다. + INCOMPLETE, + // 파싱 완료. 추출된 상품 정보가 채워졌다. READY, diff --git a/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt new file mode 100644 index 00000000..55ca0136 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/item/event/ItemParsingIncomplete.kt @@ -0,0 +1,9 @@ +package com.depromeet.piki.item.event + +// 아이템 파싱이 일부 필드만 채우고 끝났다 — 도메인 사실. 실패가 아니라 "사용자가 나머지를 채우면 완성되는 상태"라 +// 완료·실패와 별개 사실로 둔다(#944). 소비자(알림)가 "나머지는 직접 채워주세요" 로 유도하는 근거다. +data class ItemParsingIncomplete( + val itemId: Long, + // ItemParsingCompleted 와 같은 이유(#576) — 라우팅은 버전 단위가 정확하다. + val snapshotId: Long, +) diff --git a/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt b/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt index 78fd814e..99a56929 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/AsyncImageParsingWorker.kt @@ -5,6 +5,7 @@ import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.common.exception.HttpMappable import com.depromeet.piki.common.storage.ImageStorage import com.depromeet.piki.image.service.ImageSnapshotExtractor +import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.product.service.ProductSnapshot import io.micrometer.core.instrument.MeterRegistry import io.micrometer.observation.Observation @@ -19,9 +20,10 @@ import org.springframework.stereotype.Component // 위임하고, 이 워커는 상태 전이·재시도 정책·raw 회수만 진다. // 외부 호출은 트랜잭션 바깥에서 끝내고, 상태 전이 영속화만 ItemParsingService(@Transactional)에 위임한다. // -// 결과는 셋으로 갈린다(AsyncItemParsingWorker 와 동일한 execution at-least-once 정책, #461): +// 결과는 넷으로 갈린다(AsyncItemParsingWorker 와 동일한 execution at-least-once 정책, #461): // - 성공 → READY. 파싱이 끝났으니 raw 원본을 회수(delete)한다. -// - 확정 실패(상품 아님·추출값 신뢰 불가·READY 전이 거부) → 즉시 FAILED + raw 회수. 다시 해도 결과가 같다. +// - 부분 성공(일부 필드만 채움) → INCOMPLETE + raw 회수. 사용자가 나머지를 채워 완성한다(#944). +// - 확정 실패(상품 아님·추출값 신뢰 불가·값 0개) → 즉시 FAILED + raw 회수. 다시 해도 결과가 같다. // - 일시 외부 오류(원격 추출 서비스 5xx·연결 실패 등 RETRYABLE) → 소유권 반납(release, PROCESSING→PENDING). raw 는 보존하고 다음 tick 이 다시 집는다. @Component class AsyncImageParsingWorker( @@ -80,23 +82,18 @@ class AsyncImageParsingWorker( ) { val elapsedMs = (System.nanoTime() - started) / 1_000_000 // 일시 DB 오류(데드락·lock timeout)면 추출 재실행 없이 전이 write 만 짧게 재시도한다(TransitionRetry). - runCatchingException { transitionRetry.execute { itemParsingService.markReady(snapshotId, snapshot, attempt) } } - .onSuccess { applied -> - // 좀비 폐기(소유권 상실)면 전이가 스킵된다 — 결과를 성공으로 세지 않고, **특히 raw 를 지우지 않는다**. + runCatchingException { transitionRetry.execute { itemParsingService.markExtracted(snapshotId, snapshot, attempt) } } + .onSuccess { status -> + // 좀비 폐기(소유권 상실)면 전이가 스킵된다 — 결과를 세지 않고, **특히 raw 를 지우지 않는다**. // 재클레임된 새 시도가 바로 그 원본으로 재실행해야 하므로, 여기서 지우면 되살릴 입력을 잃는다. - if (!applied) { - log.info("item {} 이미지 좀비 결과 — 전이·raw 회수 생략 (attempt={})", itemId, attempt) - return@onSuccess - } - // 링크 워커와 같은 구조화 결과 라인 — 로그 기반 결과 분포·알림이 이미지 경로도 같은 모집단으로 세게 한다(#902). - log.info( - "item.parse.result item={} type=image result={} reason={} latency={}ms", - itemId, - ItemParsingMetrics.RESULT_READY, - ItemParsingMetrics.REASON_NONE, - elapsedMs, - ) - ItemParsingMetrics.record(meterRegistry, ItemParsingMetrics.RESULT_READY, ItemParsingMetrics.REASON_NONE) + val settled = + status ?: run { + log.info("item {} 이미지 좀비 결과 — 전이·raw 회수 생략 (attempt={})", itemId, attempt) + return@onSuccess + } + recordOutcome(itemId, snapshot, settled, elapsedMs) + // 셋 다 종결이라 raw 를 회수한다 — INCOMPLETE 도 재파싱하지 않는다(파싱 기회는 단번). 사용자가 채울 + // 화면이 쓰는 이미지는 추출이 올린 결과물(imageUrl)이지 raw 원본이 아니다. deleteRawQuietly(imageKey) } .onFailure { e -> @@ -120,6 +117,47 @@ class AsyncImageParsingWorker( } } + // 종결 결과를 원장(로그·메트릭)에 남긴다. 링크 워커와 같은 구조화 결과 라인이라 이미지 경로도 같은 모집단으로 + // 세어진다(#902). INCOMPLETE 만 missing 을 덧붙이는 이유는 링크 워커 recordOutcome 주석과 같다(#944). + private fun recordOutcome( + itemId: Long, + snapshot: ProductSnapshot, + status: ItemStatus, + elapsedMs: Long, + ) { + val result = + when (status) { + ItemStatus.READY -> ItemParsingMetrics.RESULT_READY + ItemStatus.INCOMPLETE -> ItemParsingMetrics.RESULT_INCOMPLETE + ItemStatus.FAILED -> ItemParsingMetrics.RESULT_FAILED + ItemStatus.PENDING, ItemStatus.PROCESSING -> error("추출 전이가 만들 수 없는 상태 $status") + } + val reason = + when (status) { + ItemStatus.FAILED -> ItemParsingMetrics.REASON_EXTRACT_QUALITY + else -> ItemParsingMetrics.REASON_NONE + } + if (status == ItemStatus.INCOMPLETE) { + log.info( + "item.parse.result item={} type=image result={} reason={} latency={}ms missing={}", + itemId, + result, + reason, + elapsedMs, + ItemParsingMetrics.missingFieldsOf(snapshot), + ) + } else { + log.info( + "item.parse.result item={} type=image result={} reason={} latency={}ms", + itemId, + result, + reason, + elapsedMs, + ) + } + ItemParsingMetrics.record(meterRegistry, result, reason) + } + // 파싱 실패는 두 갈래다 — 일시 오류는 소유권을 반납해 다시 집히게 하고, 확정 실패만 즉시 종결한다. // 판정은 ErrorCategory 가 쥔다: RETRYABLE(원격 추출 서비스 5xx·연결 실패 등)만 반납하고, // 그 외(비-HttpMappable 예외 포함)는 즉시 FAILED — 재시도해도 같은 결과인 코드 버그성 예외라 되살리지 않는다 diff --git a/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt b/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt index 121ba239..a1fbf47d 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/AsyncItemParsingWorker.kt @@ -3,6 +3,7 @@ package com.depromeet.piki.item.service import com.depromeet.piki.common.config.AsyncConfig import com.depromeet.piki.common.exception.ErrorCategory import com.depromeet.piki.common.exception.HttpMappable +import com.depromeet.piki.item.domain.ItemStatus import com.depromeet.piki.product.domain.ProductLink import com.depromeet.piki.product.service.ProductLinkExtractor import com.depromeet.piki.product.service.ProductSnapshot @@ -15,8 +16,8 @@ import org.springframework.stereotype.Component // itemParsingExecutor 스레드에서 "단건 파싱 한 번"을 수행한다. 외부 호출(extract)은 트랜잭션 바깥에서 끝내고, // 상태 전이 영속화만 ItemParsingService(@Transactional) 에 위임해 짧은 트랜잭션으로 묶는다. -// 결과 처리는 셋으로 갈린다(execution at-least-once, #461): 성공 → READY, 확정 실패(상품 아님·이름 없음) → 즉시 FAILED, -// 일시 외부 오류 → 소유권 반납(release, PROCESSING→PENDING)해 다음 tick 이 다시 집게 한다. +// 결과 처리는 넷으로 갈린다(execution at-least-once, #461): 성공 → READY, 부분 성공(일부 필드만 채움) → INCOMPLETE(#944), +// 확정 실패(상품 아님·값 0개) → 즉시 FAILED, 일시 외부 오류 → 소유권 반납(release, PROCESSING→PENDING)해 다음 tick 이 다시 집게 한다. // 반납은 "이 실행은 결론 없이 끝났다"는 **사실 통지**일 뿐이고, 재시도할지 종결할지의 **정책은 여전히 서비스가 쥔다** // (실행 예산이 남았으면 PENDING 으로 되돌리고, 소진했으면 그 자리에서 FAILED). // 전이 호출(markReady/markFailed)은 runCatchingException 으로 감싸 워커 스레드로 예외가 새지 않게 한다 @@ -78,25 +79,21 @@ class AsyncItemParsingWorker( val elapsedMs = (System.nanoTime() - started) / 1_000_000 // 전이가 실패(추출값 도메인 검증 위반·DB 오류·sweeper 와의 레이스로 이미 전이됨)해도 예외를 흡수한다. // 일시 DB 오류(데드락·lock timeout)면 추출 재실행 없이 전이 write 만 짧게 재시도한다(TransitionRetry). - runCatchingException { transitionRetry.execute { itemParsingService.markReady(snapshotId, snapshot, attempt) } } - .onSuccess { applied -> - // 좀비 폐기(소유권 상실)면 이 워커의 결과는 반영되지 않았다 — 결과 원장(로그·메트릭)에 성공으로 세지 않는다. + runCatchingException { transitionRetry.execute { itemParsingService.markExtracted(snapshotId, snapshot, attempt) } } + .onSuccess { status -> + // 좀비 폐기(소유권 상실)면 이 워커의 결과는 반영되지 않았다 — 결과 원장(로그·메트릭)에 세지 않는다. // 폐기 사유 자체는 서비스가 남긴다. - if (!applied) return@onSuccess - log.info( - "item.parse.result item={} result={} reason={} latency={}ms url={}", - itemId, - ItemParsingMetrics.RESULT_READY, - ItemParsingMetrics.REASON_NONE, - elapsedMs, - link.safeLogString(), - ) - ItemParsingMetrics.record(meterRegistry, ItemParsingMetrics.RESULT_READY, ItemParsingMetrics.REASON_NONE) - // 정체성 기록(#825 관측 단계) — READY 전이가 커밋된 뒤 별도 트랜잭션으로 canonical·별칭을 남긴다. + val settled = status ?: return@onSuccess + recordOutcome(itemId, link, snapshot, settled, elapsedMs) + // 정체성 기록(#825 관측 단계) — 전이가 커밋된 뒤 별도 트랜잭션으로 canonical·별칭을 남긴다. + // 값을 다 못 채운 INCOMPLETE 에서도 기록한다: 정체성은 "어느 상품인가"라 값 완성도와 무관하고, + // 사용자가 나머지를 채워 완성할 버전도 같은 상품을 가리키기 때문이다. // 전이와 분리하는 이유·병합 시 원자화 계획은 recorder 주석 참고. 기록 실패가 파싱 결과를 해치면 // 안 되므로 예외를 흡수한다(관측 부가 기능). - runCatchingException { itemIdentityRecorder.recordParsingIdentity(itemId, snapshot.finalUrl) } - .onFailure { e -> log.warn("item.identity.error item={} 정체성 기록 실패", itemId, e) } + if (settled != ItemStatus.FAILED) { + runCatchingException { itemIdentityRecorder.recordParsingIdentity(itemId, snapshot.finalUrl) } + .onFailure { e -> log.warn("item.identity.error item={} 정체성 기록 실패", itemId, e) } + } } .onFailure { e -> // 추출은 됐으나 값을 신뢰할 수 없어 READY 로 채울 수 없는 경우 → PROCESSING 방치 대신 FAILED 로. @@ -119,6 +116,53 @@ class AsyncItemParsingWorker( } } + // 종결 결과를 원장(로그·메트릭)에 남긴다. 셋 다 같은 logfmt 계약(item.parse.result)을 쓰고 result 로만 갈린다 — + // 알림·대시보드가 이 한 줄 == 종결 1건으로 세기 때문이다(#902). + // INCOMPLETE 만 missing 을 덧붙인다: "무엇을 사용자에게 물어야 하는가"가 이 결과의 핵심이라 사후에 그 분포를 + // 로그만으로 볼 수 있어야 한다(#944). 메트릭 라벨로는 올리지 않는다 — 조합이 늘어도 운영 액션이 같다. + // 값을 하나도 못 얻은 FAILED 는 extract_quality 로 센다 — "모델·프롬프트·검증 규칙을 본다"는 액션이 그 바구니와 같다. + private fun recordOutcome( + itemId: Long, + link: ProductLink, + snapshot: ProductSnapshot, + status: ItemStatus, + elapsedMs: Long, + ) { + val result = + when (status) { + ItemStatus.READY -> ItemParsingMetrics.RESULT_READY + ItemStatus.INCOMPLETE -> ItemParsingMetrics.RESULT_INCOMPLETE + ItemStatus.FAILED -> ItemParsingMetrics.RESULT_FAILED + ItemStatus.PENDING, ItemStatus.PROCESSING -> error("추출 전이가 만들 수 없는 상태 $status") + } + val reason = + when (status) { + ItemStatus.FAILED -> ItemParsingMetrics.REASON_EXTRACT_QUALITY + else -> ItemParsingMetrics.REASON_NONE + } + if (status == ItemStatus.INCOMPLETE) { + log.info( + "item.parse.result item={} result={} reason={} latency={}ms url={} missing={}", + itemId, + result, + reason, + elapsedMs, + link.safeLogString(), + ItemParsingMetrics.missingFieldsOf(snapshot), + ) + } else { + log.info( + "item.parse.result item={} result={} reason={} latency={}ms url={}", + itemId, + result, + reason, + elapsedMs, + link.safeLogString(), + ) + } + ItemParsingMetrics.record(meterRegistry, result, reason) + } + // 파싱 실패는 두 갈래다 — 재시도해도 결정론적으로 재실패하는 영구 오류는 즉시 종결, 일시 오류는 recover 에 맡긴다. // 판정은 ErrorCategory 가 쥔다: RETRYABLE(일시)만 PROCESSING 으로 두고, 그 외(INVALID_INPUT·SERVER_ERROR 등 // 재시도 무의미)는 즉시 FAILED. HttpMappable 이 아닌 예상 못한 예외는 일시·영구를 단정할 수 없어 보수적으로 일시로 둔다. 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 5b2b19df..55465526 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt @@ -3,6 +3,7 @@ package com.depromeet.piki.item.service import com.depromeet.piki.common.exception.HttpMappable import com.depromeet.piki.product.service.ExtractionFailureBucket import com.depromeet.piki.product.service.ExtractionFailureCode +import com.depromeet.piki.product.service.ProductSnapshot import io.micrometer.core.instrument.MeterRegistry // 파싱 단건의 종결 결과(READY/FAILED)를 result·reason 라벨로 센다 — 추출 실패가 트래픽에서 얼마나·왜 나는지 관측한다(#506). @@ -15,6 +16,11 @@ object ItemParsingMetrics { const val TAG_REASON = "reason" const val RESULT_READY = "ready" + + // 파싱은 끝났으나 일부 필드만 채워 사용자 입력을 기다리는 종결(#944). 실패가 아니므로 failed 에 섞지 않는다 — + // 섞으면 "우리가 못 끝낸 것"과 "사용자가 마저 채울 것"이 한 숫자가 되어 실패율이 실제보다 나쁘게 보인다. + const val RESULT_INCOMPLETE = "incomplete" + const val RESULT_FAILED = "failed" // 성공. @@ -61,6 +67,18 @@ object ItemParsingMetrics { registry.counter(METRIC, TAG_RESULT, result, TAG_REASON, reason).increment() } + // INCOMPLETE 로 끝난 건이 **무엇을 못 채웠는지** 를 로그 한 필드("price" · "name+price")로 남긴다. + // 메트릭 라벨로 두지 않는 이유는 조합이 늘어도 운영 액션이 같아서다 — 분포는 로그로 보고, 메트릭은 + // result=incomplete 한 줄로 센다(라벨 키 집합을 경로마다 같게 유지하는 #465 규율과도 맞다). + // currency 는 READY 필수가 아니라 "못 채운 것"에 세지 않는다. + fun missingFieldsOf(snapshot: ProductSnapshot): String { + val missing = mutableListOf() + snapshot.name?.takeIf { it.isNotBlank() } ?: missing.add("name") + snapshot.price ?: missing.add("price") + snapshot.imageUrl ?: missing.add("imageUrl") + return missing.joinToString("+") + } + // 확정 실패 예외 → reason 라벨. 분류의 정본은 예외가 참조하는 ErrorCode 의 bucket 이고(ExtractionFailureCode), // 여기서는 그 bucket 을 라벨 문자열로 옮기기만 한다 — 원격 code 가 늘어도 이 함수는 그대로다. // when 이 exhaustive 라, bucket 이 추가되면 라벨을 정하지 않은 채로는 컴파일되지 않는다. 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 0e44b478..33b2bf3b 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingService.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingService.kt @@ -2,8 +2,10 @@ 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.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.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.product.domain.ProductLink @@ -29,14 +31,16 @@ class ItemParsingService( ) { private val log = LoggerFactory.getLogger(javaClass) - // 반환값은 **이 전이가 실제로 적용됐는지** 다. false(좀비 폐기)면 호출부는 자기 결과를 반영된 것으로 세면 안 된다 — + // 반환값은 **이 전이로 확정된 상태** 다. null(좀비 폐기)이면 호출부는 자기 결과를 반영된 것으로 세면 안 된다 — // 특히 이미지 워커의 raw 원본 회수는 반드시 이 값으로 막아야 한다(소유권을 쥔 새 시도가 그 원본으로 재실행하므로). + // 상태가 셋으로 갈리는(READY/INCOMPLETE/FAILED) 판정은 도메인(ItemSnapshot.markExtracted)이 쥐고, 여기서는 + // 그 결과에 맞는 도메인 사실을 발행하기만 한다(#944). @Transactional - fun markReady( + fun markExtracted( snapshotId: Long, snapshot: ProductSnapshot, expectedAttempt: Int, - ): Boolean { + ): ItemStatus? { // 워커가 claim 한 그 snapshot 을 id 로 직접 전이한다 — findLatestByItemId(최신)가 아니다. // 갱신(5단계)으로 한 item 에 여러 버전이 공존하면 "최신"이 이 워커가 추출한 행과 다를 수 있어(stale/좀비 워커가 // 다른 버전을 오전이), claim 시점에 고정한 snapshotId 로 정확히 짚는다. 없으면 영속화 경로가 깨진 코드 버그다. @@ -44,13 +48,26 @@ class ItemParsingService( val target = itemSnapshotRepository.findByIdForUpdate(snapshotId) ?: error("파싱 대상 snapshot $snapshotId 이 없다") - if (isZombieResult(target, expectedAttempt)) return false - target.markReady(snapshot) + if (isZombieResult(target, expectedAttempt)) return null + val status = target.markExtracted(snapshot) // 트랜잭션 안에서 발행 → AFTER_COMMIT 리스너가 커밋 성공 후에만 알림을 보낸다 (롤백 시 발송 안 됨). itemId 는 snapshot 단일 출처. - eventPublisher.publishEvent(ItemParsingCompleted(target.itemId, target.getId())) - return true + eventPublisher.publishEvent(parsingFact(status, target)) + return status } + // 확정된 상태에 대응하는 도메인 사실. markExtracted 는 셋 중 하나로만 끝나므로 나머지 상태는 도메인 분기가 깨진 + // 코드 버그다(500). 사실을 상태와 1:1 로 두는 이유는 소비자(알림·SSE)가 상태를 다시 해석하지 않게 하려는 것이다. + 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") + } + // markReady 와 같이 적용 여부를 돌려준다 (false = 좀비 폐기). @Transactional fun markFailed( 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 98af6f6a..0dff91c5 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationKind.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationKind.kt @@ -33,6 +33,7 @@ enum class NotificationKind { // 파싱 알림은 라우팅 출처가 곧 도메인이다. 라우팅이 없는 경우(정상 흐름에선 resolveRouting 이 항상 // Wish/Tournament 를 주므로 도달하지 않는다)는 위시 기본값으로 둔다 — 클라 딥링크의 기존 기본 경로와 같다. NotificationType.ITEM_PARSING_COMPLETED, + NotificationType.ITEM_PARSING_INCOMPLETE, NotificationType.ITEM_PARSING_FAILED, -> routingKind ?: WISH 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 4ba6318a..c42f4ef2 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationType.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/domain/NotificationType.kt @@ -13,6 +13,9 @@ enum class NotificationType { // 내가 참여한 토너먼트를 주최자가 완료해 결과가 나온 사실 — 참여자에게 간다(actor=주최자). TOURNAMENT_RESULT_READY, ITEM_PARSING_COMPLETED, + // 파싱이 일부 필드만 채우고 끝난 사실 — 실패가 아니라 "나머지를 입력하면 완성된다"는 안내다(#944). + // 완료와 문구가 갈려야 해 타입을 따로 둔다 (템플릿은 타입당 하나). + ITEM_PARSING_INCOMPLETE, ITEM_PARSING_FAILED, // 전체 공지(#391/#250). 트리거·발행은 후속 — 지금은 분류/필터용 enum 만 선반영한다. ANNOUNCEMENT, diff --git a/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt new file mode 100644 index 00000000..76a1963f --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/handler/ItemParsingIncompleteHandler.kt @@ -0,0 +1,28 @@ +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 의 기본값이 제목을 메운다. +@Component +class ItemParsingIncompleteHandler( + private val recipientResolver: ItemParsingRecipientResolver, + private val itemSnapshotRepository: ItemSnapshotRepository, +) : 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 resolveRouting(event: ItemParsingIncomplete): NotificationRouting = recipientResolver.resolveRouting(event.snapshotId) + + override fun resolveActorContext(event: ItemParsingIncomplete): ActorContext = + ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name))) +} 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 58f5fef2..698edfad 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseBroadcaster.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/sse/TournamentItemParsedSseBroadcaster.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.common.config.AsyncConfig 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.notification.controller.dto.TournamentItemParsed import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository @@ -40,6 +41,15 @@ class TournamentItemParsedSseBroadcaster( broadcast(event.snapshotId, ItemStatus.READY) } + // 일부만 채워 끝난 경우도 카드 갱신 대상이다 — 참여자 화면의 로딩바를 멈추게 해야 하는 건 완료·실패와 같고, + // 카드가 "나머지를 채워 주세요" 상태로 바뀌어야 하기 때문이다(#944). status 를 그대로 실어 보내므로 + // 클라이언트는 이미 쓰던 필드로 새 값 하나를 더 받는다. + @Async(AsyncConfig.NOTIFICATION_EXECUTOR) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun on(event: ItemParsingIncomplete) { + broadcast(event.snapshotId, ItemStatus.INCOMPLETE) + } + @Async(AsyncConfig.NOTIFICATION_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun on(event: ItemParsingFailed) { 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 ccfd1dfb..6443e3e8 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -159,8 +159,9 @@ interface WishlistApi { cursor 페이지네이션: 직전 응답의 pageResponse.nextCursor 를 다음 요청 cursor 로 그대로 전달한다. 마지막 페이지면 nextCursor 는 null, hasNext 는 false. size 는 미지정 시 20, 1~50 범위를 벗어나면 양 끝으로 보정된다. - 각 항목의 item.status 로 파싱 상태(PENDING/PROCESSING/READY/FAILED)를 구분한다 — - 등록 직후 PENDING·PROCESSING 인 항목은 SSE(`/api/v1/notifications/subscribe`)로 READY/FAILED 전이를 통보받고 이 조회로 확인한다. + 각 항목의 item.status 로 파싱 상태(PENDING/PROCESSING/READY/INCOMPLETE/FAILED)를 구분한다 — + 등록 직후 PENDING·PROCESSING 인 항목은 SSE(`/api/v1/notifications/subscribe`)로 READY/INCOMPLETE/FAILED 전이를 통보받고 이 조회로 확인한다. + INCOMPLETE 는 추출이 일부 필드만 채운 상태다 — 빈 필드를 수기 수정(PATCH)으로 채우면 READY 가 된다. """, ) @ApiResponses( 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 ee4c820c..91b66271 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 @@ -78,9 +78,11 @@ data class WishItemResponse( @field:Schema(description = "상품 ID", example = "512") val id: Long, @field:Schema( - description = "파싱 상태 — PENDING(URL 등록 접수, 파싱 대기)/PROCESSING(파싱 중)/READY(완료)/FAILED(파싱 실패). " + - "URL 등록은 PENDING 으로 시작해 디스패처가 집어 PROCESSING→READY/FAILED 로 전이하고, 이미지 등록은 PROCESSING 으로 시작한다. " + - "PENDING·PROCESSING 동안은 name·price·imageUrl 이 비어 있다.", + description = "파싱 상태 — PENDING(URL 등록 접수, 파싱 대기)/PROCESSING(파싱 중)/READY(완료)/" + + "INCOMPLETE(일부만 채움, 사용자 입력 필요)/FAILED(파싱 실패). " + + "URL 등록은 PENDING 으로 시작해 디스패처가 집어 PROCESSING→READY/INCOMPLETE/FAILED 로 전이하고, 이미지 등록은 PROCESSING 으로 시작한다. " + + "PENDING·PROCESSING 동안은 name·price·imageUrl 이 비어 있다. " + + "INCOMPLETE 는 셋 중 채운 것만 있고 나머지가 비어 있다 — 수기 수정으로 빈 필드를 채우면 READY 가 된다.", example = "READY", ) val status: ItemStatus, diff --git a/src/main/kotlin/db/migration/V20260815055331__seed_item_parsing_incomplete_template.kt b/src/main/kotlin/db/migration/V20260815055331__seed_item_parsing_incomplete_template.kt new file mode 100644 index 00000000..ac8efc90 --- /dev/null +++ b/src/main/kotlin/db/migration/V20260815055331__seed_item_parsing_incomplete_template.kt @@ -0,0 +1,30 @@ +package db.migration + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +// ITEM_PARSING_INCOMPLETE 알림 템플릿을 넣는다(#944). +// title "${itemName}" · body "일부 정보만 찾았어요. 나머지는 직접 채워주세요." +// +// 완료(ITEM_PARSING_COMPLETED)와 문구가 갈려야 해서 타입을 따로 둔다 — 템플릿은 타입당 하나라 한 타입 안에서 +// 상태별로 문구를 가를 수 없다. 실패가 아니라 "사용자가 나머지를 채우면 완성된다"는 안내이므로 실패 문구와도 다르다. +// +// title·body 분리는 완료 알림(#913)과 같은 이유다: OS 푸시 제목은 줄바꿈 없이 뒤가 잘려, 이름과 상태를 한 줄에 담으면 +// 이름이 길 때 정작 무슨 일인지가 사라진다. body 는 변수 없는 고정 문구라 백오피스(#252)가 그대로 편집한다. +// +// 리터럴 dollar-brace(${...})를 SQL 마이그레이션에 두면 Flyway 가 placeholder 로 오인해 파싱이 깨지므로, +// seed(V20260615015148)·직전 변경들과 같이 JDBC 로 직접 INSERT 한다. +@Suppress("ClassName") +class V20260815055331__seed_item_parsing_incomplete_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_INCOMPLETE") + statement.setString(2, "\${itemName}") + statement.setString(3, "일부 정보만 찾았어요. 나머지는 직접 채워주세요.") + statement.executeUpdate() + } + } +} 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 3cba72dd..6315c1bd 100644 --- a/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/domain/ItemSnapshotTest.kt @@ -84,11 +84,13 @@ class ItemSnapshotTest { // --- 전이 (2단계: item 평행 추적) --- @Test - fun `PROCESSING 스냅샷을 markReady 하면 추출 결과로 채워지고 READY 와 extractedAt 이 설정된다`() { + fun `PROCESSING 스냅샷을 markExtracted 하면 추출 결과로 채워지고 READY 와 extractedAt 이 설정된다`() { val snapshot = ItemSnapshot(itemId = 1L) - snapshot.markReady( - ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, currency = "KRW"), - ) + 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) @@ -96,48 +98,78 @@ class ItemSnapshotTest { } @Test - fun `markReady 는 추출 경로를 출처로 번역해 기록한다 - 구버전 응답은 미기록`() { + fun `markExtracted 는 추출 경로를 출처로 번역해 기록한다 - 구버전 응답은 미기록`() { val fromParser = ItemSnapshot(itemId = 1L) - fromParser.markReady( + fromParser.markExtracted( ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000, extractionMethod = "STRUCTURED"), ) assertEquals(ItemSnapshotSource.SERVER, fromParser.source) val fromLlm = ItemSnapshot(itemId = 1L) - fromLlm.markReady( + 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(itemId = 1L) - legacy.markReady( + legacy.markExtracted( ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png", price = 99_000), ) assertNull(legacy.source) } @Test - fun `markReady 시 name 이 없으면 READY 불변식 위반으로 실패한다`() { + fun `markExtracted 시 name 이 없으면 INCOMPLETE 로 전이하고 얻은 값은 채워진다`() { val snapshot = ItemSnapshot(itemId = 1L) - assertFailsWith { - snapshot.markReady(ProductSnapshot(price = 1_000, imageUrl = "https://img.example.com/a.png")) - } + 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) } @Test - fun `markReady 시 price 가 없으면 READY 불변식 위반으로 실패한다`() { + fun `markExtracted 시 price 가 없으면 INCOMPLETE 로 전이한다 - 사진에 가격이 없는 정상 입력이다`() { val snapshot = ItemSnapshot(itemId = 1L) - assertFailsWith { - snapshot.markReady(ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png")) - } + val status = snapshot.markExtracted(ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png")) + assertEquals(ItemStatus.INCOMPLETE, status) + assertEquals("나이키", snapshot.name) + assertNull(snapshot.price) } @Test - fun `markReady 시 imageUrl 이 없으면 READY 불변식 위반으로 실패한다`() { + fun `markExtracted 시 imageUrl 이 없으면 INCOMPLETE 로 전이한다`() { val snapshot = ItemSnapshot(itemId = 1L) - assertFailsWith { - snapshot.markReady(ProductSnapshot(name = "나이키", price = 99_000)) - } + val status = snapshot.markExtracted(ProductSnapshot(name = "나이키", price = 99_000)) + assertEquals(ItemStatus.INCOMPLETE, status) + assertNull(snapshot.imageUrl) + } + + @Test + fun `markExtracted 가 값을 하나도 못 얻으면 FAILED 로 전이하고 extractedAt 도 남기지 않는다`() { + val snapshot = ItemSnapshot(itemId = 1L) + val status = snapshot.markExtracted(ProductSnapshot(currency = "KRW")) + assertEquals(ItemStatus.FAILED, status) + assertEquals(ItemStatus.FAILED, snapshot.status) + assertNull(snapshot.extractedAt) + } + + @Test + fun `markExtracted 는 blank name 을 값으로 세지 않아 나머지가 없으면 FAILED 다`() { + val snapshot = ItemSnapshot(itemId = 1L) + assertEquals(ItemStatus.FAILED, snapshot.markExtracted(ProductSnapshot(name = " "))) + } + + @Test + fun `INCOMPLETE 는 READY 취급을 받지 못한다`() { + val snapshot = ItemSnapshot(itemId = 1L) + snapshot.markExtracted(ProductSnapshot(name = "나이키", imageUrl = "https://img.example.com/a.png")) + assertFalse(snapshot.isReady()) + assertTrue(snapshot.isIncomplete()) + assertFalse(snapshot.isFailed()) + assertFalse(snapshot.isInProgress()) } @Test @@ -149,11 +181,11 @@ class ItemSnapshotTest { @Test - fun `PROCESSING 이 아닌 스냅샷을 markReady 하면 IllegalStateException`() { + fun `PROCESSING 이 아닌 스냅샷을 markExtracted 하면 IllegalStateException`() { val snapshot = ItemSnapshot(itemId = 1L) snapshot.markFailed() assertFailsWith { - snapshot.markReady(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) + snapshot.markExtracted(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } } @@ -162,7 +194,7 @@ class ItemSnapshotTest { @Test fun `manual 은 base 값 위에 입력을 병합한 READY 새 버전을 만들고 base 는 그대로다`() { val base = ItemSnapshot(itemId = 1L) - base.markReady(ProductSnapshot(name = "나이키", price = 99_000, imageUrl = "https://img.example.com/a.png", currency = "KRW")) + base.markExtracted(ProductSnapshot(name = "나이키", price = 99_000, imageUrl = "https://img.example.com/a.png", currency = "KRW")) val editor = java.util.UUID.randomUUID() val manual = ItemSnapshot.manual(base = base, name = null, price = 79_000, imageUrl = null, currency = null, editedBy = editor) @@ -179,6 +211,40 @@ class ItemSnapshotTest { assertEquals(ItemStatus.READY, base.status) } + // INCOMPLETE 의 완성 경로 — 추출이 못 채운 필드를 사용자가 채우면 READY 가 된다(#944). 이 시나리오가 곧 + // "채울 수 있는 만큼 채워 내려주고 나머지는 사용자가" 의 계약이라, 전이와 수기 수정이 맞물리는 지점을 고정한다. + @Test + fun `INCOMPLETE 를 base 로 빈 필드를 채우면 READY 새 버전이 되고 base 는 그대로다`() { + val base = ItemSnapshot(itemId = 1L) + base.markExtracted(ProductSnapshot(name = "몬치치 인형", imageUrl = "https://img.example.com/a.png")) + assertEquals(ItemStatus.INCOMPLETE, base.status) + + val manual = + ItemSnapshot.manual( + base = base, + name = null, + price = 25_000, + imageUrl = null, + currency = "KRW", + editedBy = 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, "기계 버전은 불변 — 이력으로 남는다") + } + + @Test + fun `INCOMPLETE base 에 채워도 여전히 빈 필드가 남으면 ItemException(400)`() { + val base = ItemSnapshot(itemId = 1L) + base.markExtracted(ProductSnapshot(imageUrl = "https://img.example.com/a.png")) + assertFailsWith { + ItemSnapshot.manual(base = base, name = "몬치치", price = null, imageUrl = null, currency = null, editedBy = java.util.UUID.randomUUID()) + } + } + @Test fun `manual 은 상태 제한이 없다 - PENDING·PROCESSING·FAILED base 로도 새 버전을 만든다`() { val editor = java.util.UUID.randomUUID() @@ -240,7 +306,7 @@ class ItemSnapshotTest { assertTrue(ItemSnapshot.pending(itemId = 1L).apply { markProcessing() }.isInProgress()) assertFalse( ItemSnapshot(itemId = 1L) - .apply { markReady(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } + .apply { markExtracted(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } .isInProgress(), ) assertFalse(ItemSnapshot(itemId = 1L).apply { markFailed() }.isInProgress()) @@ -259,7 +325,7 @@ class ItemSnapshotTest { assertFailsWith { ItemSnapshot.pending(1L).apply { markProcessing() }.markProcessing() } assertFailsWith { ItemSnapshot(itemId = 1L) - .apply { markReady(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } + .apply { markExtracted(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } .markProcessing() } assertFailsWith { ItemSnapshot(itemId = 1L).apply { markFailed() }.markProcessing() } @@ -296,7 +362,7 @@ class ItemSnapshotTest { // READY(완료)·FAILED(실패)는 마감 대상이 아니다 — recover 가 잘못된 행을 집은 코드 버그 방어. assertFailsWith { ItemSnapshot(itemId = 1L) - .apply { markReady(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } + .apply { markExtracted(ProductSnapshot(name = "x", price = 1_000, imageUrl = "https://img.example.com/a.png")) } .expire() } assertFailsWith { ItemSnapshot(itemId = 1L).apply { markFailed() }.expire() } diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ParsingHeartbeatIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ParsingHeartbeatIntegrationTest.kt index 38c2e575..8126e959 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ParsingHeartbeatIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ParsingHeartbeatIntegrationTest.kt @@ -55,7 +55,7 @@ class ParsingHeartbeatIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var jdbcTemplate: JdbcTemplate @Test - fun `claim attempt 와 어긋난 결과는 markReady 가 전이하지 않고 폐기한다`() { + fun `claim attempt 와 어긋난 결과는 markExtracted 가 전이하지 않고 폐기한다`() { val item = itemRepository.save(Item(ProductLink.parse("https://shop.example.com/products/fence-${UUID.randomUUID()}"))) val snapshot = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId()).apply { markProcessing() }) // attempt 0 (집기는 예산 미소모) val snapshotId = snapshot.getId() @@ -63,16 +63,16 @@ class ParsingHeartbeatIntegrationTest : IntegrationTestSupport() { // 소유권이 다른 시도로 넘어가 attempt 2 가 된 상황을 DB 에 반영. updated_at=now 라 배경 recover 가 안 건드린다. jdbcTemplate.update("UPDATE item_snapshots SET attempt_count = 2, updated_at = ? WHERE id = ?", LocalDateTime.now(), snapshotId) - // 옛 시도(attempt 1)의 결과로 markReady → fencing 으로 전이 없이 폐기(좀비 결과). - val applied = - itemParsingService.markReady( + // 옛 시도(attempt 1)의 결과로 markExtracted → fencing 으로 전이 없이 폐기(좀비 결과). + val settled = + itemParsingService.markExtracted( snapshotId, ProductSnapshot(link = item.link, name = "좀비결과", price = 1_000, currency = "KRW", imageUrl = "https://img.example.com/z.png"), expectedAttempt = 1, ) // 반환값이 계약이다 — 호출부(특히 이미지 워커의 raw 회수)가 이 값으로 갈리므로, DB 상태와 함께 고정한다. - assertFalse(applied, "좀비 결과는 '적용되지 않음'(false)으로 보고돼야 한다") + assertNull(settled, "좀비 결과는 '적용되지 않음'(null)으로 보고돼야 한다") val reloaded = itemSnapshotRepository.findById(snapshotId) ?: error("행 없음") assertEquals(ItemStatus.PROCESSING, reloaded.status, "좀비 결과는 READY 로 전이하면 안 된다") assertNull(reloaded.name, "좀비 결과의 추출값이 반영되면 안 된다") @@ -83,7 +83,7 @@ class ParsingHeartbeatIntegrationTest : IntegrationTestSupport() { } @Test - fun `소유권 attempt 가 일치하면 markReady 가 정상 전이한다`() { + fun `소유권 attempt 가 일치하면 markExtracted 가 정상 전이한다`() { // fencing 대조군 — 어긋날 때만 막고, 일치하면 그대로 전이함을 함께 고정한다. 워커를 태우지 않으므로 stub 세팅은 불필요하다. val item = itemRepository.save(Item(ProductLink.parse("https://shop.example.com/products/match-${UUID.randomUUID()}"))) val snapshot = itemSnapshotRepository.save(ItemSnapshot.pending(item.getId()).apply { markProcessing() }) // attempt 0 (집기는 예산 미소모) @@ -91,14 +91,14 @@ class ParsingHeartbeatIntegrationTest : IntegrationTestSupport() { try { // 실제 흐름대로 워커의 소유권 획득(0 -> 1)을 재현한 뒤 그 토큰으로 전이한다. val attempt = parsingOwnership.acquire(snapshotId, 0) ?: error("소유권 획득 실패") - val applied = - itemParsingService.markReady( + val settled = + itemParsingService.markExtracted( snapshotId, ProductSnapshot(link = item.link, name = "정상결과", price = 2_000, currency = "KRW", imageUrl = "https://img.example.com/ok.png"), expectedAttempt = attempt, ) - assertTrue(applied, "소유권이 일치하면 '적용됨'(true)으로 보고돼야 한다") + assertEquals(ItemStatus.READY, settled, "소유권이 일치하면 확정된 상태(READY)로 보고돼야 한다") val reloaded = itemSnapshotRepository.findById(snapshotId) ?: error("행 없음") assertEquals(ItemStatus.READY, reloaded.status) assertEquals("정상결과", reloaded.name) 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 e75b0643..a7badfdf 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/domain/NotificationKindTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/domain/NotificationKindTest.kt @@ -62,6 +62,9 @@ class NotificationKindTest { // 라우팅 출처가 없으면 위시 기본값. 정상 흐름에선 resolveRouting 이 항상 출처를 주므로 도달하지 않지만, // 기본값을 바꿔도 아무 테스트가 안 깨지던 구멍이라 계약으로 고정한다. Arguments.of(NotificationType.ITEM_PARSING_COMPLETED, null, NotificationKind.WISH), + Arguments.of(NotificationType.ITEM_PARSING_INCOMPLETE, NotificationKind.WISH, NotificationKind.WISH), + Arguments.of(NotificationType.ITEM_PARSING_INCOMPLETE, NotificationKind.TOURNAMENT, NotificationKind.TOURNAMENT), + Arguments.of(NotificationType.ITEM_PARSING_INCOMPLETE, null, NotificationKind.WISH), Arguments.of(NotificationType.ITEM_PARSING_FAILED, NotificationKind.WISH, NotificationKind.WISH), Arguments.of(NotificationType.ITEM_PARSING_FAILED, NotificationKind.TOURNAMENT, NotificationKind.TOURNAMENT), Arguments.of(NotificationType.ITEM_PARSING_FAILED, null, NotificationKind.WISH), 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 7f3c13c6..4b69bbc1 100644 --- a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationPushPolicySeedIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationPushPolicySeedIntegrationTest.kt @@ -31,6 +31,8 @@ class NotificationPushPolicySeedIntegrationTest : IntegrationTestSupport() { NotificationType.TOURNAMENT_COMPLETED to true, NotificationType.TOURNAMENT_RESULT_READY to true, NotificationType.ITEM_PARSING_COMPLETED to true, + // 사용자가 나머지를 채워야 등록이 끝나므로, 앱이 닫혀 있어도 알린다(완료·실패와 같은 결). + NotificationType.ITEM_PARSING_INCOMPLETE to true, NotificationType.ITEM_PARSING_FAILED to true, NotificationType.ANNOUNCEMENT to true, ) 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 f31d26f6..014ab4ca 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -2698,7 +2698,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { itemSnapshotJpaRepository.findById(result.snapshot.getId()).get().markProcessing() // 이 시딩은 워커를 태우지 않고 전이만 재현한다 — 실행이 없었으므로 attempt 는 집기 직후 값(0) 그대로이고, // 전이의 fencing 토큰도 그 값이다. (실행까지 재현하는 흐름은 WishlistRegisterAsyncIntegrationTest 가 덮는다.) - itemParsingService.markReady( + itemParsingService.markExtracted( result.snapshot.getId(), ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png"), expectedAttempt = 0, 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 d2ed67f3..1e6981ce 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt @@ -343,7 +343,7 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { ): Long { val result = wishPersistenceService.persistPendingImages(userId, listOf("items/raw/${UUID.randomUUID()}.png")).first() itemSnapshotJpaRepository.findById(result.snapshot.getId()).get().markProcessing() - itemParsingService.markReady( + itemParsingService.markExtracted( result.snapshot.getId(), ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png"), expectedAttempt = 0, 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 fca9225b..7578c15c 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt @@ -108,7 +108,7 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { itemParsingService.claimDuePending(100) // 이 시딩은 워커를 태우지 않고 전이만 재현한다 — 실행이 없었으므로 attempt 는 집기 직후 값(0) 그대로이고, // 전이의 fencing 토큰도 그 값이다. (실행까지 재현하는 흐름은 WishlistRegisterAsyncIntegrationTest 가 덮는다.) - itemParsingService.markReady( + itemParsingService.markExtracted( result.snapshot.getId(), ProductSnapshot( link = ProductLink.parse(url), 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 35efdde6..e952dcee 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRefreshIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRefreshIntegrationTest.kt @@ -329,7 +329,7 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { } @Test - fun `markReady 는 claim 한 snapshot 만 전이하고 같은 item 의 다른 진행 중 버전은 건드리지 않는다`() { + fun `markExtracted 는 claim 한 snapshot 만 전이하고 같은 item 의 다른 진행 중 버전은 건드리지 않는다`() { // F2 회귀 — 갱신은 한 item 에 여러 진행 중 snapshot 을 만든다. 전이가 findLatestByItemId(최신)가 아니라 // claim 한 snapshotId 를 짚어야, stale·좀비 워커가 다른(새) 버전을 오전이하지 않는다. val userId = UUID.randomUUID() @@ -342,7 +342,7 @@ class WishlistRefreshIntegrationTest : IntegrationTestSupport() { // v1(더 낮은 id, 최신 아님)을 지정해 전이 — findLatest 였다면 v2 가 전이됐을 것이다. // 집기는 attempt 를 안 올리므로 워커의 소유권 획득(0 -> 1)을 재현한 뒤 그 토큰으로 전이한다. val attempt = parsingOwnership.acquire(v1.getId(), 0) ?: error("소유권 획득 실패") - itemParsingService.markReady( + itemParsingService.markExtracted( v1.getId(), ProductSnapshot(link = null, name = "버전1", price = 100, currency = "KRW", imageUrl = "https://img.example.com/a.png"), expectedAttempt = attempt, 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 a727cb8c..04507417 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -47,6 +47,7 @@ import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.atomic.AtomicInteger import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -184,26 +185,28 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { } @Test - fun `추출은 됐으나 이름이 비어 있으면 READY 부적격으로 item 이 FAILED 로 전이한다`() { + fun `추출이 이름을 못 얻으면 item 이 INCOMPLETE 로 전이하고 얻은 값은 남는다`() { val mockMvc = buildMockMvc() val userId = UUID.randomUUID() insertMember(userId) try { - // isProductPage=true 라도 이름을 못 뽑으면 name 이 비어 온다. READY 불변식(name 필수)에 걸려 - // markReady 가 거부하고, 워커가 이를 받아 PROCESSING 방치 대신 FAILED 로 떨어뜨린다. + // isProductPage=true 라도 이름을 못 뽑으면 name 이 비어 온다. 예전에는 READY 불변식(name 필수)에 걸려 + // FAILED 로 떨어졌지만, 이제는 채운 만큼을 남기고 INCOMPLETE 로 안착해 사용자가 나머지를 채운다(#944). stubProductLinkExtractor.build = { ProductSnapshot(link = it, price = 99_000) } - val rejectedBefore = parseCount("failed", "ready_rejected") + val incompleteBefore = parseCount("incomplete", "none") val itemId = registerAndGetItemId(mockMvc, userId, "https://shop.example.com/products/no-name") await().atMost(Duration.ofSeconds(5)).until { - latestSnapshot(itemId)?.status == ItemStatus.FAILED + latestSnapshot(itemId)?.status == ItemStatus.INCOMPLETE } - // 결과 메트릭(#506): 추출됐으나 READY 부적격(이름 없음)은 result=failed,reason=ready_rejected 로 +1. - await().atMost(Duration.ofSeconds(2)).until { parseCount("failed", "ready_rejected") - rejectedBefore >= 1.0 } + // 결과 메트릭(#506): 부분 성공은 실패에 섞지 않고 result=incomplete,reason=none 으로 +1 한다. + await().atMost(Duration.ofSeconds(2)).until { parseCount("incomplete", "none") - incompleteBefore >= 1.0 } val snapshot = latestSnapshot(itemId) ?: error("item $itemId 의 snapshot 이 없다") - assertEquals(ItemStatus.FAILED, snapshot.status) - assertNull(snapshot.name) + assertEquals(ItemStatus.INCOMPLETE, snapshot.status) + assertNull(snapshot.name, "못 얻은 필드는 비어 있어 사용자가 채운다") + assertEquals(99_000, snapshot.price, "얻은 값은 버려지지 않는다") + assertNotNull(snapshot.extractedAt) } finally { cleanup(userId) }