diff --git a/.claude/docs/status.md b/.claude/docs/status.md index c3d6ecc2..28073b59 100644 --- a/.claude/docs/status.md +++ b/.claude/docs/status.md @@ -104,7 +104,8 @@ - MSG-438: 행사 도메인 데이터 모델·시드·대표 격자(V39 5테이블 — `entity/{EventSeries,EventOccurrence,EventStatus,EventLocation,EventLocationType,EventLocationGrid(+Id),EventVideo}`. EventVideo는 videos 1:1 확장(`@MapsId` 공유 PK, 파일 컬럼 없음 — 신고·처리·소프트 삭제·노출 게이트 전부 기존 재사용). 격자 단일 귀속은 `UNIQUE(event_occurrence_id, grid_id)` DEFERRABLE + 복합 FK, 대표 격자∈영역은 지연 FK. 상태·uploadClosesAt는 저장 안 함 — `statusAt(now)` 반개구간 파생(UPCOMING→LIVE→UPLOAD_GRACE(+30일)→ARCHIVED), `visible_from = starts_at - 14일` DDL CHECK 등식. `repository/` 5종(native는 `acquireSeedLock` advisory lock 하나, 잠금 계약 `findWithLockByLocationKey` — Hibernate 실측 `FOR NO KEY UPDATE`, 440 업로드가 같은 파생 쿼리 필수). `seed/{AreaCell,RepresentativeGridResolver(3단 결정: 홀수 직사각형 정중앙→지정→중심 최근접, 정수 산술+남서 동률),EventSeed,EventSeeder}` + `seed/events.json` 첫 시드 3행사 7위치(BIFF·부산불꽃·서울불꽃, BTS급 등재 기준), 플래그 `fillmap.event.seed.enabled` dev·prod on·`@Order(30)`. 테스트 45건 — 멱등은 `pg_stat_xact_user_tables` 행 변경 델타 0, 동시성은 `pg_blocking_pids` 결속, 지연 제약은 `pg_constraint` 직접 단언, 기동 진입점은 프록시 `run()` 전량 롤백. Codex 4라운드 수렴. 440 배포 전 미션 안티조인 6쿼리 필수(MSG-450)) - MSG-439: 행사 조회 API 4종 — `GET /api/event-occurrences`(뷰포트 칩 — bbox 4파라미터 `toBounds` 직접 검증(mission 패턴·`ViewportBounds` 재사용), `viewportRange` 4꼭짓점+사방 1칸 보정, 후보 전량 조회 후 자바 필터, 정렬 cityName→startsAt→id) · `/{id}`(상세 — `statusAt(now)` 파생·`uploadClosesAt` 파생·`notificationOn` false 고정(442 전)·`previousOccurrences` 같은 시리즈 `startsAt <` 최신순) · `/{id}/locations`(위치 목록 — `display_order`→id 정렬, `videoCount`는 `JOIN videos` ACTIVE+PUBLIC+READY 단일 GROUP BY·미집계 0) · `GET /api/grids/{gridId}/event-locations`(격자 역조회 — 일치 조회 우선이라 불량 문자열은 표시명 판정 전 빈 배열, 정렬 LIVE>UPCOMING>UPLOAD_GRACE>ARCHIVED·UPCOMING만 startsAt ASC·id DESC·locationId ASC). **존재 은닉**: `UPCOMING && visibleFrom > now` 회차는 4 API 공통으로 목록 제외·id 대입도 13404(`isVisible` 판정 하나 공유 — 칩 경로만 `findByVisibleFromLessThanEqual` SQL 별도 표현, DDL CHECK 등식으로 동치). **비로그인 GET 공개**(2026-08-20 사용자 확정): `SecurityConfig` `requestMatchers(HttpMethod.GET, 4패턴 정확 나열)` — 쓰기(440·441·442)는 로그인 유지. 표시명 재료 위치 단위 동봉(`zoneName`·`zoneCell`·`regionName` — `ZoneNameQueryService.resolver()` + `GridQueryService.resolveRegionNames` 각 요청당 1회). `exception/EventErrorCode`(13400·13401·13404) · `service/EventQueryService`(+impl, Clock 주입) · `dto/*ResponseDto` 4종 · `controller/{EventOccurrenceController,EventLocationController}`. 테스트 50건(서비스 36 — 뷰포트 11·역조회 10·위치 9·상세 6, 컨트롤러 10, 공개 정책은 필터체인 실검증 4). Codex 교차 리뷰 1R 수렴·실서버 스모크 통과 - MSG-443: 행사방 열람 인원 집계 — `POST /api/event-occurrences/{id}/heartbeat`·`GET .../viewer-count`(`EventViewerController`, **둘 다 permitAll — JwtAuthenticationFilter.PUBLIC_AUTH_PATHS엔 미등록**(등록하면 Bearer 파싱이 건너뛰어져 로그인 사용자가 익명 집계됨, 스펙 D4)), `EventViewerService`(+Impl — Redis Sorted Set `eventviewer:{occurrenceId}`, member `u:{userId}`/`s:{세션헤더}`, score epoch초, Lua 원자: 배타 청소→ZADD GT→EXPIRE 120, 조회 ZCOUNT 90초 포함 경계, Clock 주입), `EventViewerCountResponseDto`(viewerCount — 0=빈 방 표시·null=집계 장애 숨김). DB·Flyway·계약 인터페이스 0. occurrenceId 존재 검증 없음(438 테이블과의 연결은 후속 — 없는 id도 키 TTL로 자연 소멸) -- **없는 것**: 행사 업로드·생명주기 API(440·442), 익명 heartbeat rate limit(공개 근사값이라 부풀리기 수용 — 스펙 D4) +- MSG-440: 행사 영상 업로드·위치별 피드·상세 — `POST /api/event-occurrences/{oid}/locations/{lid}/videos`(촬영·갤러리 공통 확정, 좌표·제목·설명·visibility 없음, PUBLIC 고정. 판정 순서: 존재 은닉 13404 → `findWithLockById`(PESSIMISTIC_WRITE)+회차 정합 13405 → `entityManager.refresh(occurrence)`(잠금 대기 중 리시드 스테일 방지) → **멱등 replay 선행**(s3Key가 멱등 키 — `findFirstByOriginalS3KeyStartingWith` 클레임 조회, 같은 사용자·같은 위치면 200 재응답: videoId·gridId 저장 행·processingStatus 실제 값·occupied=false·newBadges=[] 고정. 보장 경계는 교체·삭제 전까지) → 허용 창 `startsAt<=now=endsAt`)). `setVisibility`는 event_videos 행이면 3427 거절. SecurityConfig GET 2패턴+HEAD 1패턴 permitAll(POST는 로그인). Flyway 0·계약 변경 0. 테스트 47건(동시성 2 — 시더 경합 드리프트·refresh 뮤테이션 검증). Codex: 스펙 4R + 구현 1R 수렴(CORS HEAD 미채택 — VideoController 의도된 이중 방어 선례) +- **없는 것**: 댓글·도움돼요(441), 행사 생명주기·알림 자동 해제(442), 익명 heartbeat rate limit(공개 근사값이라 부풀리기 수용 — 스펙 D4) ### `region` (Owner A · 구현 강정민) — 🟡 부분 - MSG-154: `entity/Region`(boundary_geom 미매핑), `repository/RegionRepository`(native UPSERT + ST_Area 기반 total_grid_count), `seed/{RegionGeoJsonReader,RegionFeature,RegionSeeder}`(플래그 게이트 `fillmap.region.seed.enabled` 기본 off, 전국 3,558 행정동) diff --git a/docs/prd/event-room-location-videos.md b/docs/prd/event-room-location-videos.md index f2ec15b3..c94b22b7 100644 --- a/docs/prd/event-room-location-videos.md +++ b/docs/prd/event-room-location-videos.md @@ -67,7 +67,7 @@ EventSeries (행사 시리즈) | 상태 | 기간 | 영상 조회 | 영상 업로드 | 댓글 작성·수정 | 도움돼요 변경 | 알림 | |---|---|---:|---:|---:|---:|---:| -| 예정 | 시작 전 | 가능 | 정책상 허용 시 가능 | 가능 | 가능 | 사용자 선택 | +| 예정 | 시작 전 | 가능 | 불가 (2026-08-21 확정) | 가능 | 가능 | 사용자 선택 | | 진행 중 | 시작~종료 | 가능 | 가능 | 가능 | 가능 | 사용자 선택 | | 업로드 유예 | 종료 후 30일 | 가능 | 가능 | 불가 | 불가 | 자동 OFF | | 아카이브 | 종료 30일 이후 | 가능 | 불가 | 불가 | 불가 | OFF | @@ -357,12 +357,16 @@ EventSeries (행사 시리즈) - 위치별 영상 첫 페이지 응답 목표는 p95 1.5초 이하다. - 영상 목록은 커서 기반 페이지네이션을 사용한다. -- 동일 영상 업로드 요청의 재시도는 중복 레코드를 만들지 않도록 멱등 키를 지원한다. +- 동일 영상 업로드 요청의 재시도는 중복 레코드를 만들지 않도록 멱등 키를 지원한다. 보장 범위는 + 해당 영상이 교체되거나 삭제되기 전까지다(2026-08-21 확정 — 교체와 삭제는 확정 응답을 받은 + 클라이언트만 할 수 있어, 이 보장이 겨누는 응답 유실 재시도와 실행 순서가 겹치지 않는다). - 대표 격자 연결과 영상 생성은 하나의 트랜잭션으로 처리한다. - 댓글·도움돼요 잠금은 UI뿐 아니라 변경 API에서 반드시 검증한다. - 업로드 마감 시각과 행사 상태 전환 작업은 재실행해도 안전해야 한다. - heartbeat 캐시 장애 시 열람 인원만 숨기고 행사 핵심 기능은 정상 제공한다. - 영상 접근 권한, 신고, 삭제 등 기존 공통 영상 정책을 그대로 적용한다. +- 영상 파일의 최대 길이(30초)·용량·지원 코덱도 기존 공통 업로드 정책을 재사용한다(2026-08-21 확정 + — 행사 전용 파일 규격을 두지 않는다). ## 11. 비목표 @@ -419,8 +423,6 @@ EventSeries (행사 시리즈) ## 15. 남은 확인 사항 -- 영상 파일의 최대 길이·용량·지원 코덱은 기존 공통 업로드 정책을 재사용할지 결정해야 한다. - 운영자가 행사 위치 영역과 대표 격자를 등록하는 어드민 화면의 범위를 별도로 정해야 한다. -- 예정 상태에서 영상 업로드를 허용할지, 행사 시작 시점부터만 허용할지 결정해야 한다. - 영상 신고 및 운영자 삭제 시 아카이브 카운트 처리 정책을 기존 영상 정책과 맞춰야 한다. - 비로그인 사용자의 영상 업로드·댓글·도움돼요 허용 여부를 인증 정책과 맞춰야 한다. diff --git a/docs/rtm.md b/docs/rtm.md index 778a05fb..c89669c9 100644 --- a/docs/rtm.md +++ b/docs/rtm.md @@ -5,7 +5,7 @@ **병합 충돌이 나면 손으로 합치지 말고 재생성한다.** 어느 쪽이든 골라 충돌만 없앤 뒤(`git checkout --ours docs/rtm.md` 등) 스크립트를 다시 돌려 그 결과를 커밋한다. 이 표는 두 원천에서 계산되는 값이라 양쪽 diff를 섞으면 어느 쪽과도 다른 상태가 된다. -요약: FR 248건 중 테스트 연결 214건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 27건, 성격상 테스트 비대상 7건 별도) +요약: FR 248건 중 테스트 연결 216건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 25건, 성격상 테스트 비대상 7건 별도) | 요구사항 ID | SRS 상태 | 검증 테스트 | |---|---|---| @@ -181,10 +181,10 @@ | FR-EVENT-04 | 폐기됨 | (없음) | | FR-EVENT-05 | 폐기됨 | (없음) | | FR-EVENT-06 | 계획 | (없음) | -| FR-EVENT-07 | 진행 중 | EventOccurrenceStatusTest, EventQueryServiceTest, EventSeederTest | -| FR-EVENT-08 | 진행 중 | EventQueryServiceTest, EventSeederTest, RepresentativeGridResolverTest | -| FR-EVENT-09 | 계획 | (없음) | -| FR-EVENT-10 | 계획 | (없음) | +| FR-EVENT-07 | 진행 중 | EventOccurrenceStatusTest, EventQueryServiceTest, EventSeederTest, EventVideoQueryServiceTest | +| FR-EVENT-08 | 구현됨 | EventQueryServiceTest, EventSeederTest, EventVideoQueryServiceTest, EventVideoUploadConcurrencyTest, EventVideoUploadServiceTest, RepresentativeGridResolverTest | +| FR-EVENT-09 | 진행 중 | EventVideoPublicAccessHttpTest, EventVideoQueryServiceTest, EventVideoUploadServiceTest, EventVideoVisibilityIntegrationTest | +| FR-EVENT-10 | 진행 중 | EventVideoQueryServiceTest, EventVideoUploadConcurrencyTest, EventVideoUploadServiceTest | | FR-EVENT-11 | 구현됨 | EventViewerCacheFailureControllerTest, EventViewerControllerTest, EventViewerServiceImplTest | | FR-NOTI-01 | 구현됨 | AuthControllerTest, AuthServiceTest, PushTokenControllerTest, PushTokenServiceIntegrationTest | | FR-NOTI-02 | 구현됨 | NotificationCommandServiceIntegrationTest, NotificationConsumerTest, NotificationRelayTest | @@ -284,8 +284,6 @@ - FR-EVENT-04 (폐기됨) - FR-EVENT-05 (폐기됨) - FR-EVENT-06 (계획) -- FR-EVENT-09 (계획) -- FR-EVENT-10 (계획) - FR-NOTI-12 (계획) - FR-NOTI-13 (폐기됨) - FR-FRIEND-13 (계획) diff --git a/docs/spec/MSG-440.md b/docs/spec/MSG-440.md new file mode 100644 index 00000000..80e857ea --- /dev/null +++ b/docs/spec/MSG-440.md @@ -0,0 +1,510 @@ +# MSG-440: 행사 영상 업로드, 위치별 피드, 영상 상세 + +**Owner**: B. `com.msg.fillmap.event.*`(MSG-438이 Owner B 편입 확정)와 +`com.msg.fillmap.video.*`(기존 Owner B)만 만진다. grid 도메인 코드는 불변이고, 이 티켓이 +grid에서 소비하는 것은 `GridEncoder` 산술(대표 격자의 중심점과 인덱스 변환)뿐이다. + +> 요구사항 정본: `docs/prd/event-room-location-videos.md` (확정, 2026-08-20 정민 승인, +> 2026-08-21 개정: 예정 상태 업로드 불가 확정과 멱등 보장 경계 동봉). +> 이 티켓이 이행하는 요구는 업로드 쪽 FR-7, FR-9, FR-10, FR-11, FR-12, FR-15, FR-22와 +> 피드 쪽 FR-20, 그리고 비기능의 멱등 키, 단일 트랜잭션, 커서 페이지네이션이다. +> 스펙은 그 요구에 "어떻게"만 붙인다. + +## 개요 + +행사방의 쓰기 경로 하나와 읽기 경로 둘을 만든다. 촬영과 갤러리 공통의 영상 업로드 확정 API, +위치별 영상 피드(커서 페이지네이션, 최신순), 그리고 영상 상세 조회다. 저장 모델과 대표 +격자[^1] 규칙은 MSG-438이, 행사 조회 4종은 MSG-439(PR #197 머지 완료)가 이미 develop에 +넣었고 이 워크트리(f4b4043)에 실재한다. 댓글과 도움돼요는 MSG-441 몫이라 이 티켓은 그 +자리만 예약한다. + +## 배경, 목표 + +행사 영상은 위치의 대표 격자 하나에만 저장된다(FR-7). 사용자는 현장에 없어도 올릴 수 있어야 +하고(FR-9), 업로드는 GPS 검증 없이 서버가 격자를 지정한다. 한편 2026-08-20 번복 확정으로 +행사 업로드도 일반 업로드와 똑같이 점령(user_grids)을 만든다(MSG-438 스펙 작업 로그, +develop 커밋 729fa24 "행사 업로드도 점령을 만드는 것으로 결정 번복"). 따라서 이 티켓의 +핵심은 새 파이프라인이 아니라, 기존 업로드 확정 파이프라인을 좌표 대신 지정 격자로 태우고 +거기에 `event_videos` 연결 한 줄을 같은 트랜잭션으로 얹는 것이다. + +## 선행 의존 + +- **MSG-438 (머지됨, 스키마 정본)**: 엔티티 5종, V39 DDL, 대표 격자 3단 결정, 위치 행 잠금 + 쿼리(`EventLocationRepository.findWithLockByLocationKey`)가 이 워크트리에 실재한다. + 이 스펙과 어긋나면 438 스펙과 코드를 따른다. +- **MSG-439 (PR #197로 develop 머지 완료, 재확인 끝)**: 워크트리를 develop 헤드(f4b4043)로 + fast-forward한 뒤 코드로 재확인한 결과다. ① `EventErrorCode`(13400, 13401, 13404)가 + 실재하고 13404 메시지("행사를 찾을 수 없습니다")가 이 스펙의 은닉 응답과 글자 단위로 + 일치한다. ② `EventVideoRepository.countVisibleByLocationIds` JPQL이 ACTIVE, PUBLIC, + READY 세 조건으로 실재하며 피드 쿼리의 인접 배치 계획이 그대로 유효하다. ③ + SecurityConfig에 조회 4종의 GET 한정 permitAll이 실재하고, 이 티켓의 패턴 추가와 + 충돌이 없다. `docs/spec/MSG-439.md`도 워크트리에 실재한다(피드 술어 동등 계약의 상대 + 문서). +- **MSG-450 배포 게이트 (438 스펙 명시)**: 미션 쿼리 여섯 곳의 행사 영상 안티조인은 MSG-450 + 구현이며, 440 배포 전 또는 같은 배포 단위여야 한다. 이 티켓 범위가 아니지만 배포 순서 + 제약으로 남는다. + +## 성공 기준 + +- 업로드 1건이 videos 행 하나와 event_videos 행 하나를 대표 격자에만 만들고(PRD 테스트 5), + 점령이 일반 업로드와 동일하게 생긴다(user_grids upsert, 2026-08-20 번복 확정). +- 같은 확정 요청의 재시도는 중복 레코드를 만들지 않고, 첫 시도가 커밋됐다면 저장 행 기준의 + 성공 응답을 되돌려준다(멱등[^2]. 마감 이후 도착한 재시도도 마찬가지다. replay 응답의 + 필드 의미와 보장 경계는 API 1 "멱등 키" 참조). +- 좌표 없이, 사용자의 현재 위치와 무관하게 업로드가 성공한다(PRD 테스트 6, FR-9). +- 업로드 허용 창은 `startsAt`부터 마감 직전까지다: 시작 전 신규 업로드는 + `EVENT_UPLOAD_NOT_STARTED`, 마감(`endsAt + 30일`) 정각부터의 신규 업로드는 + `EVENT_UPLOAD_CLOSED`로 거절된다(PRD §4.2 예정 불가 2026-08-21 확정, 테스트 9, 10, + FR-12, FR-15, 서버 시각 판정 FR-22). +- 피드에 노출되는 영상 집합이 MSG-439 `videoCount` 집계 집합과 정확히 같다(동등 계약). +- 피드는 최신 업로드 순이며 커서로 끊김 없이 이어진다(FR-20, 비기능 커서 페이지네이션). + +아래 "테스트 시나리오"가 이 기준의 검증 수단이다. + +## API 명세 + +**인증 정책**: 업로드(POST)는 로그인 필수, 피드와 상세(GET)는 비로그인 허용이다. MSG-439 +확정 1("상단 칩은 비로그인, 업로드는 로그인", 조회는 비로그인)의 연장이고, PRD §4.4의 익명 +세션 heartbeat도 비로그인 열람을 전제한다. `SecurityConfig`에는 +`requestMatchers(HttpMethod.GET, "/api/event-occurrences/*/locations/*/videos", +"/api/event-videos/*")`와 `requestMatchers(HttpMethod.HEAD, "/api/event-videos/*")` +형태로 **메서드 한정** 패턴만 등록한다(439가 등록한 조회 4종 패턴에 추가. HEAD 패턴은 +상세의 명시 HEAD 매핑이 GET 한정에 안 잡혀 익명 HEAD가 401이 되는 구멍을 막는다, Codex +3R). 업로드 POST가 피드와 같은 경로를 쓰므로 문자열 패턴만 추가하면 업로드까지 익명에 +풀린다(439가 지적한 광역 matcher 함정 그대로). 이 global 설정 수정은 이 티켓 범위다. + +**존재 은닉(공통)**: 미노출 예정 회차(파생 status가 UPCOMING이면서 `visibleFrom > now`)는 +세 API 모두 없는 회차와 동일하게 `13404 EVENT_NOT_FOUND`다(439 공통 규칙과 동일. 다른 +응답을 주면 순차 id 대입으로 노출 전 행사가 드러난다). + +컨트롤러는 `SuccessResponse.of(data)`만 반환하고 실패는 `ApiException` throw 하나로 +처리한다(response-pattern.md). 시각 필드는 전부 `LocalDateTime`(UTC `Z` 코덱, MSG-376), +서버 시각은 주입받은 `Clock`으로 얻는다. + +### 1. 행사 영상 업로드: `POST /api/event-occurrences/{occurrenceId}/locations/{locationId}/videos` + +촬영과 갤러리는 클라이언트 구분일 뿐 서버 계약은 하나다(FR-10). 파일은 기존 presigned[^3] +발급 API(`POST /api/videos/presigned-url`, 불변)로 S3에 먼저 올리고, 이 API가 확정한다. +기존 업로드의 2단 흐름(발급 후 확정)을 그대로 쓰고 행사 전용 발급 API를 만들지 않는다. + +요청 `EventVideoUploadRequestDto`: + +| 필드 | 검증 | 설명 | +|---|---|---| +| `s3Key` | `@NotBlank` | presigned 발급 응답의 pending 키. 멱등 키를 겸한다(아래) | +| `durationSec` | `@NotNull`, 1~30 | 기존 30초 상한 동일(438 확정, 기존 검증 재사용) | +| `recordedAt` | `@NotNull` | 촬영 시각. 미래 5분 허용 오차 검증 동일(3424) | + +좌표가 없고(FR-9, GPS 검증 없음), 제목과 설명이 없고(FR-11), `visibility` 필드도 없다. +공개범위는 서버가 PUBLIC으로 고정한다(438 확정: 행사 피드는 공개 전제). PRD §8의 +`capturedAt`(선택)은 438이 `videos.recorded_at`(NOT NULL)으로 대체 확정했으므로 이 API에서 +`recordedAt`은 필수다. 유예 기간의 "지난 영상"은 과거 `recordedAt`으로 자연 표현된다. + +판정 순서(서버, 단일 트랜잭션): + +1. 회차 조회. 없거나 미노출이면 `13404`. +2. 위치를 `EventLocationRepository.findWithLockById`(신규 잠금 파생 쿼리, + `PESSIMISTIC_WRITE`)로 잠그고 읽는다. 위치가 없거나 위치의 회차가 경로의 + `occurrenceId`와 다르면 `13405 EVENT_LOCATION_NOT_FOUND`. 이 잠금은 438이 정의한 + 동시성 계약의 업로드 측 이행이다: 시더의 대표 격자 재계산과 같은 행을 같은 순서로 + 잠가, 재계산 도중 들어온 업로드가 옛 대표 격자에 붙는 드리프트를 막는다. + **잠금 획득 후 회차를 재독한다**(`EntityManager.refresh` 또는 재조회): 1의 조회는 + 잠금이 없어, 잠금 대기 사이 부팅 리시드(롤링 배포 중 새 인스턴스의 멱등 UPSERT)가 + 회차 일정을 바꾸면 영속성 컨텍스트에 캐시된 `startsAt`과 `endsAt`이 낡은 값으로 + 남기 때문이다. 이후 창 판정(4)은 재독 값으로 한다. +3. 멱등 재시도 판정(아래 "멱등 키" 참조). 재시도로 판명되면 저장된 행 기준의 성공을 + 재응답하고 여기서 끝낸다. **업로드 창 판정보다 먼저다**: 마감 직전 커밋된 업로드의 + 응답이 유실되고 재시도가 마감 후 도착하는 경우, 창 판정이 앞서면 이미 성공한 + 요청이 13409로 거절돼 멱등 성공 기준과 모순되기 때문이다(Codex 교차 리뷰 P2). +4. 업로드 창 판정(**신규 확정에만, 재독한 회차 값으로 적용**): 허용 창은 + `startsAt <= now < uploadClosesAt()`이다. `now < startsAt`이면 + `13410 EVENT_UPLOAD_NOT_STARTED`(**시작 전 업로드 불가, 2026-08-21 사용자 확정**: + 행사 시작 전에는 행사 관련 기록이 남으면 안 된다. PRD §4.2 예정 상태 "불가"). + `now >= uploadClosesAt()`이면 `13409 EVENT_UPLOAD_CLOSED`. 경계는 시작 정각 포함, + 마감 정각 제외의 반개구간이라 정각이 정확히 한 판정에만 속한다(438 `statusAt` 정의와 + 동일 축). 이 금지는 **행사 업로드 API에만 적용된다**: 일반 격자 업로드 + (`POST /api/videos`)는 행사와 무관하게 원래대로 가능하다. +5. 확정 코어 실행. `gridId`는 위치의 `representativeGridId`(서버 결정, FR-7이 요구하는 + 유일 연결), `geom`은 `GridEncoder.center(대표 격자)`의 셀 중심점이다(438 명시). + 코어는 기존 `saveVideo`의 흐름을 추출한 공용 경로 그대로다: `confirmUpload`(pending 키 + 소유 검증, advisory lock[^4], 실측 크기 재검증) 후 grids lazy insert, videos + INSERT(PUBLIC), S3 복사, `upsertUserGrid` 점령, 뱃지 판정(업로드 수, 첫 점령이면 + 수집률 refresh와 수집 뱃지), 스트릭 갱신, 핫스코어와 인코딩의 afterCommit 등록까지 + 전부 동일하다(438 부수효과 계약 표의 "적용" 전 항목). **미션 훅 + (`missionAwardService.awardOnUpload`)만 호출하지 않는다.** 438의 유일한 제외 계약 + (미션 비연계)의 트리거 측 이행이다. MSG-450 안티조인이 있으면 호출해도 무의미한 + 재판정이고, 450이 늦으면 오지급 위험이 생기므로 호출 자체를 하지 않는 쪽이 제외 + 계약에 충실하다. +6. `event_videos` INSERT: `new EventVideo(video, location, occurrenceId)`. 회차와 위치의 + 정합은 438 복합 FK가 커밋 시점에 검증한다. 여기까지가 한 트랜잭션이다(비기능 "대표 + 격자 연결과 영상 생성은 하나의 트랜잭션"). + +**멱등 키 = `s3Key`**. pending 키는 presigned 발급마다 새 UUID라 요청 식별자로 충분하고, +재시도는 자연히 같은 `s3Key`를 다시 보낸다. 별도 키 필드나 저장 컬럼을 만들지 않는다. +동작: `confirmUpload`와 같은 advisory lock을 먼저 잡은 뒤, 이 pending 키에서 이미 확정된 +videos 행(원본 키 클레임 조회, `existsByOriginalS3KeyStartingWith`와 같은 파생 기준의 +find 버전)을 찾는다. 행이 있고 **같은 사용자의 영상이며 event_videos가 경로의 위치를 +가리키면** 재시도다(첫 시도 커밋 후 응답 유실). 행은 있는데 사용자나 위치가 다르면 기존과 +같이 `3401 INVALID_S3_KEY`다(도용 시도). 행이 없으면 신규 확정이다. 중복 레코드는 기존 +클레임 직렬화가 이미 구조적으로 막고 있었고, 이 판정은 거기에 "재시도가 성공을 받는" +성질만 더한다. + +**replay 응답의 의미(계약)**: 재시도 시점에는 첫 응답을 재구성할 수 없다(저장 컬럼 추가 +없음, Flyway 없음 유지). 따라서 replay 응답은 저장 행 기준으로 다음과 같이 정의한다. +`videoId`와 `gridId`는 저장 행 그대로, `processingStatus`는 **재시도 시점의 실제 값** +(인코딩이 진행돼 첫 응답과 다를 수 있다), `occupied`는 **false 고정**, `newBadges`는 +**빈 배열 고정**이다. 두 필드는 첫 응답 전용이며, 유실된 뱃지 연출은 뱃지 목록과 알림 +경로로 이미 노출되므로 유실을 허용한다. + +**replay 보장의 경계(계약)**: replay 보장은 **대상 영상이 교체나 삭제되기 전까지**다. +**PRD §10이 같은 경계를 명시한다(2026-08-21 확정)**. 교체(`Video.replaceFile`)는 +`original_s3_key`를 새 키로 덮어써 pending stem prefix 클레임 +(`existsByOriginalS3KeyStartingWith` 기준)이 사라지므로, 그 뒤의 구 확정 재시도는 +replay 대상을 찾지 못한다(Codex 교차 리뷰 2R). 이 경계가 안전한 근거는 두 가지다. +첫째, 교체와 삭제는 확정 응답의 `videoId`를 받은 클라이언트만 할 수 있으므로, 이 보장이 +겨누는 "응답 유실 재시도"와 겹치는 실행 순서가 정상 클라이언트에 없다. 둘째, 교체 후 +구 pending 키 재확정이 새 영상을 만드는 성질은 일반 업로드 확정에도 동일하게 있는 기존 +파이프라인 속성이라, 이 티켓이 새로 만드는 구멍이 아니다. 구조적 수리(클레임 표기의 +불변화)는 저장 구조 변경이 필요해 이번 범위에서 하지 않는다. + +**후속 정비 후보(별도 티켓, 이 티켓의 미해결 질문 아님)**: 공유 확정 코어의 클레임 +불변화. 교체가 `original_s3_key`를 덮어쓰는 대신 클레임 이력을 보존하는 표기로 바꾸면 +위 경계 자체가 사라진다. 일반 업로드와 행사 업로드가 코어를 공유하므로 정비도 한 곳이다. + +응답 `EventVideoUploadResponseDto`: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `videoId` | Long | | +| `gridId` | String | 서버가 지정한 대표 격자 | +| `processingStatus` | String | 신규 확정은 `UPLOADED`(인코딩은 커밋 후 비동기), replay는 재시도 시점 실제 값 | +| `occupied` | Boolean | 이 업로드로 첫 점령이 생겼는지. **첫 응답 전용**, replay는 false 고정 | +| `newBadges` | 배열 | 이 업로드로 획득한 뱃지 (기존 업로드 응답 선례, 438 "즉시 응답 적용"). **첫 응답 전용**, replay는 빈 배열 고정 | + +`completedMissions`는 없다(미션 비연계). 격자 표시명 재료도 싣지 않는다. 업로드 완료 후 +FE는 위치별 피드로 돌아가고(PRD §4.3), 위치 이름은 이미 화면에 있기 때문이다. +**주의(FE 계약)**: 노출 게이트[^5]가 READY 한정이라 인코딩이 끝나기 전에는 새 영상이 +피드에 잡히지 않는다. US-005의 "피드 최상단에 새 영상 표시"는 READY 이후 성립하며, 직후 +표시가 필요하면 FE가 이 응답으로 낙관적 카드를 그린다(기존 업로드 UX와 동일한 성질). + +### 2. 위치별 피드: `GET /api/event-occurrences/{occurrenceId}/locations/{locationId}/videos?cursor=&size=` + +비로그인 허용. 회차가 없거나 미노출이면 `13404`, 위치가 없거나 회차 불일치면 `13405`. +아카이브를 포함한 모든 노출 상태에서 조회 가능하다(PRD §4.2 "영상 조회 가능" 전 상태). +조건에 맞는 영상이 없으면 빈 페이지다(예외 아님. 빈 상태 문구는 FE 몫). + +- **노출 술어는 전역 게이트와 동일**: `videos` 조인 후 + `status = ACTIVE AND visibility = PUBLIC AND processing_status = READY`. + **이 술어는 MSG-439 `countVisibleByLocationIds`의 집계 조건과 동등 계약이다**(머지된 + 코드 실측 확인: 같은 세 조건). 삭제, 블라인드, 비공개, 처리 미완료가 숫자에는 잡히고 + 목록에는 없는 불일치와 존재 노출을 막는 계약이므로, 피드 쿼리를 + `EventVideoRepository`의 카운트 쿼리 옆에 인접 배치하고 동등성 테스트로 묶는다. +- 정렬: `createdAt` 내림차순, `id` 내림차순 타이브레이커(FR-20 최신순, 결정적 정렬). +- 커서: keyset[^6] opaque 토큰. 성분은 `locationId:createdAtEpochMicros:id`이며 + `VideoCursor`(MSG-237) 선례를 따라 Base64URL로 감싼다. 첫 성분 `locationId`는 **발급 + 위치 바인딩**이다: 다른 위치 요청에 재사용된 커서는 경계값이 그 위치의 keyset으로 + 오적용돼 결과가 조용히 잘리므로, 서비스가 요청 `locationId`와 대조해 불일치면 + `13402`로 거부한다(`VideoCursor`가 정확히 같은 이유로 gridId 바인딩을 도입한 선례, + 2026-07-28 Codex 교차 리뷰 P2). 디코드 실패도 `13402 INVALID_CURSOR`(400)다. + `size`는 1~50 밖이면 클램프, 0 이하나 미지정은 기본 20이다(MSG-237 §D5 선례 그대로). +- 구현은 JPQL 생성자 프로젝션이다(영속 컨벤션 2026-08-19 개정, native 불요). 썸네일 S3 + 키는 `ThumbnailUrlPresigner`로 presigned GET URL 변환한다(READY 게이트라 항상 존재). + +응답 `EventLocationVideoPageResponseDto`(`videos`, `hasNext`, `nextCursor` 구조, +`GridVideoPageResponseDto` 선례. 441 필드 확장이 예정돼 있어 기존 DTO를 재사용하지 않고 +행사 전용으로 둔다). 항목 `EventLocationVideoResponseDto`: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `videoId` | Long | 상세 진입 키 | +| `thumbnailUrl` | String | presigned GET URL (16:9 카드 재료) | +| `durationSec` | Short | 카드의 길이 표시 재료 | +| `createdAt` | LocalDateTime | 업로드 시각. 상대 표기("3시간 전")는 FE 계산 | + +**자리 예약(이번 구현 제외)**: 카드의 하트 수와 댓글 수(US-004)는 **MSG-441이 이 DTO에 +`helpfulCount`, `commentCount` 필드를 추가**한다. 이번 범위에서는 필드 자체를 만들지 +않는다(집계 원천 테이블이 441에서 생긴다). + +### 3. 영상 상세: `GET /api/event-videos/{videoId}` + +비로그인 허용. 로그인돼 있으면 principal의 사용자 id를 서비스에 전달한다(소유자 판정 +재료). 판정: + +- `event_videos` 행이 없거나, 연결된 videos 행이 피드와 같은 노출 술어(ACTIVE, PUBLIC, + READY) 밖이면 `13406 EVENT_VIDEO_NOT_FOUND`(404, 존재 은닉). 술어 하나를 피드, + 카운트(439), 상세가 같이 쓰므로 목록에서 보인 것만 상세가 열리고 그 역도 성립한다. + 소유자 본인도 예외가 아니다(READY 전 상세는 404. 본인 영상 확인은 기존 + `GET /api/videos/{videoId}`가 담당). 소속 회차의 존재 은닉 판정도 적용한다(정상 + 데이터에선 도달하지 않지만 판정을 명시해 일관성을 확보한다). +- 재생본 선택은 기존 재생 경로와 같은 규칙이다: blurred 우선, 없으면 encoded의 presigned + GET URL. 재생 URL을 발급했고 소유자가 아니면(비로그인 포함) `view_count`를 원자적으로 + +1 한다(기존 규칙 그대로. 행사 영상은 전역 격자 피드에도 노출되므로 두 경로의 지표 + 기준을 가른 이유가 없다). 응답에 `viewCount`는 싣지 않는다(행사 상세 화면에 조회수 + 표기가 없다). +- **명시 HEAD 매핑을 함께 둔다**: 같은 경로의 HEAD 요청에 부수효과 없는 200만 반환한다. + 없으면 Spring이 GET 핸들러로 폴백해 view_count가 오른다. `VideoController`의 재생 HEAD + 선례(104~112행, `@RequestMapping(method = HEAD)`와 `@Hidden`, 전 id 동일 응답이라 존재 + 오라클 아님)를 그대로 따른다. 익명 접근도 200이어야 하므로 SecurityConfig의 HEAD 한정 + matcher가 짝이다(인증 정책 참조, Codex 3R). + +응답 `EventVideoDetailResponseDto`: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `videoId` | Long | | +| `occurrenceId` | Long | 소속 회차 | +| `occurrenceStatus` | String | 파생 status (`statusAt(now)`, 438 정의 공유) | +| `locationId` / `locationName` | Long / String | 소속 위치 | +| `representativeGridId` | String | 대표 격자 (US-006 "대표 격자 표시") | +| `zoneName` / `zoneCell` / `regionName` | String | 대표 격자 기준 표시명 재료. 439 공통 규칙과 같은 조립(`zoneName ? zoneName+" "+zoneCell : regionName`), 무귀속 null | +| `playbackUrl` | String | presigned GET URL (READY 게이트라 항상 발급) | +| `durationSec` | Short | | +| `recordedAt` | LocalDateTime | 촬영 시각 | +| `createdAt` | LocalDateTime | 업로드 시각 | +| `uploaderNickname` | String | 작성자 정보 (2026-08-04 전역 노출 닉네임 표시 확정, MSG-371 재생 응답 선례) | +| `interactionLocked` | Boolean | `now >= endsAt` 파생. 댓글과 도움돼요 입력 UI 비활성 재료(FR-13). 서버 측 잠금 강제는 441 변경 API 몫 | + +`regionName`은 저장 라벨 조회(`VideoRepository.findRegionNameByGridId` 선례)로 충분하다. +업로드 경로가 대표 격자의 grids 행을 lazy insert로 반드시 만들므로, 상세가 열리는 영상의 +격자에는 행이 항상 있다(439가 필요로 했던 "행 없는 격자" 계약 확장이 여기선 불필요). +댓글 목록과 도움돼요 수는 441이 이 응답에 추가한다(자리 예약, 이번 구현 제외). + +### 에러 코드: `EventErrorCode` (13xxx) + +`EventErrorCode`는 MSG-439가 만들어 develop에 실재한다(13400, 13401, 13404). 이 티켓은 +**기존 enum에 상수 5개를 추가**한다: + +| developCode | 상수 | HTTP | 메시지 | +|---|---|---|---| +| 13402 | `INVALID_CURSOR` | 400 | 유효하지 않은 커서입니다 | +| 13405 | `EVENT_LOCATION_NOT_FOUND` | 404 | 행사 위치를 찾을 수 없습니다 | +| 13406 | `EVENT_VIDEO_NOT_FOUND` | 404 | 행사 영상을 찾을 수 없습니다 | +| 13409 | `EVENT_UPLOAD_CLOSED` | 409 | 행사 영상 업로드가 마감되었습니다 | +| 13410 | `EVENT_UPLOAD_NOT_STARTED` | 409 | 행사 시작 전에는 영상을 올릴 수 없습니다 | + +`13404 EVENT_NOT_FOUND`(존재 은닉 겸용)는 기존 상수를 그대로 쓴다. 13410은 마감(13409)과 +같은 창 위반이지만 FE 안내 문구가 달라야 해 코드를 가른다(2026-08-21 확정의 귀결). + +- PRD §7의 `REPRESENTATIVE_GRID_MISSING`은 만들지 않는다. 438 DDL(NOT NULL과 지연 FK)이 + 대표 격자 부재를 구조적으로 막아 런타임 도달 경로가 없다. 성공 지표의 "대표 격자 누락 + 실패 0.1% 미만"은 0으로 충족된다. +- PRD §7의 `INVALID_VIDEO`는 기존 video 대역 검증 코드가 대신한다(3401, 3402, 3413, 3415, + 3424 등. PRD §10 "기존 공통 영상 정책 재사용"의 귀결이라 새 코드가 없다). + +### 기존 API 변경 1건: 공개범위 전환 차단 + +`PATCH /api/videos/{videoId}/visibility`는 대상이 `event_videos` 행이면 +`3427 EVENT_VIDEO_VISIBILITY_FIXED`(400, `VideoErrorCode` 신규 상수, 메시지 "행사 영상의 +공개 범위는 변경할 수 없습니다")로 거절한다. 438 확정("PUBLIC 고정의 자연 귀결로 기존 +공개범위 변경 API는 event_videos 행이면 거부, 구현 440 몫")의 이행이다. +`VideoServiceImpl.setVisibility`가 `EventVideoRepository.existsById`를 소비한다. 같은 +Owner B 내부 의존이고, 엔티티 방향(event가 video 참조)과 빈 방향(video 서비스가 event +리포지토리 참조)이 달라 순환이 없다. + +삭제와 교체는 차단하지 않는다. PRD §10이 기존 공통 정책 재사용을 요구하고 glossary의 +"업로드 후 수정, 삭제 자유"가 정본이다. 삭제는 soft delete[^7]라 event_videos 행이 남아도 +노출 술어가 피드, 카운트, 상세에서 동시에 걷어내고, 점령 롤백은 기존 삭제 경로가 그대로 +수행한다(438 부수효과 계약). 교체는 같은 격자 유지 규칙이라 대표 격자 연결이 변하지 +않는다. PRD §15의 "신고와 운영자 삭제 시 아카이브 카운트 정책"은 이 범위 밖이다(현행 +공통 정책이 그대로 적용된다). + +## 도메인 로직 + +- **업로드 가능 창**: `startsAt <= now < uploadClosesAt()` (시작 전 불가, 2026-08-21 + 사용자 확정. 존재 은닉 판정이 선행한다). 신규 확정에만 적용하고 멱등 replay는 창 + 판정을 타지 않는다(API 1 판정 순서). 일반 격자 업로드(`POST /api/videos`)는 행사와 + 무관하게 이 창의 제약을 받지 않는다. 판정은 주입받은 `Clock`의 서버 시각이다(FR-22, + MSG-376 컨벤션). 상태 저장 컬럼이 없어 마감 전환 배치도 없다(438 파생 설계의 귀결, + "재실행 안전" 비기능의 충족 방식). +- **격자는 서버 결정**: 클라이언트는 격자를 정하지 않는다. `gridId`와 `geom` 모두 위치의 + 대표 격자에서 파생된다. glossary의 점령 정의(첫 영상 업로드로 user_grids 행 생성)가 + 대표 격자에 그대로 적용된다. +- **잠금 계약**: 대표 격자를 읽는 업로드와 대표 격자를 재계산하는 시더가 + `event_locations` 행 하나를 `PESSIMISTIC_WRITE`로 직렬화한다(438 계약의 양측 이행. + 이 티켓은 업로드 측 `findWithLockById`를 추가한다). 잠금 획득 후에는 회차도 + 재독한다: 잠금 대기 사이 리시드가 일정을 바꿨을 때 낡은 캐시 값으로 창 판정하는 것을 + 막기 위해서다(API 1 판정 순서 2). +- **술어는 한 곳**: 노출 술어(ACTIVE, PUBLIC, READY)는 `EventVideoRepository`의 쿼리 + 두 곳(피드, 439 카운트)과 상세 판정이 공유하는 정의 하나다. 표현이 갈라지면 숫자와 + 목록의 불일치가 곧 존재 노출이므로, 동등성을 테스트로 고정한다. + +## 데이터 모델 + +변경 없음. **Flyway 마이그레이션을 만들지 않는다**(V39는 438 사용, 멱등 키 저장 컬럼도 +불필요: `videos.original_s3_key` 클레임이 그 역할을 이미 한다). 쓰는 테이블은 +`videos`(INSERT), `grids`(lazy insert), `user_grids`(UPSERT), `event_videos`(INSERT)이고, +읽는 테이블은 `event_occurrences`, `event_locations`, `users`(닉네임), `zones`와 +`regions` 라벨(상세 표시명 재료)이다. + +## 계약 변경 + +**없음.** `GridQueryService`, `HotZoneService`, `UserGridQueryService`, +`UserOidcCommandService`의 시그니처는 바뀌지 않는다. `VideoService`에 격자 지정 확정 +메서드가 추가되지만 video와 event 모두 Owner B라 도메인 간 계약 인터페이스가 아니다 +(B 내부 확장). `ZoneNameQueryService`(Owner A)는 상세 표시명 재료로 기존 시그니처 +그대로 소비만 추가한다. + +## 변경 파일 (실재 클래스 기준) + +신규 (`com.msg.fillmap.event.*`): + +- `controller/EventVideoController` (API 3종 + 상세 HEAD 매핑) +- `dto/EventVideoUploadRequestDto`, `EventVideoUploadResponseDto`, + `EventLocationVideoResponseDto`, `EventLocationVideoPageResponseDto`, + `EventVideoDetailResponseDto` +- `service/EventVideoService`, `EventVideoServiceImpl` +- `support/EventVideoCursor` (keyset 커서, `VideoCursor` 선례, `locationId` 바인딩) + +수정: + +- `event/exception/EventErrorCode`: 상수 5개 추가(13402, 13405, 13406, 13409, 13410) +- `event/repository/EventVideoRepository`: 피드 keyset 조회, 멱등 재시도 판정용 조회 추가 +- `event/repository/EventLocationRepository`: `findWithLockById` 추가 +- `video/service/VideoService`, `VideoServiceImpl`: 확정 코어 추출(좌표 경로와 격자 지정 + 경로가 공유), 격자 지정 저장 메서드 추가, `setVisibility` 행사 영상 차단 +- `video/exception/VideoErrorCode`: `EVENT_VIDEO_VISIBILITY_FIXED(3427)` 추가 +- `global/config/SecurityConfig`: GET 한정 permitAll 두 패턴과 HEAD 한정 한 패턴 추가 + +## 테스트 시나리오 + +한국어 테스트 메서드명 후보: + +업로드 (검증: FR-EVENT-08 계열, PRD 테스트 5, 6, 9, 10): + +- `행사_업로드는_서버가_지정한_대표_격자_하나에만_영상을_만든다` +- `행사_업로드도_일반_업로드처럼_점령을_만든다` (user_grids 생성, occupied=true) +- `이미_점령한_대표_격자에_또_올리면_video_count만_증가한다` (재방문 의미 유지) +- `같은_s3Key_재시도는_중복_레코드_없이_저장_행_기준_성공을_돌려준다` (멱등 replay: + videoId와 gridId는 저장 행, processingStatus는 재시도 시점 값, occupied false, + newBadges 빈 배열) +- `마감_후_도착한_재시도도_커밋된_업로드면_성공을_돌려준다` (멱등 판정이 창 판정보다 + 앞선다, Codex P2 회귀) +- `다른_사용자가_같은_s3Key로_확정하면_3401로_거절된다` +- `다른_위치로_같은_s3Key를_재시도하면_3401로_거절된다` (멱등 판정의 위치 일치 조건) +- `노출_중인_예정_회차_업로드는_13410으로_거절된다` (시작 전 불가, 2026-08-21 확정) +- `시작_정각_업로드는_성공한다` (허용 창 하한은 폐구간 경계) +- `마감_정각부터의_신규_업로드는_13409로_거절된다` (허용 창 상한은 개구간 경계) +- `마감_직전_업로드는_성공한다` (PRD 테스트 9) +- `미노출_예정_회차_업로드는_13404로_실패한다` (존재 은닉이 13410보다 선행) +- `위치가_경로의_회차에_속하지_않으면_13405로_실패한다` +- `행사_업로드의_공개범위는_PUBLIC으로_고정된다` +- `행사_업로드_응답에_획득_뱃지가_동봉된다` (업로드 수 뱃지, 첫 점령 시 수집 뱃지) +- `행사_업로드는_미션_판정을_호출하지_않는다` (제외 계약 트리거 측) +- `행사_업로드는_스트릭과_핫스코어_훅을_그대로_탄다` +- `대표_격자_재계산_시더와_행사_업로드가_경쟁해도_드리프트가_없다` (438 잠금 계약의 + 업로드 측, 438 동시성 테스트와 대응) +- `창_판정은_위치_잠금_후_재독한_회차_값으로_한다` (리시드 경쟁 시 낡은 캐시 값 판정 + 방지, Codex 2R) + +피드 (검증: FR-EVENT 계열, FR-20): + +- `위치별_피드는_최신_업로드_순이다` +- `커서로_다음_페이지가_끊김_없이_이어진다` (경계 동시각 동률 포함) +- `노출_게이트_밖_영상은_피드에_잡히지_않는다` (DELETED, BLINDED, PRIVATE, 비READY 시드) +- `피드_노출_집합과_위치_영상_수_집계가_같다` (439 동등 계약) +- `다른_위치와_다른_회차의_영상이_피드에_섞이지_않는다` +- `영상이_없는_위치의_피드는_빈_페이지다` +- `무효_커서는_13402로_거절된다` +- `다른_위치에서_발급된_커서는_13402로_거절된다` (locationId 바인딩, VideoCursor 선례) +- `size는_1과_50_사이로_클램프된다` + +상세: + +- `상세_응답에_대표_격자_표시명_재료와_작성자_닉네임이_담긴다` +- `종료_시각부터_interactionLocked가_참이다` (반개구간 경계) +- `행사_영상이_아닌_videoId_상세는_13406으로_실패한다` +- `노출_게이트_밖_영상의_상세는_13406으로_실패한다` (소유자 본인 포함) +- `비소유자_상세_재생은_view_count를_증가시킨다` (비로그인 포함, 소유자는 미증가) +- `상세_HEAD_요청은_view_count를_올리지_않는다` (명시 HEAD 매핑, VideoController 선례 회귀) + +보안과 기존 API 회귀: + +- `무인증_피드와_상세는_200으로_성공한다` (GET 한정 permitAll 검증) +- `무인증_상세_HEAD는_부수효과_없이_200이다` (HEAD 한정 permitAll, Codex 3R) +- `무인증_업로드는_401로_거절된다` (같은 경로 POST가 풀리지 않음, 광역 matcher 회귀 방지) +- `공개_GET에서도_무효_토큰은_2401로_거절된다` +- `행사_영상의_공개범위_전환은_3427로_거절된다` +- `일반_영상의_공개범위_전환은_기존대로_동작한다` +- `행사_영상_삭제는_점령_롤백과_함께_피드와_카운트에서_사라진다` + +--- + +[^1]: 대표 격자: 행사 위치의 영역 격자 중 영상 레코드가 실제로 연결되는 단 하나의 격자. + 영역 전체에 영상을 복제하지 않기 위한 장치로, 값은 MSG-438 시더가 계산해 저장했다. +[^2]: 멱등(idempotent): 같은 요청을 여러 번 보내도 한 번 보낸 것과 결과가 같은 성질. + 모바일 환경에서 응답 유실 후 재시도가 흔해, 업로드 확정이 이 성질을 가져야 중복 + 영상이 생기지 않는다. +[^3]: presigned URL: 서버가 서명해 발급하는 기한부 S3 접근 주소. 클라이언트가 파일을 + 서버를 거치지 않고 S3에 직접 올리거나 내려받게 한다. FillMap 업로드는 발급, S3 직접 + PUT, 확정의 2단 흐름이다. +[^4]: advisory lock: 특정 행이 아니라 임의의 키에 거는 PostgreSQL 잠금. 아직 존재하지 + 않는 데이터(같은 pending 키의 동시 확정)에 대한 경쟁을 직렬화할 수 있고, 트랜잭션이 + 끝나면 자동 해제된다. +[^5]: 노출 게이트: 영상을 전역에 보여줘도 되는지 거르는 공통 조건. 이 서비스에서는 + 삭제되지 않았고(ACTIVE), 전체 공개이며(PUBLIC), 인코딩이 끝난(READY) 영상만 통과한다. +[^6]: keyset 커서: 페이지 번호 대신 "마지막으로 본 항목의 정렬 키"를 기준으로 다음 + 페이지를 끊는 방식. 목록에 새 항목이 끼어들어도 중복과 누락 없이 이어지고, 토큰은 + 클라이언트가 해석하지 않는 opaque 값이다. +[^7]: soft delete: 행을 지우는 대신 상태 값(`VideoStatus.DELETED`)으로 삭제를 표시하는 + 방식. 참조 무결성과 이력을 보존하면서 노출만 끊는다. + +## 작업 로그 + +### 2026-08-21 — 스펙 확정과 구현 완료 (전 범위) + +- 스펙은 Codex 교차 리뷰 4라운드로 수렴. 반영: 멱등 replay 판정을 마감 판정보다 선행(마감 직전 + 커밋 + 마감 후 재시도 모순 해소), replay 응답 필드 계약, 피드 커서 locationId 바인딩(VideoCursor + gridId 선례), 위치 잠금 후 회차 재독, 상세 명시 HEAD + HEAD permitAll. 작성 중 MSG-439가 + 머지(develop f4b4043)돼 재동기화 후 enum·술어·SecurityConfig 3지점 재확인을 마쳤다. +- 사용자 확정 3건이 스펙 과정에서 나와 PRD에 반영됐다: 시작 전(예정) 업로드 불가(13410), + 멱등 보장 경계는 대상 영상의 교체·삭제 전까지, 영상 파일 규격은 기존 공통 업로드 정책 재사용. +- 구현은 auth-dev(에이전트) 단독, 신규 테스트 47건 포함 전체 스위트 2109건 green(리더·리뷰어 + 독립 재실행 일치). convention-reviewer 차단 위반 0건. 구현 Codex 리뷰 1라운드 — CORS + allowedMethods HEAD 추가 1건은 미채택(VideoController 선례의 의도된 이중 방어: FE에 HEAD + 사용처가 없고 HEAD 매핑은 GET 폴백의 view_count 오염 방지 전용). +- 회차 재독(`entityManager.refresh`)은 해당 줄을 제거한 뮤테이션으로 테스트가 실제로 깨지는지 + 확인한 뒤 원복했다(검증 실효성 확인). + +**데이터 저장 위치** — 새 테이블·컬럼 없음(Flyway 0). 쓰기는 기존 `videos`(INSERT), +`grids`(lazy insert), `user_grids`(UPSERT), `event_videos`(INSERT — 회차·위치·영상 연결 한 행). +S3는 기존 경로 그대로: pending `videos/pending/{userId}/{uuid}.{ext}` → 확정 시 +`videos/original/{userId}/{pendingUuid}-{newUuid}.{ext}` 서버측 복사. + +**실행 쿼리 (실측 — hibernate show_sql, 2026-08-21)** + +- 피드 첫 페이지 (`EventVideoRepository.findVisiblePage` JPQL): + `select v1_0.id, v1_0.thumbnail_url, v1_0.duration_sec, v1_0.created_at from event_videos ev1_0 + join videos v1_0 on v1_0.id = ev1_0.video_id where ev1_0.event_location_id = ? and + v1_0.status = 'ACTIVE' and v1_0.visibility = 'PUBLIC' and v1_0.processing_status = 'READY' + order by v1_0.created_at desc, v1_0.id desc fetch first ? rows only` +- 피드 다음 페이지(keyset)는 같은 술어에 + `and (v1_0.created_at < ? or (v1_0.created_at = ? and v1_0.id < ?))` 가 추가된다. +- 위치 영상 수(439와 동등 계약 상대): `select ev1_0.event_location_id, count(ev1_0.video_id) + from event_videos ev1_0 join videos v1_0 on v1_0.id = ev1_0.video_id where + ev1_0.event_location_id in (?) and v1_0.status = 'ACTIVE' and v1_0.visibility = 'PUBLIC' and + v1_0.processing_status = 'READY' group by ev1_0.event_location_id` — 세 쿼리의 술어가 글자 + 단위로 같다(동등성 테스트가 고정). +- 멱등 replay 클레임 조회(`VideoRepository.findFirstByOriginalS3KeyStartingWith` 파생): + `where v1_0.original_s3_key like ? escape '\' fetch first 1 rows only` (인자는 + `videos/original/{stem}-%`). +- 상세의 소속 조회: `select ev1_0.video_id, ev1_0.event_occurrence_id, ev1_0.event_location_id + from event_videos ev1_0 where ev1_0.video_id = ?`. 위치 잠금은 438 시더와 같은 파생 쿼리 + 계열로 `for no key update` 가 붙는다. + +**예외 흐름** — 전부 `ApiException` → `GlobalExceptionHandler` 변환. 업로드: 미노출·부재 회차 +13404(404) → 위치 부재·회차 불일치 13405(404) → 시작 전 13410(409) → 마감 후 13409(409), +pending 키 도용·소유 불일치·위치 불일치 재시도는 기존 3401(400). 피드: 커서 디코드 실패와 +locationId 불일치가 `IllegalArgumentException`/`DateTimeException` 을 서비스가 잡아 +13402(400)로 변환. 상세: 노출 술어 밖·비행사 영상 13406(404, 소유자 본인 포함). 공개범위 전환: +event_videos 행이면 3427(400). 무인증 업로드 POST는 시큐리티 체인에서 401(핸들러 밖), +무효 토큰은 공개 GET에서도 2401. + +**빈 동작** — 새 스케줄러·executor 없음. 업로드는 단일 트랜잭션이고 기존 코어의 afterCommit +훅(핫스코어 가산·인코딩 트리거)이 커밋 후에 돈다 — 트랜잭션 안 예외 시 훅 미실행. +`EventVideoServiceImpl` 은 `EntityManager` 주입으로 위치 잠금 획득 직후 회차를 `refresh` 한다 +(잠금 대기 중 부팅 리시드가 일정을 바꾼 경우 영속성 컨텍스트 스테일 방지). 미션 훅은 공용 코어 +밖(`saveVideo` 전용)이라 행사 경로가 구조적으로 호출하지 못한다. + +**커밋** — 6건: docs 스펙 확정 / refactor 확정 코어 분리 / feat 조회·멱등 쿼리와 에러 코드 / +fix 공개범위 차단 / feat 업로드·피드·상세 서비스 / feat API 노출과 비로그인 공개. 문서 마무리 +(SRS 승격 FR-EVENT-08 구현됨·09/10 진행 중, rtm 재생성, status.md, 이 로그)는 후속 docs 커밋. diff --git a/docs/srs-changelog.md b/docs/srs-changelog.md index 0d998d6a..56dfb8b6 100644 --- a/docs/srs-changelog.md +++ b/docs/srs-changelog.md @@ -88,3 +88,5 @@ union이 붙어 양쪽 행이 그대로 남는다. 2026-08-14에 같은 자리 | 2026-08-20 | FR-EVENT-09에 점령 생성을 보강했다. 행사 업로드는 그 위치의 대표 격자에 점령을 만든다(일반 업로드와 동일). 같은 날 확정했던 "점령 미생성"(위치 증거 부재 근거)을 사용자가 당일 번복한 것으로, 위치 증거 없이 점령이 생겨 안 가 본 격자가 도감에 칠해지는 트레이드오프를 인지한 채 도감 노출을 우선했다. 이에 따라 MSG-438 스펙의 삭제 경로 제외 계약과 도감 안티조인 4경로 계약이 소멸하고, 제외는 미션 비연계(안티조인 여섯 쿼리, MSG-450)만 남는다 | 사용자 확정 (2026-08-20 당일 번복, MSG-438 스펙 개정) | | 2026-08-21 | FR-EVENT-01·02를 구현됨으로 올렸다(MSG-439 — 뷰포트 칩 목록·회차 상세·위치 목록·격자 역조회 API 4종, 비로그인 GET 공개). FR-EVENT-07·08은 조회 몫이 끝나 근거를 갱신하되 영상·피드 실데이터 검증이 MSG-440·441 몫이라 진행 중을 유지했다. 비로그인 조회 허용(웹 조회 4종 permitAll, 쓰기부터 로그인)·visibleFrom 2주 전·행사 이미지 없음은 2026-08-20 사용자 확정의 이행이다 | MSG-439 구현 (2026-08-21) | | 2026-08-21 | FR-EVENT-11을 구현됨으로 승격했다. heartbeat 수신과 열람 인원 조회 API가 MSG-443으로 구현돼 검증 테스트 23건과 같은 브랜치에 실렸다. 구현 확정 사항: 열람 계열은 비로그인 허용(웹 정책 정합, 익명은 X-Viewer-Session 헤더로 식별·64자 유계), 저장은 Redis Sorted Set 키 TTL 120초(활성 창 90초와 역할 분리), 캐시 장애는 인원 수만 숨김. occurrenceId 존재 검증은 행사 테이블(MSG-438) 연결 후속 | 정민 확정 (2026-08-20 비로그인 허용, MSG-443 구현 커밋 동반) | +| 2026-08-21 | FR-EVENT-10에 예정 상태 업로드 불가를 보강했다. 시작 전(노출 중) 회차에서는 행사 영상 업로드를 거절해 행사 기록이 남지 않는다(일반 격자 업로드는 무관하게 가능). 정본 PRD §15에 미확정으로 남아 있던 항목을 사용자가 확정했고, 같은 날 영상 파일 규격(길이 30초·용량·코덱)의 기존 공통 업로드 정책 재사용과 멱등 보장 경계(대상 영상의 교체·삭제 전까지)도 함께 확정해 PRD §4.2·§10·§15를 정합시켰다 | 사용자 확정 (2026-08-21, MSG-440 스펙 확정 과정) | +| 2026-08-21 | FR-EVENT-08을 구현됨으로 승격하고 FR-EVENT-09·10을 진행 중으로 올렸다(MSG-440 — 업로드 확정 API·위치별 피드·영상 상세, 검증 테스트 47건 동반). 09의 남은 범위는 댓글·도움돼요(MSG-441), 10의 남은 범위는 댓글·도움돼요 잠금 강제(MSG-441)와 알림 자동 해제(MSG-442)다. FR-EVENT-07은 영상 실데이터의 회차 미혼합까지 끝나 근거만 갱신하고 댓글 몫(MSG-441)이 남아 진행 중을 유지했다 | MSG-440 구현 (2026-08-21) | diff --git a/docs/srs.md b/docs/srs.md index 847ec3d6..b141d8ac 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -372,10 +372,10 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | FR-EVENT-04 | ~~사용자는 제보에 댓글을 달 수 있다~~ | 폐기됨 | 2026-08-20 제보 폐기에 따라 소멸. 댓글은 영상 단위로 대체(FR-EVENT-09) | | FR-EVENT-05 | ~~행사 기간 중 주요 행사 장소별로 상태가 있는 최신 제보 요약을 조회할 수 있다(지도 상태 라벨 재료)~~ | 폐기됨 | 2026-08-20 제보 폐기에 따라 소멸. 장소별 노출 재료는 영상 수(FR-EVENT-02)로 대체, 상태 라벨 개념은 대체 없이 폐기 | | FR-EVENT-06 | 사용자는 행사방의 알림을 참여 절차 없이 켜고 끌 수 있다. 알림을 켠 사용자에게 행사 시작과 일정 변경 알림이 발송되고, 행사가 끝나면 구독이 자동으로 비활성화된다. 사용자는 이 알림을 카테고리로도 켜고 끌 수 있다 | 계획 | 정본 PRD US-007 (2026-08-20 갱신 — 참여 전제 제거, 종료 시 자동 해제 편입). 알림 카테고리(FR-NOTI-06) 확장 필요. MSG-442 | -| FR-EVENT-07 | 행사는 시리즈(반복 개최되는 행사의 공통 단위)와 회차(특정 기간의 개최)로 나뉜다. 같은 행사가 다시 열리면 새 회차가 생기고, 회차 간에 위치·영상·댓글·집계가 섞이지 않는다. 기본 화면은 현재 회차이고 이전 회차의 기록을 그대로 조회할 수 있다 | 진행 중 | 정본 PRD §3, US-010 (2026-08-20). MSG-438 이 저장 계층(시리즈·회차 분리, 회차 미혼합 복합 FK)까지 구현. MSG-439 가 회차 조회(상세·이전 회차 목록·회차별 위치와 영상 수 격리) 구현 (2026-08-21). 남은 범위 = 영상·댓글 실데이터의 회차 미혼합(MSG-440·441) | -| FR-EVENT-08 | 행사 위치는 격자 영역과 대표 격자 하나를 가진다. 영상 파일과 레코드는 대표 격자에만 연결하고 영역의 다른 격자에 복제하지 않으며, 영역 내 어느 격자를 선택해도 같은 위치별 영상 피드가 조회된다. 대표 격자는 홀수 행·열 직사각형이면 정중앙, 아니면 등재 시 지정, 지정도 없으면 영역 중심에 가장 가까운 포함 격자를 계산해 저장한다 | 진행 중 | 정본 PRD §4.1, FR-1~8 (2026-08-20). MSG-438 이 격자 영역 저장과 대표 격자 3단 결정까지 구현. MSG-439 가 격자 역조회(`GET /api/grids/{gridId}/event-locations` — 영역 내 어느 격자든 같은 위치 해석) 구현 (2026-08-21). 남은 범위 = 위치별 영상 피드 자체(MSG-440) | -| FR-EVENT-09 | 사용자는 행사 위치에 영상을 올릴 수 있다. 촬영과 갤러리 선택을 모두 지원하고, 현재 위치나 영상 GPS 메타데이터를 필수 조건으로 쓰지 않으며, 제목·설명은 입력받지 않는다. 격자는 서버가 그 위치의 대표 격자로 결정한다. 업로드는 그 위치의 대표 격자에 점령을 만든다(일반 업로드와 동일, 2026-08-20 확정). 위치별 피드는 최신 업로드순이고, 사용자는 영상에 댓글(작성자 본인만 수정·삭제)과 도움돼요(추가·취소)를 남길 수 있다 | 계획 | 정본 PRD §4.3, US-004~006 (2026-08-20). 점령 생성은 같은 날 "미생성" 확정을 당일 번복한 결정이다(위치 증거 없는 점령 트레이드오프 수용, `docs/srs-changelog.md` 참조). MSG-440·441 | -| FR-EVENT-10 | 행사는 예정, 진행 중, 업로드 유예(종료 후 30일), 아카이브 순서로 흐른다. 종료 시점부터 댓글과 도움돼요 변경이 차단되고(기존 수는 계속 표시), 종료 후 30일까지는 영상을 올릴 수 있으며, 그 후에는 업로드도 차단돼 읽기 전용이 된다. 판정은 전부 서버 시각 기준이다 | 계획 | 정본 PRD §4.2, FR-12~16·22 (2026-08-20). MSG-442 | +| FR-EVENT-07 | 행사는 시리즈(반복 개최되는 행사의 공통 단위)와 회차(특정 기간의 개최)로 나뉜다. 같은 행사가 다시 열리면 새 회차가 생기고, 회차 간에 위치·영상·댓글·집계가 섞이지 않는다. 기본 화면은 현재 회차이고 이전 회차의 기록을 그대로 조회할 수 있다 | 진행 중 | 정본 PRD §3, US-010 (2026-08-20). MSG-438 이 저장 계층(시리즈·회차 분리, 회차 미혼합 복합 FK)까지 구현. MSG-439 가 회차 조회(상세·이전 회차 목록·회차별 위치와 영상 수 격리) 구현 (2026-08-21). MSG-440 이 영상 실데이터의 회차 미혼합(피드가 경로의 회차·위치 정합을 검증) 구현 (2026-08-21). 남은 범위 = 댓글 실데이터의 회차 미혼합(MSG-441) | +| FR-EVENT-08 | 행사 위치는 격자 영역과 대표 격자 하나를 가진다. 영상 파일과 레코드는 대표 격자에만 연결하고 영역의 다른 격자에 복제하지 않으며, 영역 내 어느 격자를 선택해도 같은 위치별 영상 피드가 조회된다. 대표 격자는 홀수 행·열 직사각형이면 정중앙, 아니면 등재 시 지정, 지정도 없으면 영역 중심에 가장 가까운 포함 격자를 계산해 저장한다 | 구현됨 | 정본 PRD §4.1, FR-1~8 (2026-08-20). MSG-438 이 격자 영역 저장과 대표 격자 3단 결정까지 구현. MSG-439 가 격자 역조회(`GET /api/grids/{gridId}/event-locations` — 영역 내 어느 격자든 같은 위치 해석) 구현 (2026-08-21). MSG-440 이 위치별 영상 피드와 대표 격자 단일 연결 업로드를 구현해 전 범위 완료 (2026-08-21) | +| FR-EVENT-09 | 사용자는 행사 위치에 영상을 올릴 수 있다. 촬영과 갤러리 선택을 모두 지원하고, 현재 위치나 영상 GPS 메타데이터를 필수 조건으로 쓰지 않으며, 제목·설명은 입력받지 않는다. 격자는 서버가 그 위치의 대표 격자로 결정한다. 업로드는 그 위치의 대표 격자에 점령을 만든다(일반 업로드와 동일, 2026-08-20 확정). 위치별 피드는 최신 업로드순이고, 사용자는 영상에 댓글(작성자 본인만 수정·삭제)과 도움돼요(추가·취소)를 남길 수 있다 | 진행 중 | 정본 PRD §4.3, US-004~006 (2026-08-20). 점령 생성은 같은 날 "미생성" 확정을 당일 번복한 결정이다(위치 증거 없는 점령 트레이드오프 수용, `docs/srs-changelog.md` 참조). MSG-440 이 업로드(촬영·갤러리 공통, 위치 무관, 서버 대표 격자 지정, s3Key 멱등, 점령 생성)와 위치별 피드(최신순 커서)·영상 상세 구현 (2026-08-21). 남은 범위 = 댓글·도움돼요(MSG-441) | +| FR-EVENT-10 | 행사는 예정, 진행 중, 업로드 유예(종료 후 30일), 아카이브 순서로 흐른다. 예정 상태에서는 영상을 올릴 수 없다(2026-08-21 확정 — 행사 시작 전에는 행사 기록이 남지 않는다). 종료 시점부터 댓글과 도움돼요 변경이 차단되고(기존 수는 계속 표시), 종료 후 30일까지는 영상을 올릴 수 있으며, 그 후에는 업로드도 차단돼 읽기 전용이 된다. 판정은 전부 서버 시각 기준이다 | 진행 중 | 정본 PRD §4.2, FR-12~16·22 (2026-08-20, 예정 상태 업로드 불가는 2026-08-21 사용자 확정). MSG-440 이 업로드 창 판정(시작 전 13410·마감 후 13409, 서버 Clock, 시작 정각 포함·마감 정각 제외) 구현 (2026-08-21). 남은 범위 = 댓글·도움돼요 잠금 강제(MSG-441)·알림 자동 해제(MSG-442) | | FR-EVENT-11 | 행사방을 지금 보고 있는 사람 수를 근실시간으로 표시한다. 클라이언트 heartbeat(기본 30초 주기)로 갱신하고 마지막 신호가 90초 이내인 고유 세션만 세며, 같은 사용자의 중복 탭은 한 명이다. 집계 장애 시 인원 수만 숨기고 다른 행사방 기능은 정상 동작한다 | 구현됨 | 정본 PRD §4.4, FR-17~19 (2026-08-20). MSG-443 구현(2026-08-21 — 열람은 비로그인 허용, 익명은 세션 헤더 식별. occurrenceId 존재 검증은 후속) | ### NOTI: 알림 diff --git a/src/main/java/com/msg/fillmap/event/controller/EventVideoController.java b/src/main/java/com/msg/fillmap/event/controller/EventVideoController.java new file mode 100644 index 00000000..5a491ce2 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/controller/EventVideoController.java @@ -0,0 +1,123 @@ +package com.msg.fillmap.event.controller; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.Valid; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +import com.msg.fillmap.auth.jwt.AuthPrincipal; +import com.msg.fillmap.event.dto.EventLocationVideoPageResponseDto; +import com.msg.fillmap.event.dto.EventVideoDetailResponseDto; +import com.msg.fillmap.event.dto.EventVideoUploadRequestDto; +import com.msg.fillmap.event.dto.EventVideoUploadResponseDto; +import com.msg.fillmap.event.service.EventVideoService; +import com.msg.fillmap.response.SuccessResponse; + +/** + * 행사 영상 API (MSG-440). 업로드(POST)는 로그인 필수, 피드와 상세(GET)는 비로그인 열람이다 — MSG-439 가 + * 확정한 "조회는 비로그인, 쓰기는 로그인" 경계의 연장이다. SecurityConfig 에 GET 한정 matcher 만 등록해 + * 같은 경로의 업로드 POST 가 함께 열리지 않게 한다. + */ +@Tag(name = "행사 (Events)", description = "행사 위치의 영상 업로드·피드·상세 API.") +@RestController +@RequiredArgsConstructor +public class EventVideoController { + + private final EventVideoService eventVideoService; + + @Operation( + summary = "행사 영상 업로드 확정", + description = "행사 위치에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 " + + "S3 에 먼저 올리고 이 API 가 확정한다 — 촬영이든 갤러리 선택이든 서버 계약은 하나다.\n\n" + + "좌표를 받지 않는다. 격자는 서버가 그 위치의 대표 격자로 정하므로 현장에 없어도 올릴 수 있고, " + + "공개 범위는 PUBLIC 으로 고정된다. 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들고 뱃지·" + + "스트릭도 그대로 반영된다(미션만 연계되지 않는다).\n\n" + + "같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 " + + "occupied 는 false, newBadges 는 빈 배열이다(첫 응답 전용 필드).\n\n" + + "올릴 수 있는 기간은 행사 시작부터 종료 30일 후 직전까지다. 시작 전이면 409 + developCode " + + "13410, 마감 이후면 409 + 13409 다. 존재하지 않거나 아직 노출 기간 전인 회차는 404 + 13404, " + + "위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다.\n\n" + + "인코딩이 끝나기 전에는 피드에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 " + + "낙관적으로 그린다(기존 업로드와 같은 성질)." + ) + @PostMapping("/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos") + public SuccessResponse upload( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal, + @Parameter(description = "행사 회차 id", example = "12") @PathVariable long occurrenceId, + @Parameter(description = "행사 위치 id", example = "34") @PathVariable long locationId, + @Valid @RequestBody EventVideoUploadRequestDto request + ) { + return SuccessResponse.of( + eventVideoService.upload(principal.userId(), occurrenceId, locationId, request)); + } + + @Operation( + summary = "위치별 영상 피드 조회", + description = "행사 위치에 올라온 영상을 최신 업로드순으로 한 페이지 돌려준다. 영역 안 어느 격자를 " + + "눌러 들어와도 같은 위치의 같은 피드다.\n\n" + + "여기 담기는 영상은 위치 목록의 영상 수와 정확히 같은 집합이다 — 삭제·비공개·처리 미완료 영상은 " + + "숫자에서도 목록에서도 함께 빠진다. 인코딩이 끝나기 전 영상은 아직 담기지 않는다.\n\n" + + "cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 위치 " + + "피드에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 " + + "생략하면 20 이다.\n\n" + + "아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 " + + "노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. " + + "비로그인으로도 조회할 수 있다." + ) + @GetMapping("/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos") + public SuccessResponse getLocationVideos( + @Parameter(description = "행사 회차 id", example = "12") @PathVariable long occurrenceId, + @Parameter(description = "행사 위치 id", example = "34") @PathVariable long locationId, + @Parameter(description = "직전 응답의 nextCursor. 첫 페이지는 생략") @RequestParam(required = false) String cursor, + @Parameter(description = "페이지 크기 (1~50, 기본 20)", example = "20") + @RequestParam(required = false, defaultValue = "0") int size + ) { + return SuccessResponse.of(eventVideoService.getLocationVideos(occurrenceId, locationId, cursor, size)); + } + + @Operation( + summary = "행사 영상 상세 조회", + description = "영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 " + + "격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.\n\n" + + "피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + " + + "developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 " + + "같은 404 다.\n\n" + + "interactionLocked 는 행사 종료 시각부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다" + + "(기존 수는 계속 표시). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 " + + "포함이고 올린 본인은 제외다." + ) + @GetMapping("/api/event-videos/{videoId}") + public SuccessResponse getVideoDetail( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal, + @Parameter(description = "영상 id", example = "1042") @PathVariable long videoId + ) { + return SuccessResponse.of(eventVideoService.getVideoDetail(videoId, userId(principal))); + } + + // 명시 HEAD 매핑 — 없으면 Spring 이 GET 핸들러로 폴백해 view_count 가 오른다(VideoController 선례). + // 접근 제어 없이 200 만 반환한다: 전 id 동일 응답이라 존재 오라클이 아니다. + @Hidden // API 표면이 아닌 내부 심 — OpenAPI 스펙 노출 제외 + @RequestMapping(value = "/api/event-videos/{videoId}", method = RequestMethod.HEAD) + public SuccessResponse headVideoDetail() { + return new SuccessResponse<>(null); // data 없는 성공 + } + + /** 비로그인 요청은 principal 이 없다 — 상세의 소유자 판정(조회수 제외) 재료라 null 을 그대로 넘긴다. */ + private Long userId(AuthPrincipal principal) { + return principal == null ? null : principal.userId(); + } +} diff --git a/src/main/java/com/msg/fillmap/event/dto/EventLocationVideoPageResponseDto.java b/src/main/java/com/msg/fillmap/event/dto/EventLocationVideoPageResponseDto.java new file mode 100644 index 00000000..41f019ff --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/dto/EventLocationVideoPageResponseDto.java @@ -0,0 +1,26 @@ +package com.msg.fillmap.event.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 위치별 영상 피드 페이지 (MSG-440 API 2). videos 는 한 페이지, hasNext 는 lookahead 판정 결과, + * nextCursor 는 다음 페이지 opaque 커서다(마지막 페이지면 null). 총계는 담지 않는다 — 위치의 영상 수는 + * 위치 목록(MSG-439)이 이미 준다. 격자 영상 목록(GridVideoPageResponseDto)과 구조가 같지만 항목 타입이 + * 다르고 MSG-441 이 항목에 필드를 더할 예정이라 행사 전용 타입으로 둔다. + */ +@Schema(description = "위치별 영상 피드 페이지 (keyset 커서 페이지네이션)", + requiredProperties = {"videos", "hasNext", "nextCursor"}) +public record EventLocationVideoPageResponseDto( + @Schema(description = "이 페이지의 영상. 조건에 맞는 영상이 없으면 빈 배열") + List videos, + + @Schema(description = "다음 페이지 존재 여부 (lookahead 판정)") + boolean hasNext, + + @Schema(description = "다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. " + + "마지막 페이지면 null", nullable = true) + String nextCursor +) { +} diff --git a/src/main/java/com/msg/fillmap/event/dto/EventLocationVideoResponseDto.java b/src/main/java/com/msg/fillmap/event/dto/EventLocationVideoResponseDto.java new file mode 100644 index 00000000..947da00a --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/dto/EventLocationVideoResponseDto.java @@ -0,0 +1,27 @@ +package com.msg.fillmap.event.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 위치별 영상 피드 항목 (MSG-440 API 2). 16:9 카드 한 장의 재료다 — 상대 시간 표기("3시간 전")는 createdAt + * 으로 클라이언트가 계산한다. 썸네일은 READY 게이트를 통과한 영상만 담기므로 항상 발급된다. + * 하트 수·댓글 수는 집계 원천 테이블이 MSG-441 에서 생기므로 이번 범위에 필드가 없다(자리 예약). + */ +@Schema(description = "위치별 영상 피드 항목", + requiredProperties = {"videoId", "thumbnailUrl", "durationSec", "createdAt"}) +public record EventLocationVideoResponseDto( + @Schema(description = "영상 ID — 상세 진입 키", example = "1042") + Long videoId, + + @Schema(description = "썸네일 presigned GET URL") + String thumbnailUrl, + + @Schema(description = "영상 길이(초)", example = "15") + Short durationSec, + + @Schema(description = "업로드 시각", example = "2026-10-06T12:30:00Z") + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/msg/fillmap/event/dto/EventVideoDetailResponseDto.java b/src/main/java/com/msg/fillmap/event/dto/EventVideoDetailResponseDto.java new file mode 100644 index 00000000..54315c90 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/dto/EventVideoDetailResponseDto.java @@ -0,0 +1,67 @@ +package com.msg.fillmap.event.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 행사 영상 상세 (MSG-440 API 3). 재생 화면 한 장의 재료다 — 재생본 URL 과 영상 메타에 더해, 이 영상이 + * 어느 행사·어느 위치·어느 대표 격자의 것인지를 함께 준다(US-006 "대표 격자 표시"). + * 표시명 조립은 클라이언트 몫으로 {@code zoneName 있으면 "{zoneName} {zoneCell}", 없으면 regionName} 이며 + * 서버는 폴백 문자열을 만들지 않는다(MSG-341 공통 규칙). + * 댓글 목록과 도움돼요 수는 MSG-441 이 이 응답에 추가한다(자리 예약). + */ +@Schema(description = "행사 영상 상세", + requiredProperties = {"videoId", "occurrenceId", "occurrenceStatus", "locationId", "locationName", + "representativeGridId", "zoneName", "zoneCell", "regionName", "playbackUrl", "durationSec", + "recordedAt", "createdAt", "uploaderNickname", "interactionLocked"}) +public record EventVideoDetailResponseDto( + @Schema(description = "영상 ID", example = "1042") + Long videoId, + + @Schema(description = "소속 행사 회차 ID", example = "12") + Long occurrenceId, + + @Schema(description = "요청 시점 회차 상태 (UPCOMING/LIVE/UPLOAD_GRACE/ARCHIVED)", example = "LIVE") + String occurrenceStatus, + + @Schema(description = "소속 행사 위치 ID", example = "34") + Long locationId, + + @Schema(description = "소속 행사 위치 이름", example = "영화의전당") + String locationName, + + @Schema(description = "영상이 붙은 대표 격자 ID", example = "19422_9582") + String representativeGridId, + + @Schema(description = "대표 격자가 속한 구역 이름. 구역 밖이면 null — 이때 라벨은 regionName 이다", + example = "서면", nullable = true) + String zoneName, + + @Schema(description = "구역 내 위치 코드 \"{행}-{열}\". zoneName 과 항상 쌍이라 구역 밖이면 함께 null", + example = "A-14", nullable = true) + String zoneCell, + + @Schema(description = "대표 격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속이면 null", + example = "부산광역시 부산진구 부전2동", nullable = true) + String regionName, + + @Schema(description = "재생본 presigned GET URL") + String playbackUrl, + + @Schema(description = "영상 길이(초)", example = "15") + Short durationSec, + + @Schema(description = "촬영 시각", example = "2026-10-06T12:00:00Z") + LocalDateTime recordedAt, + + @Schema(description = "업로드 시각", example = "2026-10-06T12:30:00Z") + LocalDateTime createdAt, + + @Schema(description = "작성자 닉네임", example = "필맵러") + String uploaderNickname, + + @Schema(description = "댓글·도움돼요 입력 UI 를 비활성화할지 여부 — 행사 종료 시각부터 true", example = "false") + boolean interactionLocked +) { +} diff --git a/src/main/java/com/msg/fillmap/event/dto/EventVideoUploadRequestDto.java b/src/main/java/com/msg/fillmap/event/dto/EventVideoUploadRequestDto.java new file mode 100644 index 00000000..ba330b8e --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/dto/EventVideoUploadRequestDto.java @@ -0,0 +1,36 @@ +package com.msg.fillmap.event.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 행사 영상 업로드 확정 요청 (MSG-440 §API 1). 촬영과 갤러리는 클라이언트 구분일 뿐 서버 계약은 하나다. + * 좌표가 없다 — 격자는 서버가 그 위치의 대표 격자로 정한다(FR-EVENT-08, GPS 검증 없음). 제목·설명도 없고 + * 공개범위는 서버가 PUBLIC 으로 고정한다(행사 피드는 공개 전제, MSG-438 확정). + * s3Key 는 기존 presigned 발급(POST /api/videos/presigned-url) 응답의 pending 키이며 멱등 키를 겸한다. + */ +@Schema(description = "행사 영상 업로드 확정 요청") +public record EventVideoUploadRequestDto( + + @Schema(description = "presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다", + example = "videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4") + @NotBlank + String s3Key, + + @Schema(description = "영상 길이(초). 1~30초", example = "15") + @NotNull + @Min(1) + @Max(30) + Short durationSec, + + @Schema(description = "촬영 시각. 미래 시각은 거부된다(단말 시계 오차 5분 허용)", example = "2026-10-06T12:30:00Z") + @NotNull + LocalDateTime recordedAt +) { +} diff --git a/src/main/java/com/msg/fillmap/event/dto/EventVideoUploadResponseDto.java b/src/main/java/com/msg/fillmap/event/dto/EventVideoUploadResponseDto.java new file mode 100644 index 00000000..6e8887e7 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/dto/EventVideoUploadResponseDto.java @@ -0,0 +1,38 @@ +package com.msg.fillmap.event.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import com.msg.fillmap.badge.dto.EarnedBadgeResponseDto; + +/** + * 행사 영상 업로드 확정 응답 (MSG-440 §API 1). gridId 는 서버가 정한 그 위치의 대표 격자다. + *

+ * occupied 와 newBadges 는 첫 응답 전용이다 — 같은 s3Key 재시도(응답 유실 후 재전송)는 저장된 행 + * 기준으로 성공을 돌려주되 occupied 는 false, newBadges 는 빈 배열이다. 재시도 시점에 첫 응답을 복원할 + * 저장 컬럼이 없기 때문이며, 유실된 뱃지 연출은 뱃지 목록과 알림 경로로 이미 노출된다. + * processingStatus 는 신규 확정이면 UPLOADED, 재시도면 그 시점의 실제 값이다(인코딩이 진행됐을 수 있다). + *

+ * completedMissions 는 없다 — 행사 업로드는 미션과 연계되지 않는다(MSG-438 제외 계약). + * 격자 표시명 재료도 싣지 않는다 — 업로드 후 화면은 위치별 피드로 돌아가고 위치 이름이 이미 거기 있다. + */ +@Schema(description = "행사 영상 업로드 확정 응답", + requiredProperties = {"videoId", "gridId", "processingStatus", "occupied", "newBadges"}) +public record EventVideoUploadResponseDto( + @Schema(description = "생성된 영상 ID", example = "1001") + Long videoId, + + @Schema(description = "서버가 지정한 대표 격자 ID", example = "19422_9582") + String gridId, + + @Schema(description = "영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)", example = "UPLOADED") + String processingStatus, + + @Schema(description = "이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false", example = "true") + boolean occupied, + + @Schema(description = "이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열") + List newBadges +) { +} diff --git a/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java b/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java index e7cba6ad..e197046a 100644 --- a/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java +++ b/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java @@ -20,7 +20,13 @@ public enum EventErrorCode implements ErrorCodeIfs { INVALID_VIEWPORT(13400, HttpStatus.BAD_REQUEST, "유효하지 않은 지도 범위입니다"), VIEWPORT_TOO_LARGE(13401, HttpStatus.BAD_REQUEST, "조회 범위가 너무 넓습니다"), + INVALID_CURSOR(13402, HttpStatus.BAD_REQUEST, "유효하지 않은 커서입니다"), EVENT_NOT_FOUND(13404, HttpStatus.NOT_FOUND, "행사를 찾을 수 없습니다"), + EVENT_LOCATION_NOT_FOUND(13405, HttpStatus.NOT_FOUND, "행사 위치를 찾을 수 없습니다"), + EVENT_VIDEO_NOT_FOUND(13406, HttpStatus.NOT_FOUND, "행사 영상을 찾을 수 없습니다"), + EVENT_UPLOAD_CLOSED(13409, HttpStatus.CONFLICT, "행사 영상 업로드가 마감되었습니다"), + // 마감(13409)과 같은 창 위반이지만 FE 안내 문구가 달라야 해 코드를 가른다 (MSG-440, 2026-08-21 확정). + EVENT_UPLOAD_NOT_STARTED(13410, HttpStatus.CONFLICT, "행사 시작 전에는 영상을 올릴 수 없습니다"), ; private final Integer errorCode; diff --git a/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java b/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java index ed2ae1d5..d79e3842 100644 --- a/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java +++ b/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java @@ -22,6 +22,13 @@ public interface EventLocationRepository extends JpaRepository findWithLockByLocationKey(String locationKey); + /** + * 업로드 측 잠금 (MSG-440) — 대표 격자를 읽기 전에 같은 위치 행을 같은 방식으로 잠근다. 시더는 자연키로, + * 업로드는 경로의 id 로 들어오지만 잠기는 행은 event_locations 하나로 같아 위 드리프트 방지가 성립한다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithLockById(Long id); + /** * 회차의 위치 목록 (MSG-439 API 3). 정렬 계약은 display_order 오름차순 → id 오름차순 타이브레이커라 * 같은 표시 순서를 준 위치가 둘이어도 응답 순서가 흔들리지 않는다. diff --git a/src/main/java/com/msg/fillmap/event/repository/EventLocationVideoRow.java b/src/main/java/com/msg/fillmap/event/repository/EventLocationVideoRow.java new file mode 100644 index 00000000..1d5a6295 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/repository/EventLocationVideoRow.java @@ -0,0 +1,11 @@ +package com.msg.fillmap.event.repository; + +import java.time.LocalDateTime; + +/** + * 위치별 영상 피드 한 줄 (MSG-440 API 2). 카드에 필요한 네 값만 뽑는 JPQL 생성자 프로젝션이라 Video 엔티티 + * 전체(geom 포함)를 영속성 컨텍스트에 올리지 않는다. thumbnailKey 는 S3 키 그대로이고, presigned GET URL + * 변환은 서비스 몫이다 — 리포지토리는 서명 같은 외부 관심사를 갖지 않는다. + */ +public record EventLocationVideoRow(Long videoId, String thumbnailKey, Short durationSec, LocalDateTime createdAt) { +} diff --git a/src/main/java/com/msg/fillmap/event/repository/EventVideoRepository.java b/src/main/java/com/msg/fillmap/event/repository/EventVideoRepository.java index 8c2f8a78..6179be3a 100644 --- a/src/main/java/com/msg/fillmap/event/repository/EventVideoRepository.java +++ b/src/main/java/com/msg/fillmap/event/repository/EventVideoRepository.java @@ -1,8 +1,10 @@ package com.msg.fillmap.event.repository; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -32,4 +34,46 @@ public interface EventVideoRepository extends JpaRepository { GROUP BY ev.location.id """) List countVisibleByLocationIds(@Param("locationIds") Collection locationIds); + + /** + * 위치별 영상 피드 첫 페이지 (MSG-440 API 2). 노출 술어는 바로 위 카운트와 동등 계약이라 두 쿼리를 + * 붙여 둔다 — 표현이 갈라지면 위치 목록의 영상 수와 이 피드가 어긋나고, 그 차이가 곧 삭제·비공개 영상의 + * 존재 노출이다. 정렬은 업로드 시각 내림차순 + id 내림차순 타이브레이커라 같은 시각 업로드가 둘이어도 + * 페이지 경계가 결정적이다. 건수 제한은 Pageable 이 지고(JPQL 에 LIMIT 이 없다) 서비스가 size+1 lookahead + * 로 넘긴다 — Pageable 에 Sort 를 실으면 ORDER BY 계약이 흔들리므로 정렬 없는 PageRequest 여야 한다. + */ + @Query(""" + SELECT new com.msg.fillmap.event.repository.EventLocationVideoRow( + v.id, v.thumbnailUrl, v.durationSec, v.createdAt) + FROM EventVideo ev + JOIN ev.video v + WHERE ev.location.id = :locationId + AND v.status = com.msg.fillmap.video.entity.VideoStatus.ACTIVE + AND v.visibility = com.msg.fillmap.video.entity.Visibility.PUBLIC + AND v.processingStatus = com.msg.fillmap.video.entity.ProcessingStatus.READY + ORDER BY v.createdAt DESC, v.id DESC + """) + List findVisibleByLocationId(@Param("locationId") Long locationId, Pageable pageable); + + /** + * 위치별 영상 피드 다음 페이지 (MSG-440 API 2) — 커서 경계값 뒤부터 같은 술어·같은 정렬로 이어 읽는다. + * keyset 조건은 정렬 키와 같은 (createdAt, id) 쌍이라 항목이 끼어들어도 중복·누락 없이 이어진다. + */ + @Query(""" + SELECT new com.msg.fillmap.event.repository.EventLocationVideoRow( + v.id, v.thumbnailUrl, v.durationSec, v.createdAt) + FROM EventVideo ev + JOIN ev.video v + WHERE ev.location.id = :locationId + AND v.status = com.msg.fillmap.video.entity.VideoStatus.ACTIVE + AND v.visibility = com.msg.fillmap.video.entity.Visibility.PUBLIC + AND v.processingStatus = com.msg.fillmap.video.entity.ProcessingStatus.READY + AND (v.createdAt < :cursorCreatedAt OR (v.createdAt = :cursorCreatedAt AND v.id < :cursorId)) + ORDER BY v.createdAt DESC, v.id DESC + """) + List findVisibleByLocationIdAfter( + @Param("locationId") Long locationId, + @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + Pageable pageable); } diff --git a/src/main/java/com/msg/fillmap/event/service/EventVideoService.java b/src/main/java/com/msg/fillmap/event/service/EventVideoService.java new file mode 100644 index 00000000..2a592c52 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/service/EventVideoService.java @@ -0,0 +1,37 @@ +package com.msg.fillmap.event.service; + +import com.msg.fillmap.event.dto.EventLocationVideoPageResponseDto; +import com.msg.fillmap.event.dto.EventVideoDetailResponseDto; +import com.msg.fillmap.event.dto.EventVideoUploadRequestDto; +import com.msg.fillmap.event.dto.EventVideoUploadResponseDto; + +/** + * 행사 영상 쓰기·읽기 경로 (MSG-440). event 도메인 내부 계약이라 다른 도메인이 소비하지 않는다. + * 세 경로가 공유하는 규칙 둘: ① 미노출 예정 회차(UPCOMING 인데 visibleFrom 이 아직 미래)는 없는 회차와 + * 같은 404(13404)다 — 다른 응답을 주면 순차 id 대입으로 노출 전 행사가 드러난다(MSG-439 공통 규칙). + * ② 노출 술어(ACTIVE·PUBLIC·READY)는 피드·상세·위치별 영상 수(MSG-439)가 같은 정의 하나를 쓴다 — + * 표현이 갈라지면 "숫자엔 있는데 목록엔 없는" 불일치가 곧 존재 노출이다. + */ +public interface EventVideoService { + + /** + * 행사 영상 업로드 확정 (API 1). 격자는 서버가 위치의 대표 격자로 정하고 공개범위는 PUBLIC 고정이며, + * 점령·뱃지·스트릭·인코딩은 일반 업로드와 같고 미션 판정만 타지 않는다. + * 같은 s3Key 재시도는 중복을 만들지 않고 저장 행 기준의 성공을 되돌려준다(멱등, 창 판정보다 앞선다). + */ + EventVideoUploadResponseDto upload(long userId, long occurrenceId, long locationId, + EventVideoUploadRequestDto request); + + /** + * 위치별 영상 피드 (API 2). 최신 업로드순이며 cursor 는 직전 응답의 nextCursor(opaque) 다 — null 이면 + * 첫 페이지, 무효거나 다른 위치에서 발급된 것이면 13402 다. size 는 [1, 50] 밖이면 클램프한다 + * (0 이하·미지정은 기본 20). 아카이브를 포함한 모든 상태에서 조회되고, 영상이 없으면 빈 페이지다. + */ + EventLocationVideoPageResponseDto getLocationVideos(long occurrenceId, long locationId, String cursor, int size); + + /** + * 행사 영상 상세 (API 3). userId 는 로그인 사용자, 비로그인이면 null 이다 — 소유자 판정(조회수 증가 + * 제외) 재료로만 쓴다. 행사 영상이 아니거나 노출 술어 밖이면 소유자 본인에게도 13406 이다. + */ + EventVideoDetailResponseDto getVideoDetail(long videoId, Long userId); +} diff --git a/src/main/java/com/msg/fillmap/event/service/EventVideoServiceImpl.java b/src/main/java/com/msg/fillmap/event/service/EventVideoServiceImpl.java new file mode 100644 index 00000000..396811cf --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/service/EventVideoServiceImpl.java @@ -0,0 +1,319 @@ +package com.msg.fillmap.event.service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import jakarta.persistence.EntityManager; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.msg.fillmap.event.dto.EventLocationVideoPageResponseDto; +import com.msg.fillmap.event.dto.EventLocationVideoResponseDto; +import com.msg.fillmap.event.dto.EventVideoDetailResponseDto; +import com.msg.fillmap.event.dto.EventVideoUploadRequestDto; +import com.msg.fillmap.event.dto.EventVideoUploadResponseDto; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.entity.EventStatus; +import com.msg.fillmap.event.entity.EventVideo; +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventLocationVideoRow; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.event.support.EventVideoCursor; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; +import com.msg.fillmap.video.entity.ProcessingStatus; +import com.msg.fillmap.video.entity.Video; +import com.msg.fillmap.video.entity.VideoStatus; +import com.msg.fillmap.video.entity.Visibility; +import com.msg.fillmap.video.exception.VideoErrorCode; +import com.msg.fillmap.video.repository.VideoRepository; +import com.msg.fillmap.video.service.ConfirmedVideo; +import com.msg.fillmap.video.service.VideoService; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; +import com.msg.fillmap.zone.service.ZoneCellName; +import com.msg.fillmap.zone.service.ZoneNameQueryService; + +/** + * 행사 영상 구현 (MSG-440). 업로드는 새 파이프라인이 아니라 기존 업로드 확정 코어를 좌표 대신 지정 격자로 + * 태우고 event_videos 연결 한 줄을 같은 트랜잭션으로 얹는 것이다 — 점령·뱃지·스트릭·인코딩이 일반 업로드와 + * 같아야 한다는 2026-08-20 확정의 귀결이고, 유일한 예외가 미션 비연계다. + */ +@Service +public class EventVideoServiceImpl implements EventVideoService { + + /** 피드 페이지 크기 — 격자·미션 영상 목록과 같은 규격이다. 범위 밖은 에러가 아니라 클램프(MSG-237 §D5). */ + private static final int PAGE_DEFAULT_SIZE = 20; + private static final int PAGE_MAX_SIZE = 50; + + private final EventOccurrenceRepository occurrenceRepository; + private final EventLocationRepository locationRepository; + private final EventVideoRepository eventVideoRepository; + private final VideoService videoService; + // 상세의 작성자 닉네임·행정동 라벨 조회 (MSG-371·MSG-341 선례). 같은 Owner B 내부 의존이다. + private final VideoRepository videoRepository; + private final ThumbnailUrlPresigner thumbnailUrlPresigner; + private final ZoneNameQueryService zoneNameQueryService; + private final EntityManager entityManager; + private final Clock clock; + + /** + * 프로덕션 생성자 — clock 을 Clock.systemUTC() 로 고정해 전체 생성자로 위임한다 (EventQueryServiceImpl + * 선례). UTC 인 이유는 starts_at·ends_at 이 UTC 로 저장되기 때문이다 — 기본존(로컬 KST)이면 업로드 창 + * 판정이 9시간 어긋난다. 전체 생성자는 창 경계 검증용 고정 클럭 주입에 쓴다. + */ + @Autowired + public EventVideoServiceImpl( + EventOccurrenceRepository occurrenceRepository, + EventLocationRepository locationRepository, + EventVideoRepository eventVideoRepository, + VideoService videoService, + VideoRepository videoRepository, + ThumbnailUrlPresigner thumbnailUrlPresigner, + ZoneNameQueryService zoneNameQueryService, + EntityManager entityManager + ) { + this(occurrenceRepository, locationRepository, eventVideoRepository, videoService, videoRepository, + thumbnailUrlPresigner, zoneNameQueryService, entityManager, Clock.systemUTC()); + } + + public EventVideoServiceImpl( + EventOccurrenceRepository occurrenceRepository, + EventLocationRepository locationRepository, + EventVideoRepository eventVideoRepository, + VideoService videoService, + VideoRepository videoRepository, + ThumbnailUrlPresigner thumbnailUrlPresigner, + ZoneNameQueryService zoneNameQueryService, + EntityManager entityManager, + Clock clock + ) { + this.occurrenceRepository = occurrenceRepository; + this.locationRepository = locationRepository; + this.eventVideoRepository = eventVideoRepository; + this.videoService = videoService; + this.videoRepository = videoRepository; + this.thumbnailUrlPresigner = thumbnailUrlPresigner; + this.zoneNameQueryService = zoneNameQueryService; + this.entityManager = entityManager; + this.clock = clock; + } + + /** + * 판정 순서가 계약이다 (§API 1): 회차 존재·노출 → 위치 잠금·회차 정합 → 회차 재독 → 멱등 재시도 → + * 업로드 창 → 확정 코어 → event_videos 연결. 멱등 판정이 창 판정보다 앞선 이유는, 마감 직전 커밋된 + * 업로드의 응답이 유실되고 재시도가 마감 후 도착했을 때 이미 성공한 요청이 13409 로 거절되면 안 되기 + * 때문이다(Codex 교차 리뷰 P2). 전체가 한 트랜잭션이다 — 영상 생성과 대표 격자 연결은 함께 커밋된다. + */ + @Override + @Transactional + public EventVideoUploadResponseDto upload(long userId, long occurrenceId, long locationId, + EventVideoUploadRequestDto request) { + LocalDateTime now = LocalDateTime.now(clock); + EventOccurrence occurrence = occurrenceRepository.findById(occurrenceId) + .filter(found -> isVisible(found, now)) + .orElseThrow(() -> new ApiException(EventErrorCode.EVENT_NOT_FOUND)); + EventLocation location = locationRepository.findWithLockById(locationId) + .filter(found -> found.getOccurrence().getId().equals(occurrenceId)) + .orElseThrow(() -> new ApiException(EventErrorCode.EVENT_LOCATION_NOT_FOUND)); + // 잠금 획득 후 회차 재독 — 위 조회는 잠금이 없어, 잠금 대기 사이 부팅 리시드(롤링 배포 중 새 + // 인스턴스의 멱등 UPSERT)가 일정을 바꾸면 영속성 컨텍스트에 캐시된 startsAt·endsAt 이 낡은 값으로 + // 남는다. 재조회는 1차 캐시가 같은 인스턴스를 돌려주므로 refresh 여야 한다(Codex 2R). + entityManager.refresh(occurrence); + + // 멱등 재시도 판정 — 저장 컬럼을 새로 만들지 않고 pending 키 클레임(videos.original_s3_key)을 쓴다. + Optional

+ * 와이어 DTO 가 아니라 Owner B 내부(video → event) 반환 타입이다. video 는 소비 측에서 읽기 전용이며 + * 상태 변경은 video 도메인 서비스만 한다(영속 컨벤션 "타 도메인 엔티티는 읽기 전용"). + */ +public record ConfirmedVideo(Video video, boolean occupied, List newBadges) { +} diff --git a/src/main/java/com/msg/fillmap/video/service/VideoService.java b/src/main/java/com/msg/fillmap/video/service/VideoService.java index 95d8c610..13a3c336 100644 --- a/src/main/java/com/msg/fillmap/video/service/VideoService.java +++ b/src/main/java/com/msg/fillmap/video/service/VideoService.java @@ -1,6 +1,8 @@ package com.msg.fillmap.video.service; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import com.msg.fillmap.video.dto.FriendGridVideoResponseDto; import com.msg.fillmap.video.dto.GridCoverVideoResponseDto; @@ -16,6 +18,7 @@ import com.msg.fillmap.video.dto.VideoUploadResponseDto; import com.msg.fillmap.video.dto.VideoVisibilityRequestDto; import com.msg.fillmap.video.dto.VideoVisibilityResponseDto; +import com.msg.fillmap.video.entity.Video; public interface VideoService { @@ -81,6 +84,24 @@ public interface VideoService { */ VideoUploadResponseDto saveVideo(long userId, VideoUploadRequestDto request); + /** + * 격자 지정 업로드 확정 (MSG-440) — 좌표 대신 호출자가 정한 격자에 영상을 확정한다. 행사 업로드가 쓰며 + * geom 은 그 격자의 셀 중심점, 공개범위는 PUBLIC 고정이다. pending 키 검증·grids lazy insert· + * videos INSERT·S3 복사·점령 UPSERT·뱃지·스트릭·커밋 후 인코딩과 핫스코어는 saveVideo 와 같은 코어를 + * 지나고, 미션 판정만 타지 않는다(행사 업로드 미션 비연계, MSG-438 제외 계약). + * 호출자의 트랜잭션에 합류한다 — 영상 생성과 행사 연결이 한 트랜잭션이어야 하기 때문이다. + * video 도 event 도 Owner B 라 도메인 간 계약 인터페이스가 아니라 B 내부 확장이다. + */ + ConfirmedVideo confirmAtGrid(long userId, String gridId, String s3Key, Short durationSec, + LocalDateTime recordedAt); + + /** + * pending 키에서 이미 확정된 영상 조회 (MSG-440 멱등 재시도 판정). 확정과 같은 advisory lock 을 먼저 + * 잡으므로 앞 시도가 커밋됐다면 반드시 보인다 — 호출자는 찾은 영상의 소유자·연결이 자기 요청과 같은지 + * 확인해 재시도(성공 재응답)와 도용(3401)을 가른다. 형식이 어긋난 키는 empty 다. + */ + Optional

+ * 격리(공유 로컬 DB): 자연키에 UUID 를 섞어 재실행·병렬 실행에서 충돌하지 않게 하고, 호출자는 서해 먼바다 + * 격자를 넘겨 육상 실데이터와 겹치지 않게 한다. 정리는 호출 테스트의 {@code @Transactional} 롤백이 한다. + */ +public class EventTestFixtures { + + private final EventSeriesRepository seriesRepository; + private final EventOccurrenceRepository occurrenceRepository; + private final EventLocationRepository locationRepository; + private final EventLocationGridRepository locationGridRepository; + + public EventTestFixtures(EventSeriesRepository seriesRepository, + EventOccurrenceRepository occurrenceRepository, + EventLocationRepository locationRepository, + EventLocationGridRepository locationGridRepository) { + this.seriesRepository = seriesRepository; + this.occurrenceRepository = occurrenceRepository; + this.locationRepository = locationRepository; + this.locationGridRepository = locationGridRepository; + } + + public String 키(String suffix) { + return "msg440-" + suffix + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + public EventSeries 시리즈() { + return seriesRepository.save(new EventSeries(키("series"), "테스트 시리즈")); + } + + /** 노출 영역 사각형은 대표 격자 주변 한 칸이면 충분하다 — 이 티켓의 조회는 뷰포트 겹침을 타지 않는다. */ + public EventOccurrence 회차(EventSeries series, LocalDateTime startsAt, LocalDateTime endsAt, String gridId) { + String[] index = gridId.split("_"); + int gridY = Integer.parseInt(index[0]); + int gridX = Integer.parseInt(index[1]); + EventOccurrence occurrence = new EventOccurrence(series, 키("occ")); + occurrence.update(series, "테스트 행사", "부산", startsAt, endsAt, gridY, gridY + 1, gridX, gridX + 1); + return occurrenceRepository.save(occurrence); + } + + /** 첫 격자가 대표 격자다 — 대표 격자 결정 규칙(3단)은 시더 몫이고 여기서는 지정값을 그대로 쓴다. */ + public EventLocation 위치(EventOccurrence occurrence, String name, String... gridIds) { + EventLocation location = new EventLocation(occurrence, 키("loc")); + location.update(occurrence, name, EventLocationType.POPUP, "11:00 ~ 20:00", 0, gridIds[0]); + locationRepository.save(location); + for (String gridId : gridIds) { + locationGridRepository.save(new EventLocationGrid(location.getId(), occurrence.getId(), gridId)); + } + return location; + } +} diff --git a/src/test/java/com/msg/fillmap/event/controller/EventVideoPublicAccessHttpTest.java b/src/test/java/com/msg/fillmap/event/controller/EventVideoPublicAccessHttpTest.java new file mode 100644 index 00000000..435813e6 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/controller/EventVideoPublicAccessHttpTest.java @@ -0,0 +1,184 @@ +package com.msg.fillmap.event.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.event.dto.EventLocationVideoPageResponseDto; +import com.msg.fillmap.event.dto.EventVideoDetailResponseDto; +import com.msg.fillmap.event.dto.EventVideoUploadRequestDto; +import com.msg.fillmap.event.dto.EventVideoUploadResponseDto; +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.service.EventVideoService; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.user.entity.UserRole; + +/** + * 행사 영상 API 의 인증 계약 (MSG-440 §API 명세 인증 정책). 피드와 상세는 비로그인 열람이고 업로드는 + * 로그인부터다 — 업로드 POST 가 피드 GET 과 같은 경로라, 메서드 무제한 문자열 패턴으로 열면 업로드가 + * 조용히 익명에 풀린다. 서비스는 목이라 이 테스트의 변수는 SecurityConfig 등록과 컨트롤러 배선뿐이다. + *

+ * 인증된 업로드 두 건은 그 배선을 본다 — 토큰의 사용자 id 가 서비스 첫 인자로 넘어가는지(다른 사람 이름으로 + * 영상이 저장되는 걸 막는 지점)와 요청 본문 검증이 실제로 발동하는지다. 인증은 레포 선례대로 실제 JWT 를 + * 발급해 Authorization 헤더로 싣는다(AdminAuthorizationTest) — 시큐리티 실체인과 필터를 그대로 통과해야 + * principal 이 채워지므로 목 인증으로는 이 배선이 검증되지 않는다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("행사 영상 공개 접근 (SecurityConfig)") +class EventVideoPublicAccessHttpTest { + + private static final long OCCURRENCE_ID = 12L; + private static final long LOCATION_ID = 34L; + private static final long VIDEO_ID = 1042L; + private static final long UPLOADER_ID = 7007L; + private static final String FEED_PATH = "/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos"; + private static final String DETAIL_PATH = "/api/event-videos/{videoId}"; + private static final String UPLOAD_BODY = """ + {"s3Key":"videos/pending/7007/6f1c1f0e.mp4","durationSec":10,"recordedAt":"2026-10-06T12:00:00Z"}"""; + /** durationSec 0 은 @Min(1) 위반 — @Valid 가 안 걸려 있으면 그대로 서비스까지 흘러간다. */ + private static final String INVALID_UPLOAD_BODY = """ + {"s3Key":"videos/pending/7007/6f1c1f0e.mp4","durationSec":0,"recordedAt":"2026-10-06T12:00:00Z"}"""; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TokenProvider tokenProvider; + + @MockitoBean + private EventVideoService eventVideoService; + + private String bearer(long userId) { + return "Bearer " + tokenProvider.issueAccessToken(userId, UserRole.USER); + } + + private EventVideoDetailResponseDto 상세() { + return new EventVideoDetailResponseDto(VIDEO_ID, OCCURRENCE_ID, "LIVE", LOCATION_ID, "영화의전당", + "19422_9582", null, null, "부산광역시 부산진구 부전2동", "https://example.test/playback", (short) 10, + LocalDateTime.of(2026, 10, 6, 12, 0), LocalDateTime.of(2026, 10, 6, 12, 30), "필맵러", false); + } + + @Test + @DisplayName("무인증 피드와 상세는 200으로 성공한다") + void 무인증_피드와_상세는_200으로_성공한다() throws Exception { + given(eventVideoService.getLocationVideos(anyLong(), anyLong(), any(), anyInt())) + .willReturn(new EventLocationVideoPageResponseDto(List.of(), false, null)); + given(eventVideoService.getVideoDetail(anyLong(), any())).willReturn(상세()); + + mockMvc.perform(get(FEED_PATH, OCCURRENCE_ID, LOCATION_ID)).andExpect(status().isOk()); + mockMvc.perform(get(DETAIL_PATH, VIDEO_ID)).andExpect(status().isOk()); + } + + @Test + @DisplayName("무인증 상세 HEAD 는 부수효과 없이 200이다") + void 무인증_상세_HEAD는_부수효과_없이_200이다() throws Exception { + mockMvc.perform(head(DETAIL_PATH, VIDEO_ID)).andExpect(status().isOk()); + + // 명시 HEAD 매핑이 없으면 GET 핸들러로 폴백해 조회수가 오른다 — 서비스 호출 자체가 없어야 한다. + then(eventVideoService).should(never()).getVideoDetail(anyLong(), any()); + } + + @Test + @DisplayName("무인증 업로드는 401로 거절된다 (피드와 같은 경로가 함께 풀리지 않는다)") + void 무인증_업로드는_401로_거절된다() throws Exception { + mockMvc.perform(post(FEED_PATH, OCCURRENCE_ID, LOCATION_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"s3Key":"videos/pending/1/x.mp4","durationSec":10,"recordedAt":"2026-10-06T12:00:00Z"}""")) + .andExpect(status().isUnauthorized()); + then(eventVideoService).should(never()).upload(anyLong(), anyLong(), anyLong(), any()); + } + + // 검증: FR-EVENT-09 + @Test + @DisplayName("인증된 업로드는 토큰의 사용자 id 로 서비스에 위임된다") + void 인증된_업로드는_토큰의_사용자_id로_서비스에_위임된다() throws Exception { + given(eventVideoService.upload(anyLong(), anyLong(), anyLong(), any())) + .willReturn(new EventVideoUploadResponseDto(1001L, "19422_9582", "UPLOADED", true, List.of())); + + mockMvc.perform(post(FEED_PATH, OCCURRENCE_ID, LOCATION_ID) + .header(HttpHeaders.AUTHORIZATION, bearer(UPLOADER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(UPLOAD_BODY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.videoId").value(1001)) + .andExpect(jsonPath("$.data.gridId").value("19422_9582")) + .andExpect(jsonPath("$.data.processingStatus").value("UPLOADED")) + .andExpect(jsonPath("$.data.occupied").value(true)) + .andExpect(jsonPath("$.data.newBadges").isEmpty()); + + // 업로더가 경로나 본문이 아니라 토큰에서 온다 — 여기가 어긋나면 남의 이름으로 영상이 저장된다. + ArgumentCaptor request = ArgumentCaptor.forClass(EventVideoUploadRequestDto.class); + then(eventVideoService).should() + .upload(eq(UPLOADER_ID), eq(OCCURRENCE_ID), eq(LOCATION_ID), request.capture()); + assertThat(request.getValue().s3Key()).isEqualTo("videos/pending/7007/6f1c1f0e.mp4"); + assertThat(request.getValue().durationSec()).isEqualTo((short) 10); + } + + @Test + @DisplayName("길이가 범위 밖인 업로드 요청은 서비스에 닿기 전에 400이다") + void 길이가_범위_밖인_업로드_요청은_서비스에_닿기_전에_400이다() throws Exception { + mockMvc.perform(post(FEED_PATH, OCCURRENCE_ID, LOCATION_ID) + .header(HttpHeaders.AUTHORIZATION, bearer(UPLOADER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(INVALID_UPLOAD_BODY)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(400)); + then(eventVideoService).should(never()).upload(anyLong(), anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("공개 GET 에서도 무효 토큰은 2401로 거절된다 (토큰 검증 성질 보존)") + void 공개_GET에서도_무효_토큰은_2401로_거절된다() throws Exception { + mockMvc.perform(get(DETAIL_PATH, VIDEO_ID) + .header(HttpHeaders.AUTHORIZATION, "Bearer not-a-real-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.developCode").value(2401)); + } + + @Test + @DisplayName("무인증 상세 요청에 다른 영상 경로가 함께 열리지 않는다 (기존 보호 유지)") + void 무인증_상세_요청에_다른_영상_경로가_함께_열리지_않는다() throws Exception { + mockMvc.perform(get("/api/videos/{videoId}", VIDEO_ID)).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/event-occurrences/{id}/locations/{lid}/videos/{vid}", + OCCURRENCE_ID, LOCATION_ID, VIDEO_ID)).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("무효 커서 파라미터는 인증 없이도 도메인 코드 13402로 응답한다") + void 무효_커서_파라미터는_인증_없이도_도메인_코드_13402로_응답한다() throws Exception { + given(eventVideoService.getLocationVideos(anyLong(), anyLong(), anyString(), anyInt())) + .willThrow(new ApiException(EventErrorCode.INVALID_CURSOR)); + + mockMvc.perform(get(FEED_PATH, OCCURRENCE_ID, LOCATION_ID).param("cursor", "broken")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(13402)); + } +} diff --git a/src/test/java/com/msg/fillmap/event/service/EventVideoQueryServiceTest.java b/src/test/java/com/msg/fillmap/event/service/EventVideoQueryServiceTest.java new file mode 100644 index 00000000..5e382423 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/service/EventVideoQueryServiceTest.java @@ -0,0 +1,476 @@ +package com.msg.fillmap.event.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; + +import com.msg.fillmap.event.EventTestFixtures; +import com.msg.fillmap.event.dto.EventLocationVideoPageResponseDto; +import com.msg.fillmap.event.dto.EventLocationVideoResponseDto; +import com.msg.fillmap.event.dto.EventVideoDetailResponseDto; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.entity.EventVideo; +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventLocationVideoCount; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.event.support.EventVideoCursor; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; +import com.msg.fillmap.grid.GridEncoder.GridPoint; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.repository.UserRepository; +import com.msg.fillmap.video.entity.Video; +import com.msg.fillmap.video.entity.Visibility; +import com.msg.fillmap.video.repository.VideoRepository; +import com.msg.fillmap.video.service.VideoService; +import com.msg.fillmap.video.support.GeoSupport; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; +import com.msg.fillmap.zone.entity.Zone; +import com.msg.fillmap.zone.repository.ZoneRepository; +import com.msg.fillmap.zone.service.ZoneNameQueryService; + +/** + * 위치별 영상 피드와 행사 영상 상세 (MSG-440 §API 2·3, 실 PostgreSQL). 노출 술어·정렬·keyset 이 전부 DB + * 판정이라 모킹으로는 검증되지 않는다 — presign 만 목이다(자격증명 없는 환경에서도 돌게). + *

+ * 격리(공유 로컬 DB): 서해 먼바다 격자와 합성 자연키(msg440-*)만 쓰고 {@code @Transactional} 롤백으로 + * 정리한다. 단언은 이 테스트가 만든 위치·영상 id 로 좁혀 주변 데이터에 흔들리지 않는다. + */ +@SpringBootTest +@Transactional +@DisplayName("행사 영상 피드·상세 (실 PostgreSQL)") +class EventVideoQueryServiceTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 6, 1, 0, 0); + + /** 서해 먼바다 기준 격자 — 다른 행사 테스트(125.0·125.2·125.3·125.4)와 겹치지 않는 대역. */ + private static final GridIndex 바다 = GridEncoder.decode(GridEncoder.encode(34.0, 125.5)); + + private static final String PRESIGNED = "https://example.test/presigned"; + + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + + @Autowired + private EventVideoRepository eventVideoRepository; + + @Autowired + private VideoRepository videoRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ZoneRepository zoneRepository; + + @Autowired + private ZoneNameQueryService zoneNameQueryService; + + @Autowired + private VideoService videoService; + + @Autowired + private EntityManager em; + + /** presign 은 자격증명이 필요한 로컬 서명이라 목으로 고정한다 — 검증 대상은 "키가 실렸는가"다. */ + @MockitoBean + private ThumbnailUrlPresigner thumbnailUrlPresigner; + + private EventTestFixtures fixtures; + private Long userId; + + @BeforeEach + void setUp() { + given(thumbnailUrlPresigner.presign(anyString())).willReturn(PRESIGNED); + fixtures = new EventTestFixtures(seriesRepository, occurrenceRepository, locationRepository, + locationGridRepository); + userId = 사용자(); + } + + private Long 사용자() { + return userRepository.save(User.createLocalUser( + "msg440-feed-" + UUID.randomUUID() + "@example.com", "hash", "행사업로더")).getId(); + } + + private EventVideoService service() { + return service(NOW); + } + + private EventVideoService service(LocalDateTime now) { + return new EventVideoServiceImpl(occurrenceRepository, locationRepository, eventVideoRepository, + videoService, videoRepository, thumbnailUrlPresigner, zoneNameQueryService, em, + Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); + } + + private String 격자(long dy) { + return (바다.gridY() + dy) + "_" + 바다.gridX(); + } + + private EventLocation 위치(long dy) { + return 위치(NOW.minusDays(1), NOW.plusDays(1), dy); + } + + private EventLocation 위치(LocalDateTime startsAt, LocalDateTime endsAt, long dy) { + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), startsAt, endsAt, 격자(dy)); + return fixtures.위치(occurrence, "행사 위치", 격자(dy)); + } + + /** 전역 노출 게이트(ACTIVE·PUBLIC·READY)를 통과하는 행사 영상 하나. */ + private Video 노출영상(EventLocation location) { + Video video = 영상(location, Visibility.PUBLIC, userId); + video.markReady("videos/encoded/" + UUID.randomUUID() + ".mp4", "thumb/" + UUID.randomUUID() + ".jpg"); + return video; + } + + private Video 영상(EventLocation location, Visibility visibility, Long owner) { + String gridId = location.getRepresentativeGridId(); + GridIndex index = GridEncoder.decode(gridId); + GridPoint center = GridEncoder.center(gridId); + videoRepository.upsertGrid(gridId, index.gridY(), index.gridX(), center.lat(), center.lon(), + GeoSupport.bboxWkt(gridId)); + Video video = videoRepository.save(Video.create(owner, gridId, + "videos/original/" + UUID.randomUUID() + ".mp4", GeoSupport.toPoint(center.lat(), center.lon()), + (short) 10, NOW, visibility)); + eventVideoRepository.save(new EventVideo(video, location, location.getOccurrence().getId())); + return video; + } + + /** 업로드 시각을 직접 못 박는다 — created_at 은 저장 시각 자동 값이라 정렬·동률 검증에는 고정이 필요하다. */ + private void 업로드시각(Long videoId, LocalDateTime createdAt) { + em.flush(); + em.createNativeQuery("UPDATE videos SET created_at = :t WHERE id = :i") + .setParameter("t", createdAt) + .setParameter("i", videoId) + .executeUpdate(); + } + + private List 영상ids(EventLocationVideoPageResponseDto page) { + return page.videos().stream().map(EventLocationVideoResponseDto::videoId).toList(); + } + + private EventLocationVideoPageResponseDto 피드(EventLocation location, String cursor, int size) { + return service().getLocationVideos(location.getOccurrence().getId(), location.getId(), cursor, size); + } + + @Nested + @DisplayName("위치별 피드 (API 2)") + class 피드조회 { + + // 검증: FR-EVENT-09 + @Test + @DisplayName("위치별 피드는 최신 업로드 순이다") + void 위치별_피드는_최신_업로드_순이다() { + EventLocation location = 위치(0); + Video 오래된 = 노출영상(location); + Video 최신 = 노출영상(location); + 업로드시각(오래된.getId(), NOW.minusHours(2)); + 업로드시각(최신.getId(), NOW.minusHours(1)); + + assertThat(영상ids(피드(location, null, 0))) + .containsExactly(최신.getId(), 오래된.getId()); + } + + @Test + @DisplayName("커서로 다음 페이지가 끊김 없이 이어진다 (경계 동시각 동률 포함)") + void 커서로_다음_페이지가_끊김_없이_이어진다() { + EventLocation location = 위치(1); + Video 첫째 = 노출영상(location); + Video 둘째 = 노출영상(location); + Video 셋째 = 노출영상(location); + // 앞 둘의 업로드 시각을 같게 둬 페이지 경계가 동률 위에 놓이게 한다 — id 타이브레이커가 없으면 + // 여기서 항목이 중복되거나 빠진다. + 업로드시각(첫째.getId(), NOW.minusHours(1)); + 업로드시각(둘째.getId(), NOW.minusHours(1)); + 업로드시각(셋째.getId(), NOW.minusHours(2)); + + EventLocationVideoPageResponseDto first = 피드(location, null, 2); + EventLocationVideoPageResponseDto second = 피드(location, first.nextCursor(), 2); + + assertThat(first.hasNext()).isTrue(); + assertThat(영상ids(first)).containsExactly(둘째.getId(), 첫째.getId()); + assertThat(second.hasNext()).isFalse(); + assertThat(second.nextCursor()).isNull(); + assertThat(영상ids(second)).containsExactly(셋째.getId()); + } + + @Test + @DisplayName("노출 게이트 밖 영상은 피드에 잡히지 않는다") + void 노출_게이트_밖_영상은_피드에_잡히지_않는다() { + EventLocation location = 위치(2); + Video 정상 = 노출영상(location); + 영상(location, Visibility.PUBLIC, userId); // 인코딩 미완(READY 아님) + Video 비공개 = 영상(location, Visibility.PRIVATE, userId); + 비공개.markReady("videos/encoded/priv.mp4", "thumb/priv.jpg"); + Video 삭제 = 노출영상(location); + 삭제.markDeleted(); + Video 블라인드 = 노출영상(location); + 블라인드.markBlinded(); + + assertThat(영상ids(피드(location, null, 0))).containsExactly(정상.getId()); + } + + @Test + @DisplayName("피드 노출 집합과 위치 영상 수 집계가 같다") + void 피드_노출_집합과_위치_영상_수_집계가_같다() { + EventLocation location = 위치(3); + 노출영상(location); + 노출영상(location); + 영상(location, Visibility.PRIVATE, userId); // 두 술어 모두에서 빠져야 한다 + em.flush(); + + long 집계 = eventVideoRepository.countVisibleByLocationIds(List.of(location.getId())).stream() + .mapToLong(EventLocationVideoCount::videoCount) + .sum(); + + assertThat(피드(location, null, 0).videos()).hasSize((int) 집계); + } + + // 검증: FR-EVENT-07 + @Test + @DisplayName("다른 위치와 다른 회차의 영상이 피드에 섞이지 않는다") + void 다른_위치와_다른_회차의_영상이_피드에_섞이지_않는다() { + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), 격자(4)); + EventLocation 대상 = fixtures.위치(occurrence, "위치 A", 격자(4)); + EventLocation 같은회차_다른위치 = fixtures.위치(occurrence, "위치 B", 격자(5)); + EventLocation 다른회차 = 위치(6); + Video 대상영상 = 노출영상(대상); + 노출영상(같은회차_다른위치); + 노출영상(다른회차); + + assertThat(영상ids(피드(대상, null, 0))).containsExactly(대상영상.getId()); + } + + @Test + @DisplayName("영상이 없는 위치의 피드는 빈 페이지다") + void 영상이_없는_위치의_피드는_빈_페이지다() { + EventLocationVideoPageResponseDto page = 피드(위치(7), null, 0); + + assertThat(page.videos()).isEmpty(); + assertThat(page.hasNext()).isFalse(); + assertThat(page.nextCursor()).isNull(); + } + + @Test + @DisplayName("무효 커서는 13402로 거절된다") + void 무효_커서는_13402로_거절된다() { + EventLocation location = 위치(8); + + assertThatThrownBy(() -> 피드(location, "!!!not-a-cursor!!!", 0)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.INVALID_CURSOR); + } + + @Test + @DisplayName("다른 위치에서 발급된 커서는 13402로 거절된다") + void 다른_위치에서_발급된_커서는_13402로_거절된다() { + EventLocation location = 위치(9); + String 남의커서 = EventVideoCursor.encode(location.getId() + 1000, NOW, 1L); + + assertThatThrownBy(() -> 피드(location, 남의커서, 0)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.INVALID_CURSOR); + } + + @Test + @DisplayName("size 는 1과 50 사이로 클램프된다") + void size는_1과_50_사이로_클램프된다() { + EventLocation location = 위치(10); + for (int i = 0; i < 51; i++) { + 노출영상(location); + } + + // 0 이하·미지정은 기본 20, 상한 초과는 50 으로 자른다 — 범위 밖은 에러가 아니다. + assertThat(피드(location, null, 0).videos()).hasSize(20); + assertThat(피드(location, null, 1).videos()).hasSize(1); + assertThat(피드(location, null, 100).videos()).hasSize(50); + } + + @Test + @DisplayName("행사 영상 삭제는 점령 롤백과 함께 피드와 카운트에서 사라진다") + void 행사_영상_삭제는_점령_롤백과_함께_피드와_카운트에서_사라진다() { + EventLocation location = 위치(18); + Video video = 노출영상(location); + String gridId = location.getRepresentativeGridId(); + videoRepository.upsertUserGrid(userId, gridId, video.getId()); + em.flush(); + assertThat(피드(location, null, 0).videos()).hasSize(1); + + // 삭제는 차단하지 않는다 — soft delete 라 event_videos 행은 남고 노출 술어가 걷어낸다. + videoService.deleteVideo(userId, video.getId()); + + em.flush(); + assertThat(피드(location, null, 0).videos()).isEmpty(); + assertThat(eventVideoRepository.countVisibleByLocationIds(List.of(location.getId()))).isEmpty(); + assertThat(((Number) em.createNativeQuery( + "SELECT count(*) FROM user_grids WHERE user_id = :u AND grid_id = :g") + .setParameter("u", userId) + .setParameter("g", gridId) + .getSingleResult()).longValue()).isZero(); + } + + @Test + @DisplayName("미노출 예정 회차의 피드는 13404, 회차 밖 위치는 13405다") + void 미노출_예정_회차의_피드는_13404_회차_밖_위치는_13405다() { + EventLocation 미노출 = 위치(NOW.plusDays(20), NOW.plusDays(21), 11); + EventLocation 정상 = 위치(12); + + assertThatThrownBy(() -> 피드(미노출, null, 0)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_NOT_FOUND); + assertThatThrownBy(() -> service() + .getLocationVideos(정상.getOccurrence().getId(), 정상.getId() + 10_000, null, 0)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_LOCATION_NOT_FOUND); + } + } + + @Nested + @DisplayName("영상 상세 (API 3)") + class 상세조회 { + + private long 조회수(Long videoId) { + em.flush(); + return ((Number) em.createNativeQuery("SELECT view_count FROM videos WHERE id = :i") + .setParameter("i", videoId) + .getSingleResult()).longValue(); + } + + /** 격자 한 칸을 덮는 구역 — zones 는 로컬에서 비어 있어 표시명 단언에는 자체 시드가 필요하다. */ + private void 구역(String gridId) { + GridIndex index = GridEncoder.decode(gridId); + zoneRepository.save(Zone.builder() + .zoneKey(fixtures.키("zone")) + .name("테스트구역") + .minGridY((int) index.gridY()) + .maxGridY((int) index.gridY()) + .minGridX((int) index.gridX()) + .maxGridX((int) index.gridX()) + .priority(0) + .build()); + } + + // 검증: FR-EVENT-08 + @Test + @DisplayName("상세 응답에 대표 격자 표시명 재료와 작성자 닉네임이 담긴다") + void 상세_응답에_대표_격자_표시명_재료와_작성자_닉네임이_담긴다() { + EventLocation location = 위치(13); + 구역(location.getRepresentativeGridId()); + Video video = 노출영상(location); + em.flush(); + + EventVideoDetailResponseDto detail = service().getVideoDetail(video.getId(), userId); + + assertThat(detail.occurrenceId()).isEqualTo(location.getOccurrence().getId()); + assertThat(detail.occurrenceStatus()).isEqualTo("LIVE"); + assertThat(detail.locationId()).isEqualTo(location.getId()); + assertThat(detail.locationName()).isEqualTo("행사 위치"); + assertThat(detail.representativeGridId()).isEqualTo(location.getRepresentativeGridId()); + assertThat(detail.zoneName()).isEqualTo("테스트구역"); + assertThat(detail.zoneCell()).isEqualTo("A-1"); + assertThat(detail.playbackUrl()).isEqualTo(PRESIGNED); + assertThat(detail.durationSec()).isEqualTo((short) 10); + assertThat(detail.uploaderNickname()).isEqualTo("행사업로더"); + assertThat(detail.interactionLocked()).isFalse(); + } + + // 검증: FR-EVENT-10 + @Test + @DisplayName("종료 시각부터 interactionLocked 가 참이다") + void 종료_시각부터_interactionLocked가_참이다() { + EventLocation location = 위치(NOW.minusDays(2), NOW, 14); // 종료 정각이 지금이다 + Video video = 노출영상(location); + em.flush(); + + assertThat(service().getVideoDetail(video.getId(), userId).interactionLocked()).isTrue(); + // 종료 1초 전이면 아직 열려 있다 — 경계는 종료 정각 포함의 반개구간이다. + assertThat(service(NOW.minusSeconds(1)).getVideoDetail(video.getId(), userId).interactionLocked()) + .isFalse(); + } + + @Test + @DisplayName("행사 영상이 아닌 videoId 상세는 13406으로 실패한다") + void 행사_영상이_아닌_videoId_상세는_13406으로_실패한다() { + EventLocation location = 위치(15); + String gridId = location.getRepresentativeGridId(); + GridIndex index = GridEncoder.decode(gridId); + GridPoint center = GridEncoder.center(gridId); + videoRepository.upsertGrid(gridId, index.gridY(), index.gridX(), center.lat(), center.lon(), + GeoSupport.bboxWkt(gridId)); + Video 일반영상 = videoRepository.save(Video.create(userId, gridId, + "videos/original/" + UUID.randomUUID() + ".mp4", GeoSupport.toPoint(center.lat(), center.lon()), + (short) 10, NOW, Visibility.PUBLIC)); + 일반영상.markReady("videos/encoded/plain.mp4", "thumb/plain.jpg"); + em.flush(); + + assertThatThrownBy(() -> service().getVideoDetail(일반영상.getId(), userId)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_VIDEO_NOT_FOUND); + } + + @Test + @DisplayName("노출 게이트 밖 영상의 상세는 13406으로 실패한다 (소유자 본인 포함)") + void 노출_게이트_밖_영상의_상세는_13406으로_실패한다() { + EventLocation location = 위치(16); + Video 인코딩중 = 영상(location, Visibility.PUBLIC, userId); + em.flush(); + + assertThatThrownBy(() -> service().getVideoDetail(인코딩중.getId(), userId)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_VIDEO_NOT_FOUND); + } + + @Test + @DisplayName("비소유자 상세 재생은 view_count 를 증가시킨다 (비로그인 포함, 소유자는 미증가)") + void 비소유자_상세_재생은_view_count를_증가시킨다() { + EventLocation location = 위치(17); + Video video = 노출영상(location); + em.flush(); + + service().getVideoDetail(video.getId(), userId); // 소유자 본인 — 증가하지 않는다 + assertThat(조회수(video.getId())).isZero(); + + service().getVideoDetail(video.getId(), 사용자()); // 타인 + service().getVideoDetail(video.getId(), null); // 비로그인 + assertThat(조회수(video.getId())).isEqualTo(2); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/service/EventVideoUploadConcurrencyTest.java b/src/test/java/com/msg/fillmap/event/service/EventVideoUploadConcurrencyTest.java new file mode 100644 index 00000000..04064ac0 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/service/EventVideoUploadConcurrencyTest.java @@ -0,0 +1,307 @@ +package com.msg.fillmap.event.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; + +import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.EventTestFixtures; +import com.msg.fillmap.event.dto.EventVideoUploadRequestDto; +import com.msg.fillmap.event.dto.EventVideoUploadResponseDto; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; +import com.msg.fillmap.hotzone.service.HotScoreCommandService; +import com.msg.fillmap.streak.service.StreakCommandService; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.repository.UserRepository; +import com.msg.fillmap.video.repository.VideoRepository; +import com.msg.fillmap.video.service.VideoEncodingService; +import com.msg.fillmap.video.service.VideoService; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; +import com.msg.fillmap.zone.service.ZoneNameQueryService; + +/** + * 행사 업로드의 위치 행 잠금 계약 (MSG-440 §도메인 잠금 계약, MSG-438 동시성 계약의 업로드 측). + * 업로드가 대표 격자를 읽기 전에 event_locations 행을 잠그고, 잠금 획득 후 회차를 다시 읽는지 본다 — + * 둘 다 "잠금 대기 사이에 바뀐 값"이 관측 대상이라 실제 커밋과 두 커넥션이 필요하다. + * + *

결정적 인터리빙 프로토콜(EventSeederDriftGuardTest·MSG-243 재사용): 메인 트랜잭션이 위치 행을 잠근 + * 채 값을 바꾸고 → 업로드 스레드를 띄워 그 잠금에 블록시키고 → 제3의 관측(pg_blocking_pids)으로 실제 + * 대기를 확인한 뒤에야 커밋한다. 고정 sleep 이 없어 경쟁 지점 도달이 보장된다. + * + *

격리(공유 로컬 DB): 서해 먼바다 격자와 합성 자연키(msg440-race-*)만 쓰고, 실제 커밋을 하므로 + * {@code @AfterEach} 에서 FK 역순 대상 지정 삭제로 정리한다. 업로드의 부수 서비스(뱃지·스트릭·핫스코어· + * 인코딩)와 S3 는 목이라 정리 대상이 videos·user_grids·행사 3층으로 좁혀진다. + */ +@SpringBootTest +@DisplayName("행사 업로드 동시성 — 위치 행 잠금과 회차 재독 (실 PostgreSQL)") +class EventVideoUploadConcurrencyTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 6, 1, 0, 0); + + /** 서해 먼바다 기준 격자 — 다른 행사 테스트(125.0·125.2·125.3)와 겹치지 않는 대역. */ + private static final GridIndex 바다 = GridEncoder.decode(GridEncoder.encode(34.0, 125.4)); + + private static final int 관측_시도 = 200; + private static final long 관측_간격_MS = 50L; + private static final long JOIN_TIMEOUT_MS = 15_000L; + + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + + @Autowired + private EventVideoRepository eventVideoRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private VideoService videoService; + + // 아래 셋은 피드·상세 경로의 협력자다 — 업로드는 쓰지 않지만 서비스 생성에 필요해 받아 둔다. + @Autowired + private VideoRepository videoRepository; + + @Autowired + private ThumbnailUrlPresigner thumbnailUrlPresigner; + + @Autowired + private ZoneNameQueryService zoneNameQueryService; + + @Autowired + private EntityManager em; + + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private VideoEncodingService videoEncodingService; + + @MockitoBean + private HotScoreCommandService hotScoreCommandService; + + @MockitoBean + private BadgeAwardService badgeAwardService; + + @MockitoBean + private StreakCommandService streakCommandService; + + private TransactionTemplate tx; + private EventTestFixtures fixtures; + private Long userId; + + @BeforeEach + void setUp() { + given(s3Client.headObject(any(HeadObjectRequest.class))).willReturn(HeadObjectResponse.builder().build()); + given(badgeAwardService.awardUploadBadges(anyLong())).willReturn(List.of()); + given(badgeAwardService.awardCollectionBadges(anyLong(), anyString())).willReturn(List.of()); + given(streakCommandService.recordUpload(anyLong())).willReturn(List.of()); + tx = new TransactionTemplate(transactionManager); + fixtures = new EventTestFixtures(seriesRepository, occurrenceRepository, locationRepository, + locationGridRepository); + userId = tx.execute(status -> userRepository.save(User.createLocalUser( + "msg440-race-" + UUID.randomUUID() + "@example.com", "hash", "경쟁업로더")).getId()); + } + + /** 실제로 커밋하므로 합성 행을 FK 역순으로 대상 지정 삭제한다 (실데이터 불가침). */ + @AfterEach + void 정리() { + tx.executeWithoutResult(status -> List.of( + """ + DELETE FROM event_videos WHERE event_location_id IN + (SELECT id FROM event_locations WHERE location_key LIKE 'msg440-race-%')""", + "DELETE FROM user_grids WHERE user_id = " + userId, + "DELETE FROM region_stats WHERE user_id = " + userId, + "DELETE FROM videos WHERE user_id = " + userId, + """ + DELETE FROM event_location_grids WHERE event_location_id IN + (SELECT id FROM event_locations WHERE location_key LIKE 'msg440-race-%')""", + "DELETE FROM event_locations WHERE location_key LIKE 'msg440-race-%'", + "DELETE FROM event_occurrences WHERE occurrence_key LIKE 'msg440-race-%'", + "DELETE FROM event_series WHERE series_key LIKE 'msg440-race-%'", + "DELETE FROM grids WHERE grid_id LIKE '" + 바다.gridY() + "_%'", + "DELETE FROM users WHERE id = " + userId + ).forEach(sql -> em.createNativeQuery(sql).executeUpdate())); + } + + private EventVideoService service() { + return new EventVideoServiceImpl(occurrenceRepository, locationRepository, eventVideoRepository, + videoService, videoRepository, thumbnailUrlPresigner, zoneNameQueryService, em, + Clock.fixed(NOW.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); + } + + private String 격자(long dx) { + return 바다.gridY() + "_" + (바다.gridX() + dx); + } + + private EventVideoUploadRequestDto 요청() { + return new EventVideoUploadRequestDto( + "videos/pending/" + userId + "/" + UUID.randomUUID() + ".mp4", (short) 10, NOW); + } + + /** + * 업로드 한 건을 자기 트랜잭션에서 돌리는 스레드. 트랜잭션 진입 직후 자기 백엔드 pid 를 알린다 — + * 이후 서비스 호출이 같은 커넥션을 쓰므로, 메인이 "내가 저 pid 를 막고 있는가"로 대기를 관측할 수 있다. + */ + private Thread 업로드_스레드(long occurrenceId, long locationId, AtomicReference pid, + AtomicReference 응답, AtomicReference 예외) { + return new Thread(() -> { + try { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + pid.set(백엔드_pid()); + 응답.set(service().upload(userId, occurrenceId, locationId, 요청())); + }); + } catch (Exception e) { + 예외.set(e); + } + }, "msg440-race-upload"); + } + + // 검증: FR-EVENT-08 + @Test + @DisplayName("대표 격자 재계산 시더와 행사 업로드가 경쟁해도 드리프트가 없다") + void 대표_격자_재계산_시더와_행사_업로드가_경쟁해도_드리프트가_없다() throws InterruptedException { + Long[] ids = tx.execute(status -> { + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), 격자(0)); + EventLocation location = fixtures.위치(occurrence, "행사 위치", 격자(0), 격자(1)); + return new Long[] {occurrence.getId(), location.getId()}; + }); + AtomicReference 업로드_pid = new AtomicReference<>(); + AtomicReference 응답 = new AtomicReference<>(); + AtomicReference 예외 = new AtomicReference<>(); + Thread 업로드 = 업로드_스레드(ids[0], ids[1], 업로드_pid, 응답, 예외); + + Boolean 업로드가_대기했다 = tx.execute(status -> { + // 시더 측 이행: 같은 행을 잠그고 대표 격자를 옆 칸으로 옮긴다 (영역 안이라 지연 FK 통과). + EventLocation locked = locationRepository.findWithLockById(ids[1]).orElseThrow(); + locked.update(locked.getOccurrence(), locked.getName(), locked.getType(), locked.getOperatingHours(), + locked.getDisplayOrder(), 격자(1)); + locationRepository.flush(); + 업로드.start(); + return 내가_막고_있기를_기다린다(업로드_pid); + }); + 업로드.join(JOIN_TIMEOUT_MS); + + assertThat(업로드가_대기했다).as("업로드가 위치 행 잠금에서 실제로 대기해야 한다").isTrue(); + assertThat(예외.get()).isNull(); + // 낡은 대표 격자에 붙었다면 위치의 피드와 격자 노출이 갈라진다 — 갱신된 값을 읽어야 한다. + assertThat(응답.get().gridId()).isEqualTo(격자(1)); + } + + // 검증: FR-EVENT-10 + @Test + @DisplayName("창 판정은 위치 잠금 후 재독한 회차 값으로 한다") + void 창_판정은_위치_잠금_후_재독한_회차_값으로_한다() throws InterruptedException { + Long[] ids = tx.execute(status -> { + // 처음엔 진행 중이라 업로드가 열려 있다 — 업로드 스레드는 이 값을 잠금 전에 읽는다. + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), 격자(2)); + EventLocation location = fixtures.위치(occurrence, "행사 위치", 격자(2)); + return new Long[] {occurrence.getId(), location.getId()}; + }); + AtomicReference 업로드_pid = new AtomicReference<>(); + AtomicReference 응답 = new AtomicReference<>(); + AtomicReference 예외 = new AtomicReference<>(); + Thread 업로드 = 업로드_스레드(ids[0], ids[1], 업로드_pid, 응답, 예외); + + Boolean 업로드가_대기했다 = tx.execute(status -> { + // 리시드 이행: 위치 행을 잠근 채 회차 일정을 "아직 시작 전"으로 바꾼다. + EventLocation locked = locationRepository.findWithLockById(ids[1]).orElseThrow(); + EventOccurrence occurrence = occurrenceRepository.findById(ids[0]).orElseThrow(); + occurrence.update(occurrence.getSeries(), occurrence.getTitle(), occurrence.getCityName(), + NOW.plusDays(10), NOW.plusDays(11), occurrence.getMinGridY(), occurrence.getMaxGridY(), + occurrence.getMinGridX(), occurrence.getMaxGridX()); + occurrenceRepository.flush(); + assertThat(locked.getId()).isEqualTo(ids[1]); + 업로드.start(); + return 내가_막고_있기를_기다린다(업로드_pid); + }); + 업로드.join(JOIN_TIMEOUT_MS); + + assertThat(업로드가_대기했다).as("업로드가 위치 행 잠금에서 실제로 대기해야 한다").isTrue(); + // 잠금 전에 읽은 낡은 회차(진행 중)로 판정하면 업로드가 성공해 버린다 — 재독하면 시작 전이라 13410. + assertThat(응답.get()).isNull(); + assertThat(예외.get()) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_UPLOAD_NOT_STARTED); + } + + /** + * 상대 세션이 내가 잡은 잠금 때문에 멈춰 있는지 확인한다. 전역 대기 수를 세면 무관한 세션의 + * 대기를 경쟁 지점 도달로 오인하므로 pg_blocking_pids 로 좁힌다 (EventSeederDriftGuardTest 선례). + */ + private boolean 내가_막고_있기를_기다린다(AtomicReference 상대_pid) { + for (int attempt = 0; attempt < 관측_시도; attempt++) { + Integer pid = 상대_pid.get(); + if (pid != null && 내가_막고_있는가(pid)) { + return true; + } + sleep(); + } + return false; + } + + private boolean 내가_막고_있는가(int 상대_pid) { + return (Boolean) em.createNativeQuery("SELECT pg_backend_pid() = ANY(pg_blocking_pids(:pid))") + .setParameter("pid", 상대_pid) + .getSingleResult(); + } + + private int 백엔드_pid() { + return ((Number) em.createNativeQuery("SELECT pg_backend_pid()").getSingleResult()).intValue(); + } + + private void sleep() { + try { + Thread.sleep(관측_간격_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/service/EventVideoUploadServiceTest.java b/src/test/java/com/msg/fillmap/event/service/EventVideoUploadServiceTest.java new file mode 100644 index 00000000..4e9f0aba --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/service/EventVideoUploadServiceTest.java @@ -0,0 +1,450 @@ +package com.msg.fillmap.event.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; + +import com.msg.fillmap.badge.dto.EarnedBadgeResponseDto; +import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.EventTestFixtures; +import com.msg.fillmap.event.dto.EventVideoUploadRequestDto; +import com.msg.fillmap.event.dto.EventVideoUploadResponseDto; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; +import com.msg.fillmap.hotzone.service.HotScoreCommandService; +import com.msg.fillmap.mission.service.MissionAwardService; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.repository.UserRepository; +import com.msg.fillmap.video.exception.VideoErrorCode; +import com.msg.fillmap.video.repository.VideoRepository; +import com.msg.fillmap.video.service.VideoEncodingService; +import com.msg.fillmap.video.service.VideoService; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; +import com.msg.fillmap.zone.service.ZoneNameQueryService; + +/** + * 행사 영상 업로드 확정 (MSG-440 §API 1, 실 PostgreSQL). 이 티켓의 업로드는 새 파이프라인이 아니라 기존 + * 확정 코어를 지정 격자로 태우는 것이라, 검증 대상이 대부분 DB 부수효과(videos·user_grids·event_videos)와 + * 서버 시각 판정이다 — 모킹으로는 잡히지 않아 실 스택으로 돈다. 고정 클럭만 주입한다. + *

+ * 격리(공유 로컬 DB): 서해 먼바다 격자와 합성 자연키(msg440-*)만 쓰고 {@code @Transactional} 롤백으로 + * 정리한다. S3·인코딩은 목이다 — 확정 경로가 실제 버킷과 인코딩 워커로 나가지 않게 한다. + */ +@SpringBootTest +@Transactional +@DisplayName("행사 영상 업로드 확정 (실 PostgreSQL)") +class EventVideoUploadServiceTest { + + /** 고정 서버 시각 — 업로드 창 판정의 기준. UTC 저장 컬럼과 같은 축이라 존 스큐가 없다. */ + private static final LocalDateTime NOW = LocalDateTime.of(2026, 6, 1, 0, 0); + + /** 서해 먼바다 기준 격자 — 육상 실데이터·다른 행사 테스트(125.0·125.2)와 겹치지 않는다. */ + private static final GridIndex 바다 = GridEncoder.decode(GridEncoder.encode(34.0, 125.3)); + + private static final EarnedBadgeResponseDto 업로드뱃지 = + new EarnedBadgeResponseDto(1L, "UPLOAD_1", "첫 기록", "첫 영상을 올렸어요", null); + private static final EarnedBadgeResponseDto 수집뱃지 = + new EarnedBadgeResponseDto(2L, "EXPLORER_1", "첫 발자국", "첫 격자를 수집했어요", null); + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventVideoRepository eventVideoRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private VideoService videoService; + + // 아래 셋은 피드·상세 경로의 협력자다 — 업로드는 쓰지 않지만 서비스 생성에 필요해 받아 둔다. + @Autowired + private VideoRepository videoRepository; + + @Autowired + private ThumbnailUrlPresigner thumbnailUrlPresigner; + + @Autowired + private ZoneNameQueryService zoneNameQueryService; + + @Autowired + private EntityManager em; + + @MockitoBean + private S3Client s3Client; + + /** 확정 커밋 후 인코딩 제출이 실제 워커로 나가지 않게 막는다 — 이 테스트의 관심사는 확정까지다. */ + @MockitoBean + private VideoEncodingService videoEncodingService; + + /** 행사 업로드가 미션 판정을 호출하지 않는다는 제외 계약(MSG-438)을 관측하기 위한 목이다. */ + @MockitoBean + private MissionAwardService missionAwardService; + + /** afterCommit 훅 등록 대상 — 테스트 트랜잭션은 커밋되지 않아 콜백을 직접 실행해 관측한다. */ + @MockitoBean + private HotScoreCommandService hotScoreCommandService; + + /** 뱃지 규칙 자체는 badge 도메인 테스트 몫이라, 여기서는 응답 동봉 배선만 고정값으로 본다. */ + @MockitoBean + private BadgeAwardService badgeAwardService; + + private EventTestFixtures fixtures; + private Long userId; + + @BeforeEach + void setUp() { + given(s3Client.headObject(any(HeadObjectRequest.class))).willReturn(HeadObjectResponse.builder().build()); + given(badgeAwardService.awardUploadBadges(anyLong())).willReturn(List.of()); + given(badgeAwardService.awardCollectionBadges(anyLong(), anyString())).willReturn(List.of()); + fixtures = new EventTestFixtures(seriesRepository, occurrenceRepository, locationRepository, + locationGridRepository); + userId = 사용자("uploader"); + } + + private Long 사용자(String prefix) { + return userRepository.save(User.createLocalUser( + "msg440-" + prefix + "-" + UUID.randomUUID() + "@example.com", "hash", "행사업로더")).getId(); + } + + private EventVideoService service() { + return service(NOW); + } + + private EventVideoService service(LocalDateTime now) { + return new EventVideoServiceImpl(occurrenceRepository, locationRepository, eventVideoRepository, + videoService, videoRepository, thumbnailUrlPresigner, zoneNameQueryService, em, + Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); + } + + private String 격자(long dy, long dx) { + return (바다.gridY() + dy) + "_" + (바다.gridX() + dx); + } + + private String 키(Long owner) { + return "videos/pending/" + owner + "/" + UUID.randomUUID() + ".mp4"; + } + + private EventVideoUploadRequestDto 요청(String s3Key) { + return new EventVideoUploadRequestDto(s3Key, (short) 10, NOW); + } + + /** 진행 중인 회차 하나와 그 위치 하나 — 창 판정이 관심사가 아닌 테스트의 기본 픽스처다. */ + private EventLocation 진행중_위치(long dy, long dx) { + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), 격자(dy, dx)); + return fixtures.위치(occurrence, "행사 위치", 격자(dy, dx), 격자(dy, dx + 1)); + } + + private EventVideoUploadResponseDto 업로드(EventLocation location, String s3Key) { + return 업로드(service(), location, s3Key, userId); + } + + private EventVideoUploadResponseDto 업로드(EventVideoService service, EventLocation location, String s3Key, + long uploader) { + return service.upload(uploader, location.getOccurrence().getId(), location.getId(), 요청(s3Key)); + } + + private long 카운트(String sql, String key, Object value) { + return ((Number) em.createNativeQuery(sql).setParameter("v", value).getSingleResult()).longValue(); + } + + private long 격자영상수(String gridId) { + em.flush(); + return 카운트("SELECT count(*) FROM videos WHERE grid_id = :v", "v", gridId); + } + + @Nested + @DisplayName("대표 격자 저장과 점령") + class 저장 { + + // 검증: FR-EVENT-08 + @Test + @DisplayName("행사 업로드는 서버가 지정한 대표 격자 하나에만 영상을 만든다") + void 행사_업로드는_서버가_지정한_대표_격자_하나에만_영상을_만든다() { + EventLocation location = 진행중_위치(0, 0); + + EventVideoUploadResponseDto response = 업로드(location, 키(userId)); + + assertThat(response.gridId()).isEqualTo(location.getRepresentativeGridId()); + assertThat(response.processingStatus()).isEqualTo("UPLOADED"); + assertThat(격자영상수(location.getRepresentativeGridId())).isEqualTo(1); + // 영역의 다른 격자에는 복제되지 않는다 — 영상은 대표 격자에만 붙는다. + assertThat(격자영상수(격자(0, 1))).isZero(); + assertThat(eventVideoRepository.findById(response.videoId())).isPresent() + .get() + .satisfies(link -> assertThat(link.getLocation().getId()).isEqualTo(location.getId())); + } + + // 검증: FR-EVENT-09 + @Test + @DisplayName("행사 업로드도 일반 업로드처럼 점령을 만든다") + void 행사_업로드도_일반_업로드처럼_점령을_만든다() { + EventLocation location = 진행중_위치(1, 0); + + EventVideoUploadResponseDto response = 업로드(location, 키(userId)); + + em.flush(); + assertThat(response.occupied()).isTrue(); + assertThat(카운트("SELECT count(*) FROM user_grids WHERE grid_id = :v", "v", + location.getRepresentativeGridId())).isEqualTo(1); + } + + @Test + @DisplayName("이미 점령한 대표 격자에 또 올리면 video_count 만 증가한다") + void 이미_점령한_대표_격자에_또_올리면_video_count만_증가한다() { + EventLocation location = 진행중_위치(2, 0); + 업로드(location, 키(userId)); + + EventVideoUploadResponseDto second = 업로드(location, 키(userId)); + + em.flush(); + assertThat(second.occupied()).isFalse(); + assertThat(카운트("SELECT video_count FROM user_grids WHERE grid_id = :v", "v", + location.getRepresentativeGridId())).isEqualTo(2); + } + + @Test + @DisplayName("행사 업로드의 공개범위는 PUBLIC 으로 고정된다") + void 행사_업로드의_공개범위는_PUBLIC으로_고정된다() { + EventLocation location = 진행중_위치(3, 0); + + EventVideoUploadResponseDto response = 업로드(location, 키(userId)); + + em.flush(); + assertThat(em.createNativeQuery("SELECT visibility FROM videos WHERE id = :v") + .setParameter("v", response.videoId()) + .getSingleResult()).isEqualTo("PUBLIC"); + } + + @Test + @DisplayName("행사 업로드 응답에 획득 뱃지가 동봉된다") + void 행사_업로드_응답에_획득_뱃지가_동봉된다() { + given(badgeAwardService.awardUploadBadges(userId)).willReturn(List.of(업로드뱃지)); + given(badgeAwardService.awardCollectionBadges(anyLong(), anyString())).willReturn(List.of(수집뱃지)); + EventLocation location = 진행중_위치(4, 0); + + EventVideoUploadResponseDto response = 업로드(location, 키(userId)); + + // 첫 점령이라 업로드 수 뱃지와 수집 뱃지가 함께 실린다. + assertThat(response.newBadges()).containsExactly(업로드뱃지, 수집뱃지); + } + + @Test + @DisplayName("행사 업로드는 미션 판정을 호출하지 않는다") + void 행사_업로드는_미션_판정을_호출하지_않는다() { + EventLocation location = 진행중_위치(5, 0); + + 업로드(location, 키(userId)); + + then(missionAwardService).should(never()).awardOnUpload(anyLong(), anyString()); + } + + @Test + @DisplayName("행사 업로드는 스트릭과 핫스코어 훅을 그대로 탄다") + void 행사_업로드는_스트릭과_핫스코어_훅을_그대로_탄다() { + EventLocation location = 진행중_위치(6, 0); + + 업로드(location, 키(userId)); + + em.flush(); + assertThat(카운트("SELECT count(*) FROM streaks WHERE user_id = :v", "v", userId)).isEqualTo(1); + // 핫스코어는 커밋 후 훅이라 테스트 트랜잭션에서는 저절로 돌지 않는다 — 등록된 동기화를 직접 + // 실행해 "대표 격자로 등록됐다"를 관측한다(롤백 시 유령 증분 방지 성질은 그대로다). + TransactionSynchronizationManager.getSynchronizations() + .forEach(TransactionSynchronization::afterCommit); + then(hotScoreCommandService).should().recordUpload(location.getRepresentativeGridId()); + } + } + + @Nested + @DisplayName("멱등 재시도") + class 멱등 { + + @Test + @DisplayName("같은 s3Key 재시도는 중복 레코드 없이 저장 행 기준 성공을 돌려준다") + void 같은_s3Key_재시도는_중복_레코드_없이_저장_행_기준_성공을_돌려준다() { + given(badgeAwardService.awardUploadBadges(userId)).willReturn(List.of(업로드뱃지)); + EventLocation location = 진행중_위치(7, 0); + String s3Key = 키(userId); + EventVideoUploadResponseDto first = 업로드(location, s3Key); + + EventVideoUploadResponseDto retry = 업로드(location, s3Key); + + assertThat(retry.videoId()).isEqualTo(first.videoId()); + assertThat(retry.gridId()).isEqualTo(first.gridId()); + assertThat(retry.processingStatus()).isEqualTo("UPLOADED"); + // 첫 응답 전용 필드는 재시도에서 비운다 — 복원할 저장 컬럼이 없다. + assertThat(retry.occupied()).isFalse(); + assertThat(retry.newBadges()).isEmpty(); + assertThat(격자영상수(location.getRepresentativeGridId())).isEqualTo(1); + } + + @Test + @DisplayName("마감 후 도착한 재시도도 커밋된 업로드면 성공을 돌려준다") + void 마감_후_도착한_재시도도_커밋된_업로드면_성공을_돌려준다() { + EventLocation location = 진행중_위치(8, 0); + String s3Key = 키(userId); + EventVideoUploadResponseDto first = 업로드(location, s3Key); + + // 마감(종료 + 30일) 한참 뒤에 도착한 재시도 — 멱등 판정이 창 판정보다 앞선다(Codex P2). + EventVideoUploadResponseDto retry = + 업로드(service(NOW.plusDays(60)), location, s3Key, userId); + + assertThat(retry.videoId()).isEqualTo(first.videoId()); + assertThat(격자영상수(location.getRepresentativeGridId())).isEqualTo(1); + } + + @Test + @DisplayName("다른 사용자가 같은 s3Key 로 확정하면 3401로 거절된다") + void 다른_사용자가_같은_s3Key로_확정하면_3401로_거절된다() { + EventLocation location = 진행중_위치(9, 0); + String s3Key = 키(userId); + 업로드(location, s3Key); + long 다른사용자 = 사용자("thief"); + + assertThatThrownBy(() -> 업로드(service(), location, s3Key, 다른사용자)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(VideoErrorCode.INVALID_S3_KEY); + } + + @Test + @DisplayName("다른 위치로 같은 s3Key 를 재시도하면 3401로 거절된다") + void 다른_위치로_같은_s3Key를_재시도하면_3401로_거절된다() { + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), + 격자(10, 0)); + EventLocation first = fixtures.위치(occurrence, "위치 A", 격자(10, 0)); + EventLocation second = fixtures.위치(occurrence, "위치 B", 격자(10, 1)); + String s3Key = 키(userId); + 업로드(first, s3Key); + + assertThatThrownBy(() -> 업로드(second, s3Key)) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(VideoErrorCode.INVALID_S3_KEY); + } + } + + @Nested + @DisplayName("업로드 창과 존재 은닉") + class 창판정 { + + private EventLocation 위치(LocalDateTime startsAt, LocalDateTime endsAt, long dy) { + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), startsAt, endsAt, 격자(dy, 0)); + return fixtures.위치(occurrence, "행사 위치", 격자(dy, 0)); + } + + // 검증: FR-EVENT-10 + @Test + @DisplayName("노출 중인 예정 회차 업로드는 13410으로 거절된다") + void 노출_중인_예정_회차_업로드는_13410으로_거절된다() { + // 시작 10일 뒤 = 노출은 시작됐지만(2주 전부터) 아직 시작 전이다. + EventLocation location = 위치(NOW.plusDays(10), NOW.plusDays(11), 11); + + assertThatThrownBy(() -> 업로드(location, 키(userId))) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_UPLOAD_NOT_STARTED); + } + + @Test + @DisplayName("시작 정각 업로드는 성공한다") + void 시작_정각_업로드는_성공한다() { + EventLocation location = 위치(NOW, NOW.plusDays(1), 12); + + assertThat(업로드(location, 키(userId)).videoId()).isNotNull(); + } + + // 검증: FR-EVENT-10 + @Test + @DisplayName("마감 정각부터의 신규 업로드는 13409로 거절된다") + void 마감_정각부터의_신규_업로드는_13409로_거절된다() { + // 마감 = 종료 + 30일. 종료를 정확히 30일 전으로 두면 지금이 마감 정각이다. + EventLocation location = 위치(NOW.minusDays(31), NOW.minusDays(30), 13); + + assertThatThrownBy(() -> 업로드(location, 키(userId))) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_UPLOAD_CLOSED); + } + + @Test + @DisplayName("마감 직전 업로드는 성공한다") + void 마감_직전_업로드는_성공한다() { + EventLocation location = 위치(NOW.minusDays(31), NOW.minusDays(30).plusMinutes(1), 14); + + assertThat(업로드(location, 키(userId)).videoId()).isNotNull(); + } + + @Test + @DisplayName("미노출 예정 회차 업로드는 13404로 실패한다 (존재 은닉이 창 판정보다 선행)") + void 미노출_예정_회차_업로드는_13404로_실패한다() { + // 시작 20일 뒤 = 노출 시작(시작 2주 전)도 아직이라 회차의 존재 자체를 숨긴다. + EventLocation location = 위치(NOW.plusDays(20), NOW.plusDays(21), 15); + + assertThatThrownBy(() -> 업로드(location, 키(userId))) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_NOT_FOUND); + } + + @Test + @DisplayName("위치가 경로의 회차에 속하지 않으면 13405로 실패한다") + void 위치가_경로의_회차에_속하지_않으면_13405로_실패한다() { + EventLocation location = 진행중_위치(16, 0); + EventOccurrence 다른회차 = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), 격자(17, 0)); + + assertThatThrownBy(() -> service().upload(userId, 다른회차.getId(), location.getId(), 요청(키(userId)))) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(EventErrorCode.EVENT_LOCATION_NOT_FOUND); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/support/EventVideoCursorTest.java b/src/test/java/com/msg/fillmap/event/support/EventVideoCursorTest.java new file mode 100644 index 00000000..bfa0a88f --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/support/EventVideoCursorTest.java @@ -0,0 +1,63 @@ +package com.msg.fillmap.event.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 위치별 영상 피드 keyset 커서 (MSG-440 §API 2). 성분은 (locationId, createdAt, id) 3개고 와이어 포맷은 + * Base64URL("{locationId}:{createdAtEpochMicros}:{id}") 다. 여기서는 라운드트립 대칭·형식 위반 예외· + * 시각 성분의 저장 가능 범위 판정을 본다 — 위치 바인딩 거부(13402)는 서비스 몫이라 피드 테스트가 본다. + */ +@DisplayName("EventVideoCursor 인코드·디코드") +class EventVideoCursorTest { + + private static final long LOCATION_ID = 77L; + + private static String base64Url(String raw) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("인코딩한 커서를 디코드하면 원래 성분이 그대로다 (마이크로초 정밀도 포함)") + void 인코딩한_커서를_디코드하면_원래_성분이_그대로다() { + LocalDateTime createdAt = LocalDateTime.of(2026, 10, 6, 12, 30, 5, 123_456_000); + + String token = EventVideoCursor.encode(LOCATION_ID, createdAt, 1039L); + + assertThat(EventVideoCursor.decode(token)) + .isEqualTo(new EventVideoCursor(LOCATION_ID, createdAt, 1039L)); + } + + @Test + @DisplayName("성분 수가 다른 토큰은 예외다") + void 성분_수가_다른_토큰은_예외다() { + assertThatThrownBy(() -> EventVideoCursor.decode(base64Url("77:1791290000000000"))) + .isInstanceOf(IllegalArgumentException.class); + // 격자 목록의 4성분 커서(VideoCursor)도 이 커서로는 무효다 — 성분 수와 뜻이 다르다. + assertThatThrownBy(() -> EventVideoCursor.decode(base64Url("19422_9582:5:1791290000000000:1039"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("정수가 아닌 성분과 깨진 Base64 는 예외다") + void 정수가_아닌_성분과_깨진_Base64는_예외다() { + assertThatThrownBy(() -> EventVideoCursor.decode(base64Url("77:a:1039"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EventVideoCursor.decode("!!!not-base64!!!")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("저장 가능 범위를 벗어난 시각 성분은 예외다 — 쿼리 바인딩 단계 500 을 디코드에서 막는다") + void 저장_가능_범위를_벗어난_시각_성분은_예외다() { + assertThatThrownBy(() -> EventVideoCursor.decode(base64Url("77:" + Long.MIN_VALUE + ":1039"))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/msg/fillmap/video/service/EventVideoVisibilityIntegrationTest.java b/src/test/java/com/msg/fillmap/video/service/EventVideoVisibilityIntegrationTest.java new file mode 100644 index 00000000..2a7d4a22 --- /dev/null +++ b/src/test/java/com/msg/fillmap/video/service/EventVideoVisibilityIntegrationTest.java @@ -0,0 +1,129 @@ +package com.msg.fillmap.video.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.msg.fillmap.event.EventTestFixtures; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.entity.EventVideo; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; +import com.msg.fillmap.grid.GridEncoder.GridPoint; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.repository.UserRepository; +import com.msg.fillmap.video.dto.VideoVisibilityRequestDto; +import com.msg.fillmap.video.entity.Video; +import com.msg.fillmap.video.entity.Visibility; +import com.msg.fillmap.video.exception.VideoErrorCode; +import com.msg.fillmap.video.repository.VideoRepository; +import com.msg.fillmap.video.support.GeoSupport; + +/** + * 행사 영상 공개범위 고정 (MSG-440 §기존 API 변경 1). 행사 영상은 PUBLIC 전제라 전환 API 가 거부하고, + * 같은 API 가 일반 영상에는 그대로 동작해야 한다 — 차단이 event_videos 행 유무에만 걸린다는 확인이다. + *

+ * 격리(공유 로컬 DB): 서해 먼바다 격자와 합성 자연키만 쓰고 {@code @Transactional} 롤백으로 정리한다. + */ +@SpringBootTest +@Transactional +@DisplayName("행사 영상 공개범위 전환 차단 (실 PostgreSQL)") +class EventVideoVisibilityIntegrationTest { + + /** 서해 먼바다 기준 격자 — 육상 실데이터와 겹치지 않는다. */ + private static final GridIndex 바다 = GridEncoder.decode(GridEncoder.encode(34.0, 125.2)); + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 6, 1, 0, 0); + + @Autowired + private VideoService videoService; + + @Autowired + private VideoRepository videoRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EventVideoRepository eventVideoRepository; + + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + + private EventTestFixtures fixtures; + private Long userId; + + @BeforeEach + void setUp() { + fixtures = new EventTestFixtures(seriesRepository, occurrenceRepository, locationRepository, + locationGridRepository); + userId = userRepository.save(User.createLocalUser( + "msg440-vis-" + UUID.randomUUID() + "@example.com", "hash", "행사업로더")).getId(); + } + + private String 격자(long dx) { + return 바다.gridY() + "_" + (바다.gridX() + dx); + } + + private Video 영상(String gridId) { + GridIndex index = GridEncoder.decode(gridId); + GridPoint center = GridEncoder.center(gridId); + videoRepository.upsertGrid(gridId, index.gridY(), index.gridX(), center.lat(), center.lon(), + GeoSupport.bboxWkt(gridId)); + return videoRepository.save(Video.create(userId, gridId, "videos/original/" + UUID.randomUUID() + ".mp4", + GeoSupport.toPoint(center.lat(), center.lon()), (short) 10, NOW, Visibility.PUBLIC)); + } + + // 검증: FR-EVENT-09 + @Test + @DisplayName("행사 영상의 공개범위 전환은 3427로 거절된다") + void 행사_영상의_공개범위_전환은_3427로_거절된다() { + String gridId = 격자(0); + EventOccurrence occurrence = fixtures.회차(fixtures.시리즈(), NOW.minusDays(1), NOW.plusDays(1), gridId); + EventLocation location = fixtures.위치(occurrence, "행사 위치", gridId); + Video video = 영상(gridId); + eventVideoRepository.save(new EventVideo(video, location, occurrence.getId())); + + assertThatThrownBy(() -> videoService.setVisibility( + userId, video.getId(), new VideoVisibilityRequestDto("PRIVATE"))) + .isInstanceOf(ApiException.class) + .extracting(e -> ((ApiException) e).getErrorCode()) + .isEqualTo(VideoErrorCode.EVENT_VIDEO_VISIBILITY_FIXED); + assertThat(video.getVisibility()).isEqualTo(Visibility.PUBLIC); + } + + @Test + @DisplayName("일반 영상의 공개범위 전환은 기존대로 동작한다") + void 일반_영상의_공개범위_전환은_기존대로_동작한다() { + Video video = 영상(격자(1)); + + videoService.setVisibility(userId, video.getId(), new VideoVisibilityRequestDto("PRIVATE")); + + assertThat(video.getVisibility()).isEqualTo(Visibility.PRIVATE); + } +} diff --git a/src/test/java/com/msg/fillmap/video/service/MissionVideoListServiceTest.java b/src/test/java/com/msg/fillmap/video/service/MissionVideoListServiceTest.java index 0c48adc1..494af158 100644 --- a/src/test/java/com/msg/fillmap/video/service/MissionVideoListServiceTest.java +++ b/src/test/java/com/msg/fillmap/video/service/MissionVideoListServiceTest.java @@ -36,6 +36,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; @@ -84,7 +85,8 @@ presigner, mock(S3Client.class), properties, mock(RegionStatsCommandService.class), new ThumbnailUrlPresigner(presigner, properties), mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), - () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class)); + () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class), + mock(EventVideoRepository.class)); // 기본값 = 요청한 작성자가 전부 살아 있다. 닉네임이 없으면 항목이 응답에서 빠지므로(MSG-371), // 닉네임을 안 보는 테스트도 이 기본 스텁이 있어야 항목을 받는다. 탈퇴 경합 테스트가 덮어쓴다. diff --git a/src/test/java/com/msg/fillmap/video/service/VideoEncodingTriggerTest.java b/src/test/java/com/msg/fillmap/video/service/VideoEncodingTriggerTest.java index f930705d..afd87819 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoEncodingTriggerTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoEncodingTriggerTest.java @@ -21,6 +21,7 @@ import org.springframework.core.task.TaskRejectedException; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -81,7 +82,7 @@ class VideoEncodingTriggerTest { mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), - mock(VideoProcessingMetrics.class)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); VideoUploadRequestDto request = new VideoUploadRequestDto( "videos/pending/1/x.mp4", 37.5445, 127.0560, (short) 10, LocalDateTime.now(ZoneOffset.UTC), "PRIVATE"); @@ -122,7 +123,7 @@ class VideoEncodingTriggerTest { mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), - new VideoProcessingMetrics(meterRegistry)); + new VideoProcessingMetrics(meterRegistry), mock(EventVideoRepository.class)); service.saveVideo(USER_ID, new VideoUploadRequestDto( "videos/pending/1/x.mp4", 37.5445, 127.0560, (short) 10, LocalDateTime.now(ZoneOffset.UTC), "PRIVATE")); diff --git a/src/test/java/com/msg/fillmap/video/service/VideoGlobalCoverServiceTest.java b/src/test/java/com/msg/fillmap/video/service/VideoGlobalCoverServiceTest.java index 0a98490b..997013e2 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoGlobalCoverServiceTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoGlobalCoverServiceTest.java @@ -26,6 +26,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -70,7 +71,8 @@ presigner, mock(S3Client.class), properties, mock(RegionStatsCommandService.class), new ThumbnailUrlPresigner(presigner, properties), mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), - () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class)); + () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class), + mock(EventVideoRepository.class)); // 기본값 = 작성자가 살아 있다. 닉네임이 빈손이면 대표 없음(null)이 되므로(MSG-371), 닉네임을 안 보는 // 테스트도 이 기본 스텁이 있어야 대표를 받는다. 탈퇴 경합을 보는 테스트는 given 으로 덮어쓴다. diff --git a/src/test/java/com/msg/fillmap/video/service/VideoGlobalListServiceTest.java b/src/test/java/com/msg/fillmap/video/service/VideoGlobalListServiceTest.java index 6ab37691..338cd9bc 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoGlobalListServiceTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoGlobalListServiceTest.java @@ -30,6 +30,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; @@ -78,7 +79,8 @@ presigner, mock(S3Client.class), properties, mock(RegionStatsCommandService.class), new ThumbnailUrlPresigner(presigner, properties), mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), - () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class)); + () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class), + mock(EventVideoRepository.class)); // 기본값 = 요청한 작성자가 전부 살아 있다(탈퇴 CASCADE 로 정상 경로엔 빈손이 없다). 닉네임이 없으면 // 항목이 응답에서 빠지므로(MSG-371), 닉네임을 안 보는 테스트도 이 기본 스텁이 있어야 항목을 받는다. diff --git a/src/test/java/com/msg/fillmap/video/service/VideoGridQueryServiceTest.java b/src/test/java/com/msg/fillmap/video/service/VideoGridQueryServiceTest.java index dba2afb9..0b16d458 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoGridQueryServiceTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoGridQueryServiceTest.java @@ -19,6 +19,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -64,7 +65,8 @@ presigner, mock(S3Client.class), properties, mock(RegionStatsCommandService.class), new ThumbnailUrlPresigner(presigner, properties), mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), - () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class)); + () -> new ZoneNameResolver(List.of()), mock(VideoProcessingMetrics.class), + mock(EventVideoRepository.class)); } private void givenVideos(Video... videos) { diff --git a/src/test/java/com/msg/fillmap/video/service/VideoHotScoreTest.java b/src/test/java/com/msg/fillmap/video/service/VideoHotScoreTest.java index aa2b2263..68798515 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoHotScoreTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoHotScoreTest.java @@ -26,6 +26,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -80,7 +81,7 @@ void setUp() { mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, hotScoreCommandService, mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), - mock(VideoProcessingMetrics.class)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); } private void givenSavedVideo() { diff --git a/src/test/java/com/msg/fillmap/video/service/VideoHourlyUploadServiceTest.java b/src/test/java/com/msg/fillmap/video/service/VideoHourlyUploadServiceTest.java index ef52da40..a861fa88 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoHourlyUploadServiceTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoHourlyUploadServiceTest.java @@ -16,6 +16,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -54,7 +55,7 @@ videoRepository, mock(VideoEncodingService.class), mock(VideoStatusWriter.class) mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), - mock(ZoneNameQueryService.class), mock(VideoProcessingMetrics.class)); + mock(ZoneNameQueryService.class), mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); } /** 저장 존 인자가 어긋나면 이 스텁이 매치되지 않아 테스트가 깨진다 — UTC 배선 검증을 겸한다. */ diff --git a/src/test/java/com/msg/fillmap/video/service/VideoPlaybackServiceTest.java b/src/test/java/com/msg/fillmap/video/service/VideoPlaybackServiceTest.java index 97c74bb2..4a629723 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoPlaybackServiceTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoPlaybackServiceTest.java @@ -25,6 +25,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; @@ -89,7 +90,7 @@ presigner, mock(S3Client.class), properties, mock(RegionStatsCommandService.class), thumbnailUrlPresigner, mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), friendshipQueryService, () -> new ZoneNameResolver(List.of(SEOMYEON)), - mock(VideoProcessingMetrics.class)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); // 기본값 = 작성자가 살아 있다. 닉네임이 빈손이면 404 로 수렴하므로(MSG-371), 닉네임을 안 보는 // 테스트도 이 기본 스텁이 있어야 응답까지 간다. 탈퇴 경합을 보는 테스트는 given 으로 덮어쓴다. diff --git a/src/test/java/com/msg/fillmap/video/service/VideoPresignTest.java b/src/test/java/com/msg/fillmap/video/service/VideoPresignTest.java index f1c823aa..94c2a0cb 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoPresignTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoPresignTest.java @@ -20,6 +20,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; @@ -64,7 +65,7 @@ presigner, mock(software.amazon.awssdk.services.s3.S3Client.class), properties, mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), mock(MissionAwardService.class), mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), - mock(VideoProcessingMetrics.class)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); } // 검증: FR-VIDEO-02 diff --git a/src/test/java/com/msg/fillmap/video/service/VideoProcessingMetricsTest.java b/src/test/java/com/msg/fillmap/video/service/VideoProcessingMetricsTest.java index c317eaae..bb9ab21c 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoProcessingMetricsTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoProcessingMetricsTest.java @@ -22,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -219,6 +220,7 @@ private VideoService videoService() { new AwsProperties("ap-northeast-2", new AwsProperties.S3("fillmap-video-dev", 104857600L, 2147483648L)), mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, mock(HotScoreCommandService.class), - mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), metrics); + mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), metrics, + mock(EventVideoRepository.class)); } } diff --git a/src/test/java/com/msg/fillmap/video/service/VideoRecordedAtValidationTest.java b/src/test/java/com/msg/fillmap/video/service/VideoRecordedAtValidationTest.java index 9ec1b8ca..94a157cd 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoRecordedAtValidationTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoRecordedAtValidationTest.java @@ -24,6 +24,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; @@ -80,7 +81,8 @@ void setUp() { mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), - mock(VideoProcessingMetrics.class), Clock.fixed(FIXED_NOW, ZoneOffset.UTC)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class), + Clock.fixed(FIXED_NOW, ZoneOffset.UTC)); } @Test diff --git a/src/test/java/com/msg/fillmap/video/service/VideoS3CleanupTest.java b/src/test/java/com/msg/fillmap/video/service/VideoS3CleanupTest.java index 7d57f09a..f379efea 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoS3CleanupTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoS3CleanupTest.java @@ -37,6 +37,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -98,7 +99,7 @@ void setUp() { mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of()), - mock(VideoProcessingMetrics.class)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); } @AfterEach diff --git a/src/test/java/com/msg/fillmap/video/service/VideoUploadGridNameTest.java b/src/test/java/com/msg/fillmap/video/service/VideoUploadGridNameTest.java index 15bf2a18..7f65eaec 100644 --- a/src/test/java/com/msg/fillmap/video/service/VideoUploadGridNameTest.java +++ b/src/test/java/com/msg/fillmap/video/service/VideoUploadGridNameTest.java @@ -23,6 +23,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import com.msg.fillmap.badge.service.BadgeAwardService; +import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.friend.service.FriendshipQueryService; import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.hotzone.service.HotScoreCommandService; @@ -89,7 +90,7 @@ private VideoService serviceWithZones(Zone... zones) { mock(RegionStatsCommandService.class), mock(ThumbnailUrlPresigner.class), mock(BadgeAwardService.class), mock(StreakCommandService.class), missionAwardService, mock(HotScoreCommandService.class), mock(FriendshipQueryService.class), () -> new ZoneNameResolver(List.of(zones)), - mock(VideoProcessingMetrics.class)); + mock(VideoProcessingMetrics.class), mock(EventVideoRepository.class)); } private VideoUploadRequestDto uploadRequest() {