Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
f0e284a
refactor: 위시 id 목록 소유 건수 조회 제거
m-a-king Oct 3, 2026
f43a8d3
refactor: 토너먼트의 호출처 없는 코드 제거
m-a-king Oct 4, 2026
4cc4d03
refactor: 아이템 확정 시점 변경에 맞춘 스키마 전환
m-a-king Oct 4, 2026
c8dc64f
refactor: 정체 URL 정규화를 추적 파라미터 목록 방식으로 전환
m-a-king Oct 5, 2026
d1f49eb
fix: host 없는 상품 링크 거부
m-a-king Oct 5, 2026
d6ac15f
refactor: 파싱 아웃박스를 요청자와 입력 기준으로 전환
m-a-king Oct 5, 2026
e850a77
refactor: 아웃박스 입력을 타입과 값 한 쌍으로 통합
m-a-king Oct 5, 2026
262f2da
refactor: 아웃박스에 요청 목적 기록
m-a-king Oct 5, 2026
8daa474
refactor: 아이템과 버전을 파싱 성공 시점에 확정하는 모델로 전환
m-a-king Oct 5, 2026
1f7016a
refactor: 아이템 종류 칸 추가
m-a-king Oct 5, 2026
1a8333c
refactor: 원본 이미지 보존
m-a-king Oct 6, 2026
fb95cc7
Merge remote-tracking branch 'origin/dev' into refactor/1180-parse-su…
m-a-king Oct 7, 2026
1ed1963
refactor: 위시·토너먼트 아이템이 파싱 성공 때 아이템을 확정
m-a-king Oct 7, 2026
14f2b8f
refactor: 토너먼트 엔티티의 backing field 를 protected set 으로 교체
m-a-king Oct 8, 2026
f17c171
refactor: 레포지토리를 정체 값 조회와 요청자 기준으로 전환
m-a-king Oct 8, 2026
59e5e81
refactor: URL 길이 상한을 768 로 축소
m-a-king Oct 8, 2026
4daf392
refactor: 아웃박스의 이미지 키 유니크 제거
m-a-king Oct 8, 2026
2ca03c5
refactor: 정규화 값 타입을 NormalizedInput 으로 통합
m-a-king Oct 8, 2026
8a3f342
refactor: 정체 값과 정규화 값을 value class 로 타입화
m-a-king Oct 8, 2026
4e3e549
refactor: 파싱 입력을 ProductLink·ImageKey 로 타입화
m-a-king Oct 8, 2026
0d3893c
refactor: 아이템 서비스를 아웃박스 요청자 기준으로 전환
m-a-king Oct 8, 2026
d714328
refactor: 파싱 요청자를 아이템 슬롯으로 개명
m-a-king Oct 8, 2026
3424104
refactor: 시작한 토너먼트의 버전 선별을 조회로 이동
m-a-king Oct 8, 2026
3514db8
refactor: 토너먼트 카드 조회의 기준 시각을 토너먼트에서만 받도록 강제
m-a-king Oct 9, 2026
3f4d91d
refactor: ItemSnapshot 정리
m-a-king Oct 9, 2026
38c5303
refactor: 추출 경로를 계약 enum 으로 받도록 교체
m-a-king Oct 9, 2026
f510116
refactor: 버전 출처를 추출 경로와 수기 여부로 분리
m-a-king Oct 9, 2026
41c1226
refactor: item_snapshots.created_by 를 NOT NULL 로 전환
m-a-king Oct 9, 2026
cf21b6e
refactor: 버전 표시 규칙의 공유 값 이름 정리
m-a-king Oct 9, 2026
19084d4
refactor: 서비스의 nullable 반환 제거
m-a-king Oct 9, 2026
ac3dea0
style: import 순서와 빈 줄 정리
m-a-king Oct 10, 2026
52c5e20
refactor: 추출 경로 enum 을 ParseInputType 하나로 통합
m-a-king Oct 10, 2026
7251161
refactor: items.canonical_url 을 NOT NULL 로 전환
m-a-king Oct 10, 2026
8b0fdd3
refactor: 업로드 키를 상품과 프로필로 분리
m-a-king Oct 10, 2026
37f3b6c
refactor: 프로필 이미지를 처음부터 사용자 접두로 올려 복사 제거
m-a-king Oct 10, 2026
fbfedd3
refactor: 수기 값 병합을 UserInputValues 로 개명
m-a-king Oct 11, 2026
116c437
refactor: 파싱 진행 중 사용자 입력 거부 추가
m-a-king Oct 11, 2026
3f568be
refactor: 위시 서비스를 아이템 슬롯 기준으로 전환
m-a-king Oct 11, 2026
d0fcb78
refactor: 위시 서비스 입출력을 Command·Query·Result 로 정리
m-a-king Oct 12, 2026
7c3d391
refactor: 위시 상세 결과를 WishResult 기반으로 전환
m-a-king Oct 12, 2026
ce01bad
refactor: 사용자 입력 네 값을 UserInput 값 객체로 통합
m-a-king Oct 12, 2026
57db04f
refactor: 위시 사용자 입력 Command 를 UserInput 기반으로 전환
m-a-king Oct 12, 2026
fb771bb
refactor: 사용자 입력 병합 규칙을 카드 종류별로 분리
m-a-king Oct 12, 2026
2309c89
refactor: 토너먼트 서비스를 아이템 슬롯 기준으로 전환
m-a-king Oct 12, 2026
50bb322
refactor: 위시·토너먼트 API 를 카드 기준 응답으로 전환
m-a-king Oct 12, 2026
e2b26a3
refactor: 파싱 알림을 요청한 카드 기준으로 전환
m-a-king Oct 12, 2026
a8df84f
refactor: 주석의 수기 어휘 정리
m-a-king Oct 12, 2026
d6ba326
test: 파싱 성공 시점 아이템 확정 모델에 맞춘 테스트 전환
m-a-king Oct 12, 2026
488f15d
fix: 위시에서 담기 정원 기준을 출전 행 수로 전환
m-a-king Oct 13, 2026
6952499
fix: 위시에서 담기 소유 판정을 itemId 비교로 전환
m-a-king Oct 14, 2026
73f9a4c
refactor: 위시 메모를 쌓이는 이력 테이블로 이관
m-a-king Oct 14, 2026
3a8bd9b
refactor: 같은 아이템을 가리키는 살아 있는 슬롯 합치기 추가
m-a-king Oct 18, 2026
e3546d9
refactor: 합쳐진 슬롯의 파싱 성공에 합침 사실 발행
m-a-king Oct 19, 2026
4318fe2
refactor: 파싱 알림 핸들러와 토너먼트 동기화 전송 정리
m-a-king Oct 19, 2026
061caaf
refactor: 토너먼트 출전 판정과 응답 조립 중복 정리
m-a-king Oct 19, 2026
5dde847
docs: 파싱 성공 모델에 맞춘 API 문서 정정
m-a-king Oct 19, 2026
9634c44
test: 사용자 입력 어휘와 테스트 단언 정리
m-a-king Oct 19, 2026
3e27c9b
fix: 알림 문구의 토너먼트 아이템 표기를 토너먼트 후보로 전환
m-a-king Oct 19, 2026
8c0f44b
fix: 사용자에게 나가는 에러 문구에서 아이템 표기 제거
m-a-king Oct 19, 2026
144040b
refactor: 사용자 입력으로 살린 슬롯의 같은 상품 슬롯 합치기 추가
m-a-king Oct 19, 2026
c62c7b4
feat: 같은 상품 슬롯에 합쳐진 슬롯의 합침 알림 추가
m-a-king Oct 19, 2026
1bcf56f
fix: 위시에서 담기와 후보 확정이 겹칠 때의 500 을 409 로 전환
m-a-king Oct 19, 2026
9239b0e
docs: 중복 409 와 같은 상품 슬롯 합치기의 경계 주석 추가
m-a-king Oct 19, 2026
9585cac
fix: 토너먼트 시작 판정의 버전 조회를 잠금 읽기로 전환
m-a-king Oct 19, 2026
67b86a8
fix: 토너먼트 후보 행이 없을 때의 위시 좌표 기본값 제거
m-a-king Oct 19, 2026
f416a10
docs: 실패 알림의 refId 를 요청한 카드 id 로 정정
m-a-king Oct 20, 2026
a94a39d
docs: 알림 이력 API 의 알림 타입 카탈로그 갱신
m-a-king Oct 20, 2026
b398fc4
docs: 토너먼트 시작 409 문서의 옛 클론 사유 제거
m-a-king Oct 20, 2026
dcaa7be
refactor: 업로드 전 검증용 사용자 입력을 UserInput 팩토리로 통합
m-a-king Oct 20, 2026
b25cdcf
docs: 파싱 성공 단계 모델에 맞춰 위시·토너먼트 후보 API 문서 갱신
m-a-king Oct 20, 2026
a3f3e3d
docs: 파싱 성공 단계 모델에 맞춰 명세·용어 문서 갱신
m-a-king Oct 20, 2026
92e83ad
chore: 통합 브랜치(dev 반영)를 파싱 성공 단계 브랜치에 머지
m-a-king Oct 20, 2026
16520db
fix: 바꿀 값 없는 사용자 입력의 버전 복제를 400 으로 거부
m-a-king Oct 20, 2026
dc26594
refactor: 출처 링크 계산을 카드로 통합
m-a-king Oct 20, 2026
8ca7e4a
refactor: 옛 시작 토너먼트의 시작 시각 백필
m-a-king Oct 20, 2026
e1311bd
fix: 정체 URL 채우기 마이그레이션의 유니크·길이 충돌 제거
m-a-king Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/testing-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 를 테스트 본문에서 명시 세팅해야 한다.")
Expand All @@ -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 을 쓴다)
Expand Down
21 changes: 11 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,18 @@ FK 제약과 JPA 연관관계 어노테이션(`@ManyToOne` 등)을 쓰지 않는
외부 호출 (LLM · HTTP fetch · 결제 등 우리 바깥 의존성) 을 트랜잭션 안에 넣지 않는다. read-timeout 이 길어 (예: extractor 원격 추출 호출) 그 동안 DB 커넥션을 잡으면 커넥션 풀이 고갈되어 다른 API 까지 latency 가 번진다.

- 외부 호출은 트랜잭션 바깥에서 끝내고, **영속화만 별도 빈에 위임**해 짧은 트랜잭션으로 묶는다.
- 예: `AsyncItemParsingWorker` 는 트랜잭션 없이 추출을 끝낸 뒤 상태 전이 영속화만 `ItemParsingService`(`@Transactional`)에 위임한다. 같은 빈 안 호출은 proxy 를 거치지 않아 `@Transactional` 이 걸리지 않으므로 별도 빈이어야 한다.
- 예: `ItemParser` 는 트랜잭션 없이 추출을 끝낸 뒤 결과 영속화만 `ItemParsingService`(`@Transactional`)에 위임한다. 같은 빈 안 호출은 proxy 를 거치지 않아 `@Transactional` 이 걸리지 않으므로 별도 빈이어야 한다.

## 로깅

### Logger 선언
`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.
Expand All @@ -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` 를 쓰지 않는다.

Expand All @@ -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) 매퍼 함수 분기).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 20 additions & 9 deletions notification-sse-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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` 별 분기표

Expand All @@ -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. 연결 수명
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});
```
Expand Down Expand Up @@ -234,7 +245,7 @@ fun openSse() {
## 9. 요약

- `type` 으로 분기한다. 문구로 분기하지 않는다
- `refId` 의 의미는 `type` 마다 다르다 (tournamentId / itemId)
- `refId` 의 의미는 `type` 마다 다르다 (tournamentId / itemId / 실패 알림은 요청한 카드 id)
- 좌표 유무는 `type` 이 가른다. `kind` 만 보고 좌표를 읽지 않는다
- 파싱 알림 안에서만 `kind` 로 출처를 가른다
- `silent-sync` 는 토스트·알림센터 없이 화면만 갱신한다
Expand Down
1 change: 1 addition & 0 deletions parsing-claim-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 현 구조 (실측 기준)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,7 +35,7 @@ class AdminExtractionModelController(

@PostMapping("/{target}")
fun save(
@PathVariable target: ExtractionTarget,
@PathVariable target: ParseInputType,
@RequestParam("model") rawModel: String,
request: HttpServletRequest,
view: Model,
Expand All @@ -58,7 +58,7 @@ class AdminExtractionModelController(

@PostMapping("/{target}/check")
fun check(
@PathVariable target: ExtractionTarget,
@PathVariable target: ParseInputType,
view: Model,
): String =
try {
Expand All @@ -70,7 +70,7 @@ class AdminExtractionModelController(

@PostMapping("/{target}/clear")
fun clear(
@PathVariable target: ExtractionTarget,
@PathVariable target: ParseInputType,
request: HttpServletRequest,
view: Model,
): String =
Expand Down
Loading
Loading