diff --git a/.claude/docs/status.md b/.claude/docs/status.md index 80a1bc94..008bf650 100644 --- a/.claude/docs/status.md +++ b/.claude/docs/status.md @@ -125,6 +125,7 @@ - MSG-501: 행사 운영자 콘솔 승인 이벤트 목록 — `GET /api/org/events`(`controller/OrgEventController` 신규, city·name 선택 파라미터), `EventQueryService.getApprovedEvents(String, String)` 추가(+Impl — 후보는 `findByEndsAtAfter(now)` 파생 쿼리 하나로 DB에서 거르고(D-1 `ends_at > now`, `statusAt` 미경유), `visible_from` 미적용(D-2 — 콘솔은 노출 전 예정 회차도 노출), 필터·검색(대소문자 무시 부분 일치)·시도별 집계(전체 기준 — totalCount·cityCounts는 필터 무관)·정렬(startsAt→id, 건수 내림차순→이름)은 자바에서. placeLabel은 기존 `findWithOccurrenceByOccurrenceIdIn` 재사용(fetch join, displayOrder→id 정렬의 첫 행 — 승인 흐름산 name 채움 규칙은 MSG-500 접점, 스펙 명시). 쿼리 회차 1 + 위치 일괄 1 고정(빈 후보면 1회). DTO 3종 `Org*ResponseDto`(placeLabel은 required+`@Schema(nullable=true)` 병기 — ResponseSchemaNullabilityTest 규약). SecurityConfig·Flyway·developCode·계약 인터페이스 전부 무변경(/api/org/** matcher가 MSG-496에서 기확정). 테스트 25건(서비스 17·컨트롤러 5 — ORG 200 실경로 포함·리포지토리 3), 전부 `// 검증: FR-EVENT-16`. reviewer 1R(P2 용어 1건 수정)·Codex 1R 수렴(지적 0) - MSG-498: 행사 운영자 행사 등재 신청 — `event.submission` 하위 패키지 신설(V49 `event_submissions`·`_locations`·`_location_rects`·`_status_history` 4테이블 + 신청 번호 전역 시퀀스, 이력 테이블 CHECK가 REJECTED 행에만 사유 강제 — 반려 사유 저장 원천은 이력 하나 D-3). API 5개 전부 `/api/org/event-submissions`(SecurityConfig 무변경 — 496 matcher·497 mustChange 인터셉터 자동 커버, 인가 테스트는 전제 확인 4건): presign(프로필 미러링, jpg·png 10MB, webp 불허) · 제출 POST(유형 FESTIVAL·POPUP 2종, 유형 밖 필드 13439 거부, 81칸은 사각형 **합집합** 판정(전개 전 단일 사각형 long 선판정으로 OOM 차단), 격자 인덱스 상한 상수는 `RepresentativeGridResolver.GRID_INDEX_UPPER_EXCLUSIVE` 승격 공유(시더 사본 제거), 대표 격자 서버 계산, 신청 번호 `FM-{KST연도}-{4자리}` native `nextval` 채번) · 내 목록(상태별 건수는 별도 쿼리 없이 로드 목록에서 groupingBy 파생 — 스냅숏 불일치 차단, 페이지네이션 도입 시 되돌릴 지점) · 상세(존재 은닉 13430 — 모든 조회가 `findByIdAndUserId` 단일 경로, rejection은 이력 최신 행) · 재제출 PATCH(REJECTED 한정, 유형 불변 전체 교체, `@Modifying(clearAutomatically=true)` 조건부 UPDATE 술어에 userId 포함 → 0행이면 소유 조회 분기(부재 13430/비REJECTED 13434), 재로드는 UPDATE 뒤, imageS3Key 생략=유지). 이미지 확정: HEAD 실측(0바이트 13436)→original 복사(uuid 시도마다 신규 — 객체 공유 차단)→롤백 보상 삭제는 **복사 호출 앞 등록**(응답 유실 고아 차단)→커밋 후 pending 삭제(재확정 차단)·STATUS_UNKNOWN엔 미삭제. 엔티티는 다대일 양방향(자식 `@ManyToOne` 주인 + mappedBy 컬렉션 + 편의 메서드, D-12 — 단방향 왕복 실측 후 주류 관행으로 확정). 리스트 요소 null은 type-use `@NotNull`로 공통 400. errorCode 13430~13439(EventErrorCode 확장). 테스트 58건(단위 25·통합 33, 동시 재제출·복사 응답 유실·게이트 커버 포함). reviewer 3R·Codex 3R 수렴(채택 3건 반영, 멱등 키는 스펙 3회+구현 3회 기각 — 콘솔 수동 제출 수용). 심사·승인(APPROVED 쓰기)은 MSG-500 몫, pending S3 라이프사이클 규칙은 배포 전 런북 +- MSG-502: 행사 운영자 이벤트 참여형 신청 — **신규 엔드포인트 0개**. MSG-498의 신청 API 5개가 `EVENT` 유형을 받도록 확장(**V50** `event_submissions` +`parent_event_occurrence_id`(FK `event_occurrences(id)`, 인덱스 없음 — 부모로 거는 조회가 없다)·+`participation_method` TEXT, `chk_event_sub_type` 재정의(FESTIVAL·POPUP·**EVENT**), `chk_event_sub_parent` 신설 — `(type = 'EVENT') = (parent_event_occurrence_id IS NOT NULL)` 로 유형과 부모의 짝을 DB가 강제(MSG-500 approve가 이 불변식 위에서 부모를 역참조)). `EventSubmissionType.EVENT` 추가(주석 필수 — glossary 이벤트 카테고리이고 `MissionType.EVENT`(축제 미션)와 이름만 같고 반대편). 엔티티는 부모를 **연관 없이 id 보관**(읽는 값이 상세의 title 하나, userId 선례)이고 `submit(...)` 인자로만 세팅 — 유형처럼 재제출로 바뀌지 않는다(D-3). 검증은 기존 `validateForm` 을 **삼분기 재작성**: "값이 있다 == 그 유형이다" 등식 3개(programDescription·operatingHours·participationMethod) + 부모 존재 등식이라 유형이 늘어도 줄 하나씩 는다. **부모만 폼이 아니라 별도 인자**(재제출 DTO에 부모 필드가 없다) — 제출은 요청 본문 값, 재제출은 **저장값**이 같은 메서드로 들어가 종료만 재검증된다. 대표 위치 정확히 1곳(D-2)은 `buildLocations` 시그니처를 건드리지 않고 **검증 순서로** 해결(validateForm 이 먼저 도는 자리에서 2곳 이상 13439, 0곳은 손대지 않고 기존 13431로 흘려보냄). `EventErrorCode` +2 — 13440 PARENT_EVENT_NOT_FOUND(404, **존재 은닉 없음** — 승인 이벤트 목록이 ORG 전원에게 같은 전량을 보여줘 회차 존재는 비밀이 아니다)·13441 PARENT_EVENT_CLOSED(409, `endsAt <= now` — MSG-501 노출 조건 `endsAt > now`의 정확한 여집합이라 종료 정각에도 목록과 신청이 갈리지 않는다, Clock 주입 판정). 상세 응답 +2(`participationMethod`·`parentEvent`{occurrenceId,name} — name 원천은 `event_occurrences.title`, 다른 유형은 둘 다 null). 기간과 부모 기간의 교차 검증 없음(D-4 — 부모는 UTC 시각, 신청은 KST 날짜 라벨). 격자 겹침 검사도 없음(D-7 — 승인 시점에 어차피 재판정, MSG-500 한 곳). SecurityConfig·계약 인터페이스 무변경. 신규 테스트 22건(목+고정 Clock 15 — 종료 정각 경계 포함 / 실 DB 7 — 저장 실측·상세 부모 이름·PATCH 본문의 부모 변경 시도 무시·재제출 시점 부모 종료 13441), 전부 `// 검증: FR-EVENT-17`. 테스트 픽스처의 고정 날짜를 상대값으로 교체(498분 포함 — 그날이 지나면 성공 경로가 13433으로 먼저 깨지는 시한폭탄 4곳 해제). reviewer 전수 통과·Codex 2R 수렴(픽스처 상대화 채택, 구버전 롤백 비호환 지적은 프리런치라 기각) - MSG-457: `EventQueryService.getLocationsBulk(Collection)` + `LocationPoint(name, representativeGridId)` 신설 — 여러 회차 위치 일괄 조회(JPQL fetch join 1쿼리, 미노출 회차 키 생략 = 단건 13404 은닉과 동결, 정렬 회차 id→display_order→id로 단건 계약 승계). route 후보 수집이 소비(회차별 getLocations 반복의 N+1 차단), `getLocations` 시그니처 불변 ### `region` (Owner A · 구현 강정민) — 🟡 부분 diff --git a/docs/prd/event-submission.md b/docs/prd/event-submission.md index a1fa987d..3c214122 100644 --- a/docs/prd/event-submission.md +++ b/docs/prd/event-submission.md @@ -5,6 +5,9 @@ > 개정: 2026-08-28 v2.1. 세 번째 등록 유형을 이벤트 참여형으로 재편했다(승인 이벤트 선택 후 > 참여 신청, MSG-501·MSG-502 분리. MSG-498은 지역축제·팝업스토어 한정). 근거는 지라 MSG-498 > 코멘트, 사용자 승인. 개정 범위는 유형 재편 반영에 한정한다 +> 개정: 2026-08-29 v2.2. 미해결 질문 1(참여형 하위 실체의 명칭, 지역축제·팝업 승인분의 노출 +> 형태)을 사용자 확정으로 해소해 결정 기록으로 옮겼고, FR-8에 이벤트 참여형의 대표 위치 1곳 +> 예외를 더했다. 개정 범위는 그 해소 반영에 한정한다 ## 1. 문제 상황 @@ -77,7 +80,7 @@ JWT role 클레임 또는 로그인 응답의 role 필드로 한다. 서버 몫 | ID | 요구사항 | 우선순위 | |----|----------|----------| | FR-7 | 행사 운영자는 등록 유형(지역축제, 팝업스토어, 이벤트 참여형) 중 하나를 골라 행사를 신청할 수 있다. 유형마다 기본 정보 항목이 다르다(아래 표). 이벤트 참여형은 독립 행사를 새로 만드는 신청이 아니라 승인된 이벤트를 골라 참여를 신청하는 구조다(2026-08-28 v2.1 재편, 표 아래 설명) | Must | -| FR-8 | 행사 위치는 주소가 아니라 지도 위 격자 사각형 영역으로 지정한다. 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있다. 형식은 기존 행사 시드의 areaRects[^2]와 같다. 위치에 이름을 붙이지 않고, 순번과 지역 라벨(격자 표시명 재료)로만 식별한다(피그마 댓글 #102) | Must | +| FR-8 | 행사 위치는 주소가 아니라 지도 위 격자 사각형 영역으로 지정한다. 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있다. 이벤트 참여형은 예외로 대표 위치 1곳만 받는다(ver 14 [4C] 시안, MSG-502 결정 D-2, v2.2). 형식은 기존 행사 시드의 areaRects[^2]와 같다. 위치에 이름을 붙이지 않고, 순번과 지역 라벨(격자 표시명 재료)로만 식별한다(피그마 댓글 #102) | Must | | FR-24 | 위치 하나가 쓸 수 있는 격자는 사각형들을 합쳐 최대 81칸(9×9 유래)이다. 초과는 제출에서 막는다(피그마 댓글 #106·#109 "1km로 합의", v2 시안 반영 완료. 미해결 질문 2 해소) | Must | | FR-9 | 위치의 대표 격자[^3]는 신청자가 정하지 않고 서버가 계산한다 | Must | | FR-10 | 신청이 접수되면 심사 중 상태가 되고 신청 번호가 부여된다 | Must | @@ -93,7 +96,7 @@ JWT role 클레임 또는 로그인 응답의 role 필드로 한다. 서버 몫 |------|------| | 지역축제 | 축제명, 주최 기관, 축제 기간, 주요 프로그램, 축제 소개, 대표 이미지 | | 팝업스토어 | 팝업명, 브랜드/운영사, 운영 기간, 운영 시간, 팝업 소개, 대표 이미지 | -| 이벤트 참여형 | 이름(참여로 생기는 하위 실체의 명칭은 용어 정리 중), 운영 주체, 공개 기간, 참여 방식, 소개, 커버 이미지. 입력 전에 시·도 칩과 승인 이벤트 목록 모달([3-1] 신규)에서 부모 이벤트를 고른다 | +| 이벤트 참여형 | 이름(참여로 생기는 실체는 별도 명칭 없이 부모 이벤트의 행사 위치로 흡수, v2.2 확정), 운영 주체, 공개 기간, 참여 방식, 소개, 커버 이미지. 입력 전에 시·도 칩과 승인 이벤트 목록 모달([3-1] 신규)에서 부모 이벤트를 고른다 | 이미지는 권장 16:9, JPG 또는 PNG, 최대 10MB(시안 문구). 주요 프로그램 같은 서술 항목은 구조화하지 않고 자유 문자열로 받되 최소 10자를 요구한다(피그마 댓글 #100 "String으로 받고 @@ -232,17 +235,27 @@ admin/events 2개(목록·unpublish), auth/password 4개(status·change·reset-r ## 8. 미해결 질문 2026-08-28 개정에서 초안의 6건 중 3건이 ver 14 시안과 피그마 댓글로 해소됐고, 같은 날 -작성자(강정민) 확인으로 2건이 더 닫혔다. 남은 미확정은 2건이며 각각 게이트가 걸린 티켓을 -명시했다. - -- [ ] **승인 행사 노출 방식과 이벤트 참여형의 세부** (시안 열린 판단 1, 미확정 유지): - 2026-08-28 v2.1 디자인 개정으로 입력 구조는 잡혔다. 세 번째 유형은 독립 행사를 새로 만드는 - 게 아니라 시·도와 승인 이벤트를 모달([3-1] 신규)에서 고른 뒤 그 아래 참여를 신청하는 - 구조다. 지역축제와 팝업스토어 승인분의 노출 형태(이벤트 편입 / 칩 / 규모별 분기)와 참여로 - 이벤트 아래 생기는 하위 실체의 명칭은 여전히 열려 있다. **MSG-502(참여 신청)와 - MSG-500(approve) 스펙 착수 전 확정이 게이트다.** v2.1 재편으로 이벤트 참여형이 빠진 - MSG-498에는 이 게이트가 걸리지 않는다. 확정되면 SRS FR-EVENT-01의 등재 기준 서술을 함께 - 고친다 +작성자(강정민) 확인으로 2건이 더 닫혔다. 2026-08-29 v2.2에서 노출 방식과 참여형 세부 1건이 +사용자 확정으로 추가로 닫혀, 남은 미확정은 1건(승인 후 일정 수정 정책, 유보 확정)이다. + +- [x] ~~승인 행사 노출 방식과 이벤트 참여형의 세부~~ → **두 갈래 모두 확정** (2026-08-29 + 사용자(정민) 확정, v2.2): ① **참여형 하위 실체는 별도 명칭 없이 "행사 위치"로 흡수한다.** + 참여 신청이 승인되면 부모 이벤트 아래 행사 위치(`event_locations`)로 반영되고, 이름, 운영 + 주체, 소개, 공개 기간, 참여 방식 같은 추가 속성은 위치에 붙는 부가 정보다. 새 용어를 + 만들지 않는다(MSG-503이 미정으로 남긴 하위 실체 명칭이 이렇게 닫혔다. glossary "이벤트" + 항목의 미정 표기 갱신은 별도 회부). 저장과 반영의 몫은 갈린다: MSG-502는 이 속성들을 신청 + 행에 저장하는 데까지이고, 승인 반영 시 `event_locations` 확장 컬럼(별도 마이그레이션)과 + 신청에서 위치로의 복사 매핑, 사용자 조회 노출 계약은 MSG-500이 정의한다(지금 + `event_locations`에는 이름 컬럼뿐이라 이 확장 없이는 운영 주체, 소개, 공개 기간, 참여 + 방식이 승인 후 노출될 경로가 없다). + ② **지역축제와 팝업스토어 승인분은 기존 미션 칩으로 편입한다.** 승인된 + 지역축제 신청은 축제 미션(`MissionType.EVENT`), 팝업스토어 신청은 팝업 미션 + (`MissionType.POPUP`)으로 등재되어 지도 홈의 기존 축제·팝업 칩 목록에 추가된다. 이벤트 + 공간(구 행사방)이나 새 칩, 규모별 분기는 만들지 않고, 이벤트 공간은 이벤트 카테고리 + (지역축제와 팝업 제외) 전용으로 유지된다(MSG-503 용어 체계와 정합). 구현은 MSG-500 + approve 몫이고, "확정되면 SRS FR-EVENT-01의 등재 기준 서술을 함께 고친다"는 후속도 + MSG-500 레인 몫이다. 이 확정으로 MSG-502(참여 신청)와 MSG-500(approve)의 스펙 착수 + 게이트가 풀렸다 - [ ] **승인 후 일정 수정 정책** (시안 열린 판단 3): 2026-08-28 유보 확정. MVP에서는 구현하지 않고 시안대로 "운영팀에 문의"로 안내한다. A 재신청 재심사 / B 관리자 직접 수정 / C 항목별 분리 중 후속 결정이 나면 그때 티켓을 새로 판다 diff --git a/docs/rtm.md b/docs/rtm.md index 8518acd2..ca9b5a1f 100644 --- a/docs/rtm.md +++ b/docs/rtm.md @@ -5,7 +5,7 @@ **병합 충돌이 나면 손으로 합치지 말고 재생성한다.** 어느 쪽이든 골라 충돌만 없앤 뒤(`git checkout --ours docs/rtm.md` 등) 스크립트를 다시 돌려 그 결과를 커밋한다. 이 표는 두 원천에서 계산되는 값이라 양쪽 diff를 섞으면 어느 쪽과도 다른 상태가 된다. -요약: FR 288건 중 테스트 연결 256건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 25건, 성격상 테스트 비대상 7건 별도) +요약: FR 289건 중 테스트 연결 257건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 25건, 성격상 테스트 비대상 7건 별도) | 요구사항 ID | SRS 상태 | 검증 테스트 | |---|---|---| @@ -203,6 +203,7 @@ | FR-EVENT-14 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest | | FR-EVENT-15 | 계획 | (없음) | | FR-EVENT-16 | 구현됨 | EventOccurrenceRepositoryTest, OrgEventControllerTest, OrgEventQueryServiceTest | +| FR-EVENT-17 | 구현됨 | EventSubmissionControllerTest, EventSubmissionValidationTest | | FR-ROUTE-01 | 진행 중 | AnonymousReadAccessHttpTest, RouteCandidateCollectorTest, RouteControllerTest, RouteIntentClientTest, RouteRecommendServiceTest | | FR-ROUTE-02 | 진행 중 | RouteCandidateCollectorTest, RouteRecommendServiceTest | | FR-ROUTE-03 | 구현됨 | EventQueryServiceTest, PlaceSearchServiceTest, RouteCandidateCollectorTest, RouteOrderPlannerTest | diff --git a/docs/spec/MSG-502.md b/docs/spec/MSG-502.md new file mode 100644 index 00000000..63e4d88e --- /dev/null +++ b/docs/spec/MSG-502.md @@ -0,0 +1,502 @@ +# MSG-502: 행사 운영자가 승인 이벤트를 골라 참여를 신청한다 (이벤트 참여형 신청) + +**Owner**: B (event 도메인은 Owner B다. CLAUDE.md 협업 원칙의 `com.msg.fillmap.event.*` 배정 +기준이고, Owner A 계약 인터페이스는 MSG-498이 이미 쓰는 표시명 재료 계약을 시그니처 변경 없이 +소비만 한다) + +> 요구사항 정본: `docs/prd/event-submission.md` (상태 검토됨, 2026-08-29 v2.2 개정 동반. 이 +> 브랜치가 미해결 질문 1의 해소를 v2.2로 싣는다). 이 스펙은 FR-7의 세 번째 등록 유형(이벤트 +> 참여형)의 신청 제출 서버 몫을 다룬다. 부모를 고르는 모달 조회(FR-26)는 MSG-501이 완료했고, +> 이 티켓은 그 응답의 `occurrenceId`를 신청 payload로 받는 쪽이다. 참여로 생기는 실체는 별도 +> 명칭 없이 부모 이벤트 아래 **행사 위치**다(v2.2 확정 1). + +## 개요 + +행사 운영자[^1]가 콘솔에서 등록 유형 "이벤트"를 골라, MSG-501 모달에서 고른 승인 이벤트 +회차[^2] 아래 참여를 신청할 수 있게 한다. 신규 엔드포인트는 없다. MSG-498이 만든 신청 API +5개(presign, 제출, 내 목록, 상세, 재제출)가 `EVENT` 유형을 받도록 확장하고, 제출 후 +흐름(심사 중, 반려, 재제출)은 기존 코드를 그대로 탄다. 새로 생기는 것은 부모 이벤트 검증 +2건(에러 코드 13440, 13441), 유형별 항목 분기, Flyway 1건(V50)이다. + +## 배경 · 목표 + +- v2.1 재편으로 세 번째 등록 유형은 독립 행사 신설이 아니라 승인 이벤트 아래 참여를 붙이는 + 구조가 됐다. 목록(MSG-501)은 있는데 그 선택을 받아 저장하는 쪽이 없다. +- 목표: ORG 토큰으로 이벤트 참여 신청을 제출부터 반려 후 재제출까지 완주할 수 있는 상태. + 승인 시 부모 회차 아래 행사 위치로 반영하는 쪽은 MSG-500이다. + +## 성공 기준 + +1. `type=EVENT` 신청을 제출하면 부모 회차 참조와 참여 방식이 저장되고, 기존과 같은 심사 중 + 상태와 `FM-2026-XXXX`꼴 신청 번호가 온다(FR-10 재사용). +2. 부모 검증이 동작한다. 없는 회차는 13440, 종료된(유예, 아카이브 포함) 회차는 13441이다. +3. EVENT 신청의 위치는 정확히 1곳(대표 위치)이고, 사각형 영역과 81칸 상한, 대표 격자[^3] + 서버 계산은 기존 규칙 그대로다(FR-8의 v2.2 예외, FR-9, FR-24 재사용). +4. 유형별 항목 분기가 동작한다. `participationMethod`와 `parentOccurrenceId`는 EVENT 전용 + 필수이고, 반대 유형 필드가 실려 오면 13439다(기존 분기 규칙의 확장). +5. 반려 재제출에서 유형과 부모는 불변이고, 재제출 시점에 부모가 종료돼 있으면 13441이다. +6. 기존 FESTIVAL, POPUP 신청 흐름이 회귀 없이 그대로 동작한다. +7. 아래 테스트 시나리오가 전부 green이다. + +## API 명세 + +신규 엔드포인트 없음. SecurityConfig 무변경(`/api/org/**` matcher가 기존 경로를 이미 덮는다, +MSG-496). 아래는 MSG-498 계약에 대한 **증분**만 적고, 적지 않은 것은 전부 MSG-498 스펙 +그대로다. + +### 1. `POST /api/org/event-submissions/image/presigned-url` : 변경 없음 + +커버 이미지도 대표 이미지와 같은 규격(jpg, jpeg, png, 10MB)이라 그대로 재사용한다. 시안의 +"16:9 권장"은 권장 문구라 서버 검증을 두지 않는다(비율 검증은 이미지 디코딩이 필요한데 얻는 +것이 안내 문구 하나다. FE 안내 몫). + +### 2. `POST /api/org/event-submissions` : EVENT 유형 추가 + +`EventSubmissionCreateRequestDto`에 필드 2개가 늘어난다. + +```json +{ + "type": "EVENT", + "parentOccurrenceId": 1, + "title": "필맵 스탬프 투어", + "organizerName": "필맵 파트너스", + "startsOn": "2026-10-06", + "endsOn": "2026-10-15", + "participationMethod": "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다", + "description": "부산국제영화제 기간에 영화의전당 일대에서 진행하는 스탬프 투어입니다", + "imageS3Key": "event-submissions/pending/12/3f2b….jpg", + "locations": [ + { + "areaRects": [ + { "minGridY": 16859, "maxGridY": 16861, "minGridX": 11509, "maxGridX": 11515 } + ] + } + ] +} +``` + +- `type`: `EVENT` 값이 추가된다(기존 FESTIVAL, POPUP 유지). +- `parentOccurrenceId`: **EVENT 전용 필수.** MSG-501 목록 응답의 `occurrenceId`를 그대로 + 넣는다. 제출은 이 본문 값으로 부모의 존재(13440)와 종료(13441)를 검증한다. FESTIVAL, + POPUP에 실려 오면 13439다(반대 유형 필드 거부 규칙 그대로). +- 유형별 항목 매핑(ver 14 [4C] 실측): `title`은 이름, `organizerName`은 운영 주체, + `startsOn`과 `endsOn`은 공개 기간, `description`은 소개, `imageS3Key`는 커버 이미지다. + 시안의 구 라벨 "행사방 이름"은 쓰지 않는다("행사방"은 MSG-503으로 폐기된 용어. 화면 라벨은 + "이름"이고 저장은 기존 `title` 컬럼 재사용이라 코드 심볼 변경도 없다). +- `participationMethod`(참여 방식): **EVENT 전용 필수.** 주요 프로그램과 같은 서술 항목이라 + String `@Size(min = 10, max = 2000)`이다(피그마 #100 규칙과 D-11 상한 준용). FESTIVAL, + POPUP에 실려 오면 13439. +- `programDescription`, `operatingHours`가 EVENT에 실려 오면 13439(자기 유형이 아닌 필드). +- `locations`: **EVENT는 정확히 1개다**(결정 D-2, PRD FR-8의 v2.2 예외). 0개는 기존 공통 + 규칙대로 13431, 2개 이상은 13439(유형별 구조 위반)다. 위치 내부(사각형 배열, 81칸 합집합, + 인덱스 범위, 대표 격자 계산)는 기존 규칙을 전부 재사용한다. +- 응답은 기존 `EventSubmissionSubmitResponseDto` 그대로다. + +### 3. `GET /api/org/event-submissions/my` : 변경 없음 + +EVENT 신청도 같은 목록에 실린다. 항목의 `type`이 `EVENT`로 오는 것 외에 필드 추가가 없다 +(부모 이름은 상세 몫. 목록 카드는 신청 `title`로 충분하다). + +### 4. `GET /api/org/event-submissions/{id}` : 필드 2개 동봉 + +EVENT 신청의 상세에 다음이 실린다(다른 유형에서는 null). + +```json +{ + "type": "EVENT", + "participationMethod": "부스 방문 후 현장에서 …", + "parentEvent": { "occurrenceId": 1, "name": "부산국제영화제" }, + "operatingHours": null, + "programDescription": null +} +``` + +- `parentEvent.name`의 원천은 `event_occurrences.title`이다. MSG-501 목록의 `name`과 같은 + 원천이라 모달에서 고른 이름과 상세에 보이는 이름이 어긋나지 않는다. 조회는 저장된 + `parentEventOccurrenceId`로 회차 1건을 읽어 채운다. +- 존재 은닉(13430), 이력, 반려 사유, 위치 표시명 재료 동봉은 전부 기존 그대로다. + +### 5. `PATCH /api/org/event-submissions/{id}` : EVENT 재제출 + +- `EventSubmissionUpdateRequestDto`에 `participationMethod`가 추가된다. + **`parentOccurrenceId`는 추가하지 않는다.** 유형과 마찬가지로 부모는 재제출에서 바꿀 수 + 없고(결정 D-3), 저장된 부모가 그대로 유지된다. 본문에 `parentOccurrenceId`를 실어 보내도 + DTO에 필드가 없어 역직렬화에서 버려진다. +- **부모 검증의 호출 계약**: 제출(POST)은 요청 본문의 `parentOccurrenceId`로 존재(13440)와 + 종료(13441)를 검증한다. 재제출(PATCH)은 본문에 부모를 받지 않으므로, 소유 조회로 로드한 + 신청의 저장 부모를 **같은 검증 메서드에 별도 인자로 넘겨 종료(13441)만 재검증한다**(저장 + 부모는 FK로 보장되는 값이라 존재 검증이 성립하지 않는다). 부모 필수 검증을 공통 + `EventSubmissionForm`에 넣지 않는 이유도 이것이다: 재제출 DTO에는 부모 필드 자체가 없다. +- 반려와 수정 사이에 부모 회차가 종료됐으면 13441이다(참여할 자리가 사라진 신청은 재제출로 + 살릴 수 없다. 새 부모로 새로 제출한다). +- 그 외 검증은 제출과 동일하게 전부 다시 탄다. 조건부 UPDATE 원자 전이, 존재 은닉, 이미지 + 유지 계약(null이면 유지)은 MSG-498 그대로다. + +## 도메인 로직 + +### 부모 이벤트 검증 (신규) + +제출과 재제출에서 EVENT 유형이면 다음 순서로 검사한다. + +1. `parentOccurrenceId`로 `EventOccurrenceRepository.findById` 조회. 없으면 **13440**(404). + 존재 은닉을 하지 않는 이유: 승인 이벤트 목록(MSG-501)이 ORG 전원에게 같은 전체를 + 보여주므로 회차의 존재는 비밀이 아니다. 은닉 대상은 남의 신청(13430)이지 공개된 부모가 + 아니다. +2. **`endsAt <= now`이면 13441**(409). `now`는 주입받은 `Clock`에서 얻는다. 이 판정은 + MSG-501 목록 노출 조건(D-1, `endsAt > now`)의 **정확한 여집합**이라, 종료 정각을 포함한 + 어떤 시각에도 모달에 보이는 회차와 신청이 되는 회차가 항상 일치한다(정각에 목록에서 + 빠지는 회차는 신청도 13441이다). 파생 상태[^4]로 말하면 UPCOMING과 LIVE만 부모가 될 수 + 있다. 재제출은 저장 부모로 이 종료 검증만 반복한다(호출 계약은 API 명세 5절). +3. **카테고리 검증은 만들지 않는다.** v2.2 확정 2로 지역축제와 팝업 승인분은 미션으로 + 편입되지 `event_occurrences`에 들어오지 않으므로, 이 테이블의 전 행이 이벤트 카테고리다 + (MSG-501 결정 D-3과 같은 전제이고, 그 결정이 남겼던 "승인분 유입 시 카테고리 컬럼" 접점은 + 확정 2로 소멸했다). "이벤트 카테고리 아닌 시리즈" 케이스는 데이터상 성립하지 않는다. +4. 관리자 노출 중지(FR-20)는 미구현이다. 중지 상태를 만드는 티켓이 이 검증에도 제외 조건을 + 더한다(MSG-501 조회와 같은 접점). + +### 위치: 대표 위치 1곳, 형태는 기존 행사 위치와 동일 (결정 D-2) + +위치 단계 화면 "다음: 대표 위치 등록"의 확정이다. EVENT 신청의 위치는 **정확히 1곳**이고, +그 1곳은 기존 위치와 똑같이 areaRects[^5] 사각형 여러 개로 영역을 그린다. 대표 격자 하나만 +점으로 받는 안은 기각한다. 승인 결과가 부모 회차 아래 `event_locations` 1행 + 격자 +전개(`event_location_grids`)인데(확정 1), 점 하나로는 승인 시점에 영역을 지어낼 수 없다. +81칸 상한(FR-24), 인덱스 범위, 합집합 판정, `RepresentativeGridResolver` 계산은 기존 검증 +경로를 분기 없이 그대로 통과한다. + +### 기간 검증 + +기존 D-6 규칙(시작 ≤ 종료, 종료일은 KST 오늘 이상)만 적용한다. **공개 기간이 부모 회차 기간 +안에 들어야 한다는 교차 검증은 두지 않는다**(결정 D-4). 부모 기간은 UTC 시각이고 신청 기간은 +KST 날짜 라벨이라 서버 단정이 경계일에서 애매하고, 시안과 PRD 어디에도 이 제약이 없으며, +기간의 타당성은 어차피 관리자 심사가 본다(반려 코드 PERIOD가 그 자리다). + +### 유형별 필수 분기 (13439 확장) + +기존 2유형 분기 표가 3유형이 된다. + +| 필드 | FESTIVAL | POPUP | EVENT | +|---|---|---|---| +| `programDescription` | 필수 | 거부 | 거부 | +| `operatingHours` | 거부 | 필수 | 거부 | +| `participationMethod` | 거부 | 거부 | 필수 | +| `parentOccurrenceId` | 거부 | 거부 | 필수 (제출 본문 한정, 재제출 DTO에는 필드 없음) | +| `locations` 개수 | 1~20 | 1~20 | 정확히 1 (2개 이상 13439) | + +위반은 전부 기존 13439 하나로 낸다(메시지가 "등록 유형에 필요한 항목이 올바르지 않습니다"라 +세 유형 모두를 덮는다). 검증 구현은 `EventSubmissionForm` 경유의 기존 분기 지점에 값 추가다. + +### 이미지, 신청 번호, 상태와 이력 + +전부 MSG-498 그대로다. 커버 이미지는 대표 이미지와 같은 pending 검증, original 복사, 롤백 +보상 경로를 타고, 신청 번호는 같은 시퀀스에서 나오며, 상태 전이와 이력 기록도 분기가 없다. + +## 데이터 모델 + +Flyway 1건, **`V50__event_submission_participation.sql`**. develop 실측 최신이 V49 +(`V49__event_submissions.sql`, MSG-498 머지분)라 V50이다(PRD 7절의 재실측 규칙. 병렬 레인이 +먼저 붙이면 구현 시점에 다시 실측한다). + +```sql +-- 이벤트 참여형 (MSG-502): 승인 이벤트 회차 아래 참여를 신청한다. +-- 승인 시 부모 회차의 event_locations 로 반영하는 쪽은 MSG-500 (PRD v2.2 확정 1). +ALTER TABLE event_submissions + ADD COLUMN parent_event_occurrence_id BIGINT REFERENCES event_occurrences(id), + ADD COLUMN participation_method TEXT; + +COMMENT ON COLUMN event_submissions.parent_event_occurrence_id + IS '참여 대상 승인 이벤트 회차. EVENT 유형 전용 (MSG-502)'; +COMMENT ON COLUMN event_submissions.participation_method + IS '참여 방식 서술. EVENT 유형 전용, 최소 10자 (#100 준용)'; + +ALTER TABLE event_submissions DROP CONSTRAINT chk_event_sub_type; +ALTER TABLE event_submissions ADD CONSTRAINT chk_event_sub_type + CHECK (type IN ('FESTIVAL', 'POPUP', 'EVENT')); + +-- EVENT 행에는 부모가 반드시 있고 다른 유형 행에는 없다. MSG-500 approve 가 이 불변식 위에서 +-- 부모를 역참조하므로 DB 가 강제한다 (유형별 텍스트 필수는 V49 선례대로 앱 검증 13439). +ALTER TABLE event_submissions ADD CONSTRAINT chk_event_sub_parent + CHECK ((type = 'EVENT') = (parent_event_occurrence_id IS NOT NULL)); +``` + +- 부모 컬럼에 인덱스는 두지 않는다. 이 티켓의 어떤 조회도 부모로 걸지 않고(상세는 id 1건 + 조회), 회차는 삭제 경로가 없어 FK 역방향 스캔 우려도 없다. 부모 기준 목록이 필요해지는 + 티켓이 그때 추가한다. +- `EventSubmissionType`에 `EVENT` 상수를 추가한다. **주석 필수**: 이 값은 glossary의 이벤트 + 카테고리(지역축제, 팝업 제외 큰 행사)이고, `MissionType.EVENT`(축제 미션)와 이름이 같지만 + 반대편을 가리킨다(glossary "이벤트" 항목 명문. v2.2 확정 2로 승인된 지역축제가 + `MissionType.EVENT` 미션이 되는 교차까지 생기므로 주석 없이는 반드시 헷갈린다). +- `EventSubmission` 엔티티에 `Long parentEventOccurrenceId`, `String participationMethod` + 추가. 부모는 연관 없이 id 보관이다. 이 티켓이 부모에서 읽는 값은 상세의 `title` 하나라 + 리포지토리 1건 조회로 충분하고, `userId` 필드의 기존 근거와 같다(MSG-500이 연관이 필요하면 + 그쪽에서 읽기 전용으로 단다). +- 기존 행 마이그레이션은 없다(신규 컬럼 둘 다 NULL 허용이고 기존 행은 전부 FESTIVAL, POPUP + 이라 CHECK를 그대로 통과한다). + +## 에러 코드 + +**event 13xxx의 13440대 블록을 이 티켓이 처음 소비한다.** MSG-501 스펙의 대역 예약대로이고, +그 스펙이 요구한 재확인도 마쳤다: develop 머지된 `EventErrorCode` 실코드의 신청 블록 점유는 +13430~13439까지다(2026-08-29 실측). 기존 대역 잔여 사용이라 `response-pattern.md` 표 갱신 +커밋은 필요 없다. + +| developCode | 상수 | HTTP | 메시지 | 발생 | +|---|---|---|---|---| +| 13440 | `PARENT_EVENT_NOT_FOUND` | 404 | 참여할 이벤트를 찾을 수 없습니다 | 없는 `parentOccurrenceId` (제출 한정) | +| 13441 | `PARENT_EVENT_CLOSED` | 409 | 종료된 이벤트에는 참여를 신청할 수 없습니다 | `endsAt <= now` 회차(정각 포함, 유예와 아카이브 포함)에 제출 또는 재제출. 상태 충돌 409(13434 선례) | + +그 외 실패는 전부 기존 코드 재사용이다: 13439(유형별 항목 분기, 위 표), 13431/13432(영역), +13433(기간), 13435~13438(이미지), 13430(존재 은닉), 13434(반려 아닌 상태 수정), Bean +Validation 공통 400(10자 미만, 2000자 초과 등). + +## 계약 변경 + +없음. `GridQueryService`, `HotZoneService`, `UserGridQueryService`, `UserOidcCommandService` +어느 것도 시그니처가 바뀌지 않는다. 표시명 재료 계약은 MSG-498 상세 응답 경로가 이미 소비 +중인 것을 그대로 쓴다. + +## 결정 기록 + +- **D-1 신규 엔드포인트 없이 기존 신청 API에 유형 편입**: 제출 후 흐름(목록, 상세, 반려, + 재제출, 심사 큐)이 FR-10~14로 세 유형 공통이고, MSG-498 결정 D-9가 정확히 이 확장(type + CHECK 재정의 + 부모 컬럼 추가)을 위해 자리를 열어 뒀다. MSG-501 스펙이 언급했던 + `/api/org/events/{occurrenceId}` 하위 신설안은 기각한다. 신청이 URL 두 체계로 갈라지면 + 목록, 상세, 재제출, 관리자 심사(MSG-500)까지 전부 두 벌이 된다. +- **D-2 위치는 대표 위치 1곳, 형태는 기존 위치 규칙 그대로**: 시안 [4C]의 버튼("다음: 대표 + 위치 등록")과 하단 안내("대표 위치가 일반 유저 지도에 표시됩니다")가 모두 단수이고, 승인 + 결과가 부모 회차 아래 행사 위치 1행이다(v2.2 확정 1). 전용 프레임이 없어 열려 있던 "영역도 + 받는지"는 **받는 쪽으로 확정**한다. `event_locations`가 영역(격자 전개)과 대표 격자를 둘 다 + 가지므로 점 하나만 받으면 승인 시 영역 재료가 없다. 검증과 계산은 기존 경로 재사용이라 + 구현 증분은 개수 검사 하나다. 이 1곳 예외는 FR-8(위치 여러 개 허용)과 충돌하지 않도록 + **PRD v2.2가 FR-8에 예외 문장으로 명문화했다**(상호참조). +- **D-3 재제출에서 유형과 부모는 불변**: MSG-498 D-8(유형 불변)과 같은 근거다. 부모를 바꾸면 + 참여 대상이 달라져 사실상 새 신청이다. 부모를 바꾸려면 새로 제출한다. 재제출의 부모 검증은 + 저장 부모로 종료만 재검증한다(호출 계약은 API 명세 5절). +- **D-4 공개 기간과 부모 기간의 교차 검증 없음**: 근거는 도메인 로직 기간 절. 타당성 판단은 + 관리자 심사(PERIOD 반려)가 게이트다. +- **D-5 참여 방식은 서술 항목**: String 최소 10자, 최대 2000자, DDL TEXT. + `programDescription`과 같은 성격이라 같은 규칙(#100, D-11)을 준용한다. +- **D-6 부모 검증은 은닉 없는 404와 상태 충돌 409, 종료 판정은 `endsAt <= now`**: 은닉과 + 409의 근거는 도메인 로직 부모 검증 절. 종료 판정을 MSG-501 노출 조건의 정확한 여집합으로 + 두어 목록과 신청의 경계가 정각에서도 갈리지 않는다. 신청의 존재 은닉(13430)과 다른 + 성격이라 코드를 섞지 않는다. +- **D-7 격자 겹침 검사는 제출 시점에 하지 않는다**: 참여 영역이 부모 회차의 기존 위치 + 격자와 겹치는지(회차 내 격자 단일 귀속, V39 제약)는 제출 시점에 확인해도 승인 시점까지 + 다른 승인으로 상태가 바뀔 수 있어 승인 처리(MSG-500)가 어차피 다시 판정해야 한다. 두 번 + 구현하지 않고 승인 쪽 한 곳에 둔다(위반 시 AREA 반려 안내 정의도 MSG-500 몫). +- **D-8 구 라벨 "행사방 이름"은 "이름"으로**: MSG-503 용어 확정의 반영이고 저장은 기존 + `title` 컬럼이다. 승인 시 이 값이 `event_locations.name`(NOT NULL)의 재료가 된다. + MSG-501이 "승인 흐름산 위치의 name 채움 규칙 미정의"로 남긴 접점의 참여분 답이 이것이고, + 규칙 확정 자체는 MSG-500 approve 스펙 몫이다. + +## 비범위 + +- 관리자 심사와 승인 반영: MSG-500. EVENT 승인 시 부모 회차 아래 행사 위치 생성(격자 전개, + `location_key` 채번, `name` 채움, 겹침 위반 처리)과 지역축제·팝업 승인분의 미션 칩 편입 + (v2.2 확정 2), SRS FR-EVENT-01 등재 기준 서술 갱신까지 전부 그쪽이다. +- **참여 속성의 승인 반영 계약: MSG-500 스펙 몫.** 이 티켓은 속성을 신청 행에 저장하는 + 데까지다. 지금 `event_locations`(V39)에는 이름 컬럼뿐이라, 신청 행에 저장된 속성이 승인 + 후 사용자에게 노출되려면 위치 쪽 확장이 필요하다. MSG-500이 정의할 것 세 가지: ① + `event_locations` 확장 컬럼(별도 마이그레이션. 후보는 운영 주체 `organizer_name`, 소개 + `description`, 공개 기간 `starts_on`과 `ends_on`, 참여 방식 `participation_method`, 커버 + 이미지 키 `image_key`), ② 승인 시 신청 행에서 위치 행으로의 복사 매핑, ③ 그 속성을 사용자 + 조회 어느 API에 실을지의 노출 계약. 이 셋이 없으면 v2.2 확정 1("속성은 위치에 붙는 부가 + 정보")이 승인 시점에 유실된다(PRD 미해결 질문 1의 결정 기록에 같은 문구로 박아 뒀다). +- 승인 이벤트 목록 조회: MSG-501 완료. +- 지역축제, 팝업스토어 신청: MSG-498 완료. 이 티켓은 그 코드의 확장점만 만진다. +- glossary "이벤트" 항목의 하위 실체 미정 표기 갱신: v2.2 확정 1의 회부 몫(별도 진행). +- 관리자 노출 중지(FR-20)의 부모 검증 제외 조건: 중지 상태를 만드는 티켓 몫. + +## 미해결 질문 + +없음. 판단 여지가 있던 지점(위치 단계의 형태, 부모 불변, 기간 교차 검증, 겹침 검사 시점)은 +결정 기록 D-2, D-3, D-4, D-7로 확정했다. 리뷰에서 이견이 나오면 그 항목만 재논의한다. + +## 테스트 시나리오 + +JUnit 5와 AssertJ, 한국어 백틱 메서드명. 구현 시 `// 검증: FR-…` 주석으로 PRD 요구사항 ID를 +달고, 전역 SRS ID는 구현 마무리에서 srs-writer로 등재를 확인한다(FR-EVENT 영역, MSG-501의 +FR-EVENT-16 선례). + +```text +// 부모 이벤트 검증 (검증: FR-7) +없는_회차로_참여_신청하면_거부한다() // 13440 +종료된_회차로_참여_신청하면_거부한다() // 13441, 고정 Clock, endsAt 경과 +종료_정각의_회차는_참여_신청이_거부된다() // 13441, endsAt == now 경계, 고정 Clock + // (MSG-501 노출 조건의 여집합 확인) +유예_중인_회차도_참여_신청이_거부된다() // 13441, UPLOAD_GRACE 는 종료 +종료_직전_회차는_참여_신청이_된다() // endsAt > now 경계, MSG-501 노출과 동일 판정 + +// 유형별 항목 분기 (검증: FR-7) +이벤트_신청에_부모_회차가_없으면_거부한다() // 13439 +축제_신청에_부모_회차가_실려_오면_거부한다() // 13439, 반대 유형 필드 +이벤트_신청에_참여_방식이_없으면_거부한다() // 13439 +이벤트_신청에_주요_프로그램이_실려_오면_거부한다() // 13439 +참여_방식이_10자_미만이면_400이다() // Bean Validation 공통 경로 +참여_방식이_2000자를_넘으면_400이다() // D-5 경계 + +// 위치 (검증: FR-8 v2.2 예외, FR-9, FR-24 / D-2) +이벤트_신청의_위치는_1곳이면_통과한다() // 사각형 여러 개 영역 + 대표 격자 계산 +이벤트_신청에_위치가_2곳이면_거부한다() // 13439 +이벤트_신청에_위치가_없으면_거부한다() // 13431, 공통 규칙 재사용 +이벤트_위치도_81칸_상한이_적용된다() // 13432, 기존 경로 무분기 확인 + +// 제출과 저장 (검증: FR-10) +이벤트_신청이_부모_참조와_참여_방식을_저장한다() // parent_event_occurrence_id 실측 +이벤트_신청도_심사_중_상태와_FM꼴_신청_번호를_받는다() // 기존 흐름 합류 + +// 상세 (검증: FR-12) +이벤트_신청_상세에_부모_이벤트_이름이_실린다() // event_occurrences.title 원천 +축제_신청_상세의_참여형_필드는_null이다() // participationMethod·parentEvent + +// 재제출 (검증: FR-13 / D-3) +이벤트_반려본을_수정하면_심사_중으로_돌아간다() // 부모 불변 확인 포함 +재제출_시점에_저장_부모가_종료됐으면_거부한다() // 13441, 저장 부모 재검증 (API 5절 계약) +재제출_본문의_부모_변경_시도는_무시되고_부모가_불변이다() // PATCH 본문에 parentOccurrenceId 를 + // 실어도 DTO 에 필드가 없어 버려진다 + +// 회귀 (기존 유형 무영향) +축제_신청_흐름은_그대로_동작한다() // FESTIVAL 기존 시나리오 대표 1건 재확인 +``` + +통합 테스트의 부모 회차는 MSG-501 선례대로 `msg502-*` 자연키와 합성 데이터로 격리해 공유 +로컬 DB의 시드에 의존하지 않는다. S3와 직렬화 수단도 MSG-498 선례 그대로다. + +--- + +[^1]: 행사 운영자: 행사를 등록하는 외부 주체(지자체, 팝업 운영사, 축제 대행사). 서버 역할값 + ORG다. "행사"를 떼고 운영자라고만 쓰면 필맵 쪽 사람을 가리키므로 붙여 쓴다(glossary.md). +[^2]: 회차: 같은 행사가 해마다 다시 열려도 데이터가 섞이지 않도록 시리즈 아래에 둔 개별 개최 + 단위(`event_occurrences`, MSG-438). 기간, 시·도, 위치가 전부 회차에 붙고, 참여 신청의 + 부모도 회차다(MSG-501 확정 ①). +[^3]: 대표 격자: 위치의 영상이 실제로 연결되는 격자 하나. 홀수 행렬 직사각형이면 정중앙, + 아니면 영역 중심에 가장 가까운 포함 격자다. 계산기는 `global.geo.RepresentativeGridResolver` + 재사용이다. +[^4]: 파생 상태: 저장 컬럼이 아니라 회차의 시작과 종료 시각에 서버 시각을 대어 계산하는 상태 + (UPCOMING, LIVE, UPLOAD_GRACE, ARCHIVED). 정의는 `EventOccurrence.statusAt` 한 곳이고, + 이 스펙의 판정은 그 경계 중 하나인 `endsAt` 저장 컬럼 조건과 등가다. +[^5]: areaRects: 격자 인덱스로 표현한 사각형 배열. 사각형 하나는 (minGridY, maxGridY, + minGridX, maxGridX) 네 정수이고, 기존 행사 시드와 MSG-498 신청이 쓰는 형식 그대로다. + +--- + +## 작업 로그 + +### 2026-08-29 — 구현 완료 (Owner B) + +**완료 모듈 3개** + +- 모듈 1 스키마·엔티티: `V50__event_submission_participation.sql`, `EventSubmissionType.EVENT`, + `EventSubmission` 필드 2개(`parentEventOccurrenceId`·`participationMethod`). +- 모듈 2 검증·서비스: `EventErrorCode` 13440·13441, `EventSubmissionServiceImpl` 유형 분기 + 재작성과 부모 검증, 제출·재제출 배선. +- 모듈 3 조회 표면·테스트: 상세 응답 2필드(`participationMethod`·`parentEvent`), + `EventSubmissionParentEventResponseDto`, 검증 테스트 22건(목 15 · 실 DB 7). + +**구현 결정** + +- **대표 위치 1곳 검증을 `buildLocations` 시그니처 변경 없이 넣었다.** 유형을 전개 메서드까지 + 내려보내는 대신, 이미 유형을 아는 `validateForm` 이 먼저 도는 자리에서 2곳 이상이면 13439 를 + 내고 0곳은 손대지 않아 기존 13431 로 흘려보낸다. 같은 실패를 두 코드로 내지 않으려는 선택이다. + **알려진 한계**: 이 판정은 "`validateForm` 이 `buildLocations` 보다 먼저 호출된다"는 실행 순서에 + 의존한다. 두 호출의 순서를 바꾸면 EVENT 신청의 위치 2곳이 13439 가 아니라 통과해 버린다 + (0곳 케이스는 순서와 무관하다). 순서를 바꾸는 리팩터링을 하려면 유형을 전개 경로에 넘기는 + 원안으로 되돌려야 한다. +- **참여 방식의 길이 검증은 DTO `@Size(min = 10, max = 2000)` 로 남겼다.** 구현 중 리뷰에서 + "13439 로 일원화하라"는 지시가 있었으나, 스펙 §API 2 와 D-5 가 `programDescription` 과 같은 규칙을 + 명시하고 테스트 시나리오도 400 으로 적고 있었다. 게다가 지시의 근거였던 "자기 유형이 아닌 필드에 + 짧은 값이 실리면 400 이 13439 보다 먼저 뜬다"는 성질은 기존 `programDescription`·`operatingHours` + 에 이미 있는 동작이라, 새 필드만 다르게 두면 세 항목의 계약이 갈린다. 이 근거를 올려 지시가 + 철회됐고 스펙대로 유지하기로 확정했다. +- **부모 검증의 호출 계약을 인자 분리로 구현했다.** 부모를 공통 `EventSubmissionForm` 에 올리지 + 않고 `validateForm(type, form, parentOccurrenceId)` 의 별도 인자로 받는다. 제출은 요청 본문 값이, + 재제출은 소유 조회로 로드한 신청의 저장값이 같은 메서드로 들어간다(재제출 DTO 에는 부모 필드 + 자체가 없다). 저장 부모는 FK 가 보장하므로 13440 은 사실상 제출 전용이고, 재제출에서는 13441 만 + 성립한다. +- **유형 분기를 이분기에서 등식 셋으로 재작성했다.** `hasText(값) != (해당 유형인가)` 형태라 유형이 + 늘어도 줄 하나가 는다. EVENT 갈래는 주요 프로그램·운영 시간을 별도 조건 없이 자동으로 거부한다. +- **테스트 픽스처의 고정 날짜를 상대값으로 바꿨다.** `daysFromToday(n)`(`EventSubmissionCommitBoundaryTest` + 선례) 하나로 통일했고, 이 티켓이 새로 만든 EVENT 픽스처 2곳뿐 아니라 MSG-498 의 축제·재제출 픽스처 + 4곳도 함께 껐다 — 전부 고정 날짜가 지나는 순간 성공 경로가 기간 검증(13433)에서 먼저 깨지는 같은 + 종류의 시한폭탄이었다. 의도된 과거값인 `pastFestivalBody`(2020년)만 그대로 뒀다. + +**Codex 리뷰 2라운드** + +- 1R 채택: 위 픽스처 상대화(EVENT 픽스처가 `endsOn = 2026-10-15` 고정이라 2026-10-16 부터 참여형 + 성공·부모 검증·상세·재제출 테스트가 13433 으로 먼저 깨지는 문제). +- 1R 기각: "EVENT 행이 생긴 뒤 구버전 앱으로의 롤백이 불안전하다". 프리런치 기능이라(FE 미구현, + 실사용자 0) 데이터가 쌓인 채 되돌리는 창이 실존하지 않고, enum 확장 마이그레이션은 레포 선례가 + 전부 같은 성질이다. 대신 아래 "알려진 특성"으로 남긴다. +- **알려진 특성 — 롤백 비호환**: V50 적용 후 `event_submissions.type = 'EVENT'` 행이 생기면 + `dfded1de` 이전 코드로의 롤백은 비호환이다(구 `EventSubmissionType` 에 EVENT 상수가 없어 해당 행 + 조회가 깨진다). 되돌려야 하면 EVENT 행 삭제가 선행 조건이다. + +**런타임 동작** + +- **데이터 저장 위치** — `event_submissions` 테이블에 컬럼 2개가 는다(`V50__event_submission_participation.sql`): + `parent_event_occurrence_id BIGINT REFERENCES event_occurrences(id)`(인덱스 없음 — 이 티켓의 어떤 + 조회도 부모로 걸지 않는다), `participation_method TEXT`. 제약 2개: `chk_event_sub_type` 을 드롭 후 + `CHECK (type IN ('FESTIVAL','POPUP','EVENT'))` 로 재생성, `chk_event_sub_parent` 신설 — + `CHECK ((type = 'EVENT') = (parent_event_occurrence_id IS NOT NULL))`. 기존 행 이행은 없다(둘 다 + NULL 허용이고 기존 행은 전부 FESTIVAL·POPUP 이라 새 CHECK 를 그대로 통과한다). 신규 테이블·Redis + 키·S3 경로 변화는 없다(이미지 경로는 MSG-498 그대로). +- **실행 쿼리** (Hibernate `org.hibernate.SQL` DEBUG 실측, 통합 테스트 실행분) + - 부모 회차 조회 `EventOccurrenceRepository.findById` — 단일 테이블 SELECT 다. 시리즈가 LAZY 라 + 조인 없이 FK 컬럼만 실린다. + + ```sql + select eo1_0.id, eo1_0.city_name, eo1_0.ends_at, eo1_0.max_grid_x, eo1_0.max_grid_y, + eo1_0.min_grid_x, eo1_0.min_grid_y, eo1_0.occurrence_key, eo1_0.schedule_revision, + eo1_0.event_series_id, eo1_0.starts_at, eo1_0.title, eo1_0.visible_from + from event_occurrences eo1_0 where eo1_0.id=? + ``` + + - 제출 INSERT — 새 컬럼 2개가 컬럼 목록에 합류해 16개가 된다(값은 파라미터 바인딩). + + ```sql + insert into event_submissions + (created_at, description, ends_on, image_key, operating_hours, organizer_name, + parent_event_occurrence_id, participation_method, program_description, starts_on, + status, submission_no, title, type, updated_at, user_id) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ``` + + - 신청 번호 채번은 종전대로 `SELECT nextval('event_submission_no_seq')`, 재제출의 상태 전이도 + 종전대로 조건부 UPDATE 한 문장이다: `update event_submissions es1_0 set status='IN_REVIEW', + updated_at=? where es1_0.id=? and es1_0.user_id=? and es1_0.status='REJECTED'`. + - 재제출의 폼 반영은 더티 체킹 UPDATE 라 `parent_event_occurrence_id` 가 SET 절에 **실린다** + (같은 값의 재기록이다). 부모 불변은 DB 가 아니라 애플리케이션이 보장한다 — 재제출 DTO 에 부모 + 필드가 없어 값을 바꿀 입력 경로가 없다. +- **예외 흐름** — 전부 서비스에서 `ApiException(EventErrorCode)` 로 던져지고 + `GlobalExceptionHandler` 가 enum 의 HTTP·developCode 로 변환한다. + - 13440 `PARENT_EVENT_NOT_FOUND` → 404: `EventSubmissionServiceImpl.validateParentEvent` 의 + `findById(...).orElseThrow`. 상세 조회의 `toParentEvent` 도 같은 코드를 쓰지만 FK 보장으로 + 도달 불가 경로다. + - 13441 `PARENT_EVENT_CLOSED` → 409: 같은 메서드의 `!parent.getEndsAt().isAfter(LocalDateTime.now(clock))`. + 제출·재제출 양쪽에서 던져진다. + - 13439 `SUBMISSION_REQUIRED_FIELD_MISSING` → 400: `validateForm` 의 유형 등식 위반과 EVENT 위치 + 2곳 이상. + - 13431 `INVALID_SUBMISSION_AREA` → 400: 위치 0곳은 `validateForm` 을 통과해 `buildLocations` 에서 + 던져진다(EVENT 도 같은 경로다). + - 참여 방식 10자 미만·2000자 초과는 도메인에 닿지 않는다 — `@Valid` 가 컨트롤러 인자 바인딩에서 + `MethodArgumentNotValidException` 을 띄우고, 핸들러가 공통 400(developCode 400, 필드 메시지 조합) + 으로 바꾼다. 유형별 13439 와 갈리는 지점이 여기다. + - 인가 실패는 이 코드에 없다 — `/api/org/**` matcher(MSG-496)와 초기 비밀번호 게이트(MSG-497)가 + 핸들러 앞에서 처리한다. +- **빈 동작** — 새 빈은 없다. `EventSubmissionServiceImpl` 이 `EventOccurrenceRepository` 를 하나 더 + 주입받고, 생성자는 2개 그대로다(프로덕션 생성자가 `Clock.systemUTC()` 를 채워 전체 생성자로 위임 — + 테스트가 고정 Clock 을 넣는 경로다. 부모 종료 판정과 신청 번호 연도 라벨이 전부 이 Clock 에서 + 나오므로 `now()` 무인자 호출은 없다). 재제출의 트랜잭션 안 순서는 MSG-498 계약 그대로다: + 소유 조회(읽기 전용) → 검증 → `@Modifying(clearAutomatically = true)` 조건부 UPDATE → **재로드** → + 폼 교체. 검증에 쓴 `owned` 엔티티는 벌크 UPDATE 의 영속성 컨텍스트 클리어로 준영속이 되지만, + 거기서 읽은 값(유형·부모 id)은 이미 지역 변수로 복사됐고 그 뒤로 다시 참조되지 않는다 — 폼을 + 실제로 고치는 대상은 UPDATE 뒤에 다시 읽은 인스턴스다. + +**커밋** + +| 해시 | 메시지 | +|---|---| +| `dfded1de` | MSG-502 docs: 이벤트 참여 신청 스펙을 확정하고 PRD에 노출 형태와 위치 예외를 반영한다 | +| `d2db1619` | MSG-502 feat: 이벤트 참여형 신청의 부모 회차와 참여 방식을 저장한다 | +| `243b0a31` | MSG-502 feat: 이벤트 참여형 신청을 접수하고 종료된 이벤트는 거부한다 | +| `b8d48db2` | MSG-502 test: 이벤트 참여형 신청 검증 22건을 더하고 픽스처 날짜를 상대화한다 | +| (이 커밋) | MSG-502 docs: 참여형 신청을 요구사항에 등재하고 구현 현황과 작업 로그를 남긴다 | diff --git a/docs/srs-changelog.md b/docs/srs-changelog.md index 28d752b4..61b9c3ff 100644 --- a/docs/srs-changelog.md +++ b/docs/srs-changelog.md @@ -142,3 +142,4 @@ union이 붙어 양쪽 행이 그대로 남는다. 2026-08-14에 같은 자리 | 2026-08-28 | FR-EVENT-16을 진행 중에서 구현됨으로 올렸다. 행사 운영자 콘솔의 승인 이벤트 목록 조회(`GET /api/org/events`)가 검증 테스트 25건과 같은 브랜치에 실렸다. 노출 조건은 종료 전 회차만이고 노출 시작 전 예정 회차도 콘솔에는 보인다(참여 신청 준비 기간 근거) | MSG-501 구현 (2026-08-28) | | 2026-08-29 | FR-AUTH-13을 진행 중에서 구현됨으로 올렸다. 문안은 불변이다. 행사 운영자 계정 발급 축이 서버에 전부 들어갔다 — 관리자 직접 발급과 발급 요청 승인이 같은 발급 코어를 쓰고(계정 생성·초기 비밀번호 생성·커밋 후 발송·발송 실패 격리), 초기 비밀번호 평문은 메일 본문 한 번 외에 응답·로그·DB 어디에도 남지 않는다(로그 캡처 단언, 발송 실패 예외를 로거에 넘기지 않는 것까지 회귀 테스트로 고정). 재생성은 재발급이라 새 비밀번호로 교체하고 이전 값을 즉시 무효화하며, 대상은 아직 초기 로그인을 마치지 않은 계정으로 한정한다(사용 중 계정에 재발급하면 발급자가 다시 비밀번호를 아는 상태가 되어 FR-AUTH-15의 목적이 무너진다). 검증 테스트 48건이 같은 브랜치에 실렸다. 계정이 없는 신청자의 공개 접수 폼과 관리자 큐(PRD FR-6)도 같은 티켓이 구현했으나 대응 SRS 행이 없어 등재 여부는 별도 판단으로 남긴다. FR-USER-16은 진행 중 그대로 두고 잔여 범위를 좁혔다 — 이번에 생긴 관리자 큐는 계정 발급 요청 전용이고 아이디 변경 요청의 검토·승인은 MSG-500 몫이다 | MSG-499 구현 (2026-08-29), 스펙 `docs/spec/MSG-499.md` | | 2026-08-29 | FR-EVENT-13·FR-EVENT-14를 계획에서 구현됨으로 올렸다. 행사 운영자 콘솔의 신청 제출(presign 포함)·내 목록·상세·반려본 재제출 API 5개가 검증 테스트와 같은 브랜치에 실렸다. 위치당 영역 상한은 사각형 합집합 기준 81칸으로 판정하고, 존재 은닉은 13430 단일 응답, 재제출 전이는 소유권 술어 포함 조건부 UPDATE로 원자화했다. 반려 행 쓰기와 승인 반영(FR-EVENT-15)은 MSG-500 몫으로 계획 유지 | MSG-498 구현 (2026-08-29) | +| 2026-08-29 | FR-EVENT-17 등재 (구현됨). 등록 유형 "이벤트"의 참여 신청 접수를 등재했다. 신규 엔드포인트 없이 MSG-498의 신청 API 5개가 EVENT 유형을 받는 구조라 접수 뒤 흐름(심사 중·신청 번호·내 목록·상세·재제출)은 세 유형이 같은 경로를 쓴다. 위치는 대표 위치 한 곳으로 한정했는데(FR-EVENT-13의 여러 위치 허용에 대한 예외이고 PRD v2.2가 FR-8에 예외 문장으로 명문화했다) 대표 격자 한 점만 받는 안은 기각했다 — 승인 결과가 회차 아래 위치 1행 + 격자 전개라 점 하나로는 승인 시점에 영역을 지어낼 수 없다. 종료 판정은 `ends_at <= now`로 FR-EVENT-16 노출 조건의 정확한 여집합이라 종료 정각에도 목록과 신청의 경계가 갈리지 않는다. 공개 기간이 부모 회차 기간 안에 들어야 한다는 교차 검증은 두지 않았다(부모는 UTC 시각, 신청은 KST 날짜 라벨이라 경계일 단정이 애매하고 타당성은 관리자 심사가 본다). FR-EVENT-13 문장에는 세 번째 유형의 상호참조를 달았다. 승인 반영(FR-EVENT-15)은 MSG-500 그대로 계획 유지 | MSG-502 구현 (2026-08-29), 스펙 `docs/spec/MSG-502.md` | diff --git a/docs/srs.md b/docs/srs.md index fcdb3125..bad59731 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -392,10 +392,11 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | FR-EVENT-10 | 행사는 예정, 진행 중, 업로드 유예(종료 후 30일), 아카이브 순서로 흐른다. 예정 상태에서는 영상을 올릴 수 없다(2026-08-21 확정 — 행사 시작 전에는 행사 기록이 남지 않는다. 일반 격자 업로드는 이와 무관하게 자유다). 종료 후 30일까지는 영상 업로드와 댓글·도움돼요 변경이 모두 열려 있고(2026-08-21 번복 — 유예 기간에 올라온 영상이 반응을 못 받으면 유예를 둔 목적과 결과가 서로 깎인다), 아카이브로 넘어가는 종료 30일 후부터 업로드와 댓글·도움돼요 변경이 함께 차단돼 읽기 전용이 된다(기존 수와 목록은 계속 표시). 판정은 전부 서버 시각 기준이다 | 구현됨 | 정본 PRD §4.2, FR-12~16·22 (2026-08-20, 예정 상태 업로드 불가는 2026-08-21 사용자 확정). MSG-442 가 4단계 파생 상태 판정(`EventOccurrence.statusAt`)과 공용 `EventLifecycleGuard`(업로드·상호작용 허용표, 경계 정각 전수 검증)를, MSG-440 이 업로드 창 판정(시작 전 13410·마감 후 13409, 시작 정각 포함·마감 정각 제외)을 각각 구현 (2026-08-21). 440·442 레인 조정 합의로 업로드 경로의 창 판정은 가드 호출로 통일됐다(코드 13410·13409 유지 — 단일 판정처, 2026-08-21). MSG-441 이 그 가드를 댓글·도움돼요 변경 다섯 경로(작성·수정·삭제·추가·취소)에 배선해 잠금 집행이 성립했고, 같은 날 잠금 시점이 종료 정각에서 아카이브 전환 정각으로 번복돼 가드·표시(`interactionLocked`)·테스트를 함께 옮겼다 — MSG-442 가 만들어 두고 호출자가 없던 `checkInteractionOpen` 의 첫 소비처이고, 판정이 영상 노출 검증 다음·소유자 검증 앞이라 아카이브된 행사에서는 자기 댓글이든 남의 댓글이든 같은 13422 다. 조회 세 경로(댓글 목록·상세·피드)에는 가드를 걸지 않아 아카이브 후에도 기존 수와 목록이 그대로 조회된다 (2026-08-21) | | FR-EVENT-11 | 행사방을 지금 보고 있는 사람 수를 근실시간으로 표시한다. 클라이언트 heartbeat(기본 30초 주기)로 갱신하고 마지막 신호가 90초 이내인 고유 세션만 세며, 같은 사용자의 중복 탭은 한 명이다. 집계 장애 시 인원 수만 숨기고 다른 행사방 기능은 정상 동작한다 | 구현됨 | 정본 PRD §4.4, FR-17~19 (2026-08-20). MSG-443 구현(2026-08-21 — 열람은 비로그인 허용, 익명은 세션 헤더 식별. occurrenceId 존재 검증은 후속) | | FR-EVENT-12 | 행사 영상은 미션 집계 어디에도 잡히지 않는다. 미션 진행도, 완료 판정, 방문 스팟, 미션 영상 목록(첫 페이지와 다음 페이지), 격자별 미션 영상 수 전부에서 제외된다. 행사 영상이 일반 업로드의 부수효과(점령, 스트릭, 핫스코어, 뱃지 등)를 그대로 받는 가운데 유일한 제외 항목이다 | 구현됨 | 구 PRD "미션 축 연계 비목표" 승계 확정 (2026-08-20). 계약 정본 `docs/spec/MSG-438.md` §부수효과 계약. MSG-450 구현 (2026-08-22 — `videos` 를 직접 조인하는 미션 쿼리 여섯 곳(진행도·완료 판정·방문 스팟·미션 영상 목록 두 쿼리·격자별 영상 수)에 `NOT EXISTS event_videos` 안티조인을 넣었다. 쿼리 1~3 은 LEFT/INNER 조인의 ON 절, 4~6 은 WHERE 절이라 미진행 미션의 0 진행도가 유지된다. 술어 동등 계약을 안티조인 포함으로 갱신, 새 테이블·인덱스·마이그레이션 없음). 쓰기 경로의 판정 시점 가시성 계약은 MSG-440 이 미션 판정 훅 자체를 행사 업로드에서 빼는 방식으로 이미 만족한다(`VideoServiceImpl.confirmAndStore` 는 미션 훅 밖) | -| FR-EVENT-13 | 행사 운영자는 등록 유형(지역축제, 팝업스토어) 중 하나를 골라 행사를 신청할 수 있고 유형마다 기본 정보 항목이 다르다(세 번째 유형인 이벤트 참여형은 승인 이벤트를 골라 참여를 신청하는 별도 구조로 MSG-501·MSG-502에 분리). 위치는 지도 격자 사각형 영역으로 지정하며 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있고, 위치당 영역은 사각형 합집합 기준 최대 격자 81칸이다. 대표 격자는 신청자가 아니라 서버가 계산한다. 접수된 신청은 심사 중 상태가 되고 신청 번호가 부여된다. 임시 저장은 미포함 확정 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 개정 (2026-08-28 승인, 유형 재편·81칸 상한 확정·임시 저장 미포함) · 스펙 `docs/spec/MSG-498.md` (2026-08-28). 위치 영역 형식은 기존 시드 areaRects와 동일. MSG-498 구현 (2026-08-29 — `POST /api/org/event-submissions` + presign, V49 스키마 4테이블, 81칸 합집합 판정, 신청 번호 FM-{연도}-{4자리} 전역 시퀀스, 대표 격자 서버 계산) | +| FR-EVENT-13 | 행사 운영자는 등록 유형(지역축제, 팝업스토어) 중 하나를 골라 행사를 신청할 수 있고 유형마다 기본 정보 항목이 다르다(세 번째 유형인 이벤트 참여형은 승인 이벤트를 골라 참여를 신청하는 별도 구조로 MSG-501·MSG-502에 분리, FR-EVENT-17). 위치는 지도 격자 사각형 영역으로 지정하며 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있고, 위치당 영역은 사각형 합집합 기준 최대 격자 81칸이다. 대표 격자는 신청자가 아니라 서버가 계산한다. 접수된 신청은 심사 중 상태가 되고 신청 번호가 부여된다. 임시 저장은 미포함 확정 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 개정 (2026-08-28 승인, 유형 재편·81칸 상한 확정·임시 저장 미포함) · 스펙 `docs/spec/MSG-498.md` (2026-08-28). 위치 영역 형식은 기존 시드 areaRects와 동일. MSG-498 구현 (2026-08-29 — `POST /api/org/event-submissions` + presign, V49 스키마 4테이블, 81칸 합집합 판정, 신청 번호 FM-{연도}-{4자리} 전역 시퀀스, 대표 격자 서버 계산) | | FR-EVENT-14 | 행사 운영자는 자기 신청의 목록과 상태별 건수, 상세(상태 변경 이력과 반려 사유 포함)를 조회할 수 있다. 다른 운영자의 신청은 존재를 은닉하는 단일 실패 응답이다. 반려된 신청만 수정해 재제출할 수 있고 재제출하면 심사 중 상태로 돌아간다 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 (2026-08-28 승인) · 스펙 `docs/spec/MSG-498.md`. MSG-498 구현 (2026-08-29 — 내 목록 `GET .../my`(상태별 건수는 목록에서 파생), 상세(이력·반려 사유 동봉), 존재 은닉 13430 단일 응답, 재제출은 REJECTED 한정 조건부 UPDATE 원자 전이. 반려 행 쓰기는 MSG-500이 처음 실행) | | FR-EVENT-15 | 관리자는 신청을 상태별로 조회하고 상세에서 위치 사각형과 노출 영역을 검토해 승인하거나 반려할 수 있다. 반려에는 사유가 필수다. 승인하면 기존 행사 도메인에 행사(시리즈, 회차, 위치)가 생성되어 기존 노출 채널로 지도에 실리고 승인 번호가 부여되며, 생성된 행사는 시드로 등재한 행사와 같은 규칙(대표 격자 결정, 회차 미혼합, 생명주기 4단계)을 따른다 | 계획 | PRD `docs/prd/event-submission.md` 초안 (2026-08-27). 승인 행사 노출 방식(기존 행사방 편입, 가벼운 칩, 규모별 분기)과 승인 후 일정 수정 정책은 미확정(미해결 질문) | | FR-EVENT-16 | 행사 운영자는 등록 유형 "이벤트"에서 참여할 승인 이벤트 목록을 조회할 수 있다. 목록은 이벤트 카테고리(지역축제와 팝업스토어를 제외한 큰 행사)의 종료 전 회차만 담고, 시·도 필터와 이벤트 이름 검색과 시·도별 건수를 제공하며, 항목마다 이름·기간·장소 라벨·시·도가 담긴다. 노출 시작 전 예정 회차도 이 콘솔 목록에는 보인다 | 구현됨 | MSG-501 구현 (2026-08-28, `GET /api/org/events` — 검증 테스트 25건 동반). PRD `docs/prd/event-submission.md` FR-26 (2026-08-28 최소 개정), v2.1 [행사 운영자 3-1] 이벤트 선택 모달. 부모 범위는 이벤트 카테고리만으로 사용자 확정(2026-08-28, 시안의 지역축제/팝업스토어 배지는 구판 잔재). FR-EVENT-13의 유형 서술(행사방)을 v2.1(이벤트 참여형)로 갱신하는 것은 MSG-498 레인 몫이라 여기서 손대지 않는다 | +| FR-EVENT-17 | 행사 운영자는 등록 유형 "이벤트"에서 승인 이벤트 회차 하나를 골라 그 아래 참여를 신청할 수 있다. 참여 방식과 참여할 회차는 이 유형 전용 필수 항목이고, 위치는 대표 위치 정확히 한 곳이다(신청 하나에 위치 여러 개를 허용하는 규칙의 예외). 위치의 사각형 영역·81칸 상한·대표 격자 서버 계산은 다른 유형과 같은 규칙이다. 참여할 회차는 존재해야 하고 이미 종료된 회차(업로드 유예·아카이브 포함)에는 신청할 수 없으며, 종료 판정 경계는 승인 이벤트 목록의 노출 조건과 정확히 반대라 목록에 보이는 회차와 신청이 되는 회차가 항상 같다. 반려본을 고쳐 다시 낼 때 등록 유형과 참여할 회차는 바뀌지 않고, 그 시점에 회차가 끝나 있으면 재제출도 거부한다. 접수 뒤 흐름(심사 중 상태, 신청 번호, 내 목록·상세·재제출)은 다른 유형과 같은 경로다. **이 요구의 범위는 신청 접수까지이고**, 승인 시 참여할 회차 아래 행사 위치로 반영하는 것은 FR-EVENT-15 몫이다 | 구현됨 | PRD `docs/prd/event-submission.md` v2.2 FR-7 이벤트 참여형 · FR-8의 대표 위치 1곳 예외 · 스펙 `docs/spec/MSG-502.md`. MSG-502 구현 (2026-08-29 — 신규 엔드포인트 없이 MSG-498의 신청 API 5개에 EVENT 유형 편입, V50 컬럼 2개(참여 방식·참여할 회차 FK) + type CHECK 재정의 + 유형과 부모의 짝을 강제하는 CHECK, 부모 검증 13440(없는 회차 404)·13441(종료 회차 409, `ends_at <= now` — FR-EVENT-16 노출 조건 `ends_at > now`의 여집합), 대표 위치 2곳 이상은 13439·0곳은 기존 13431, 재제출은 저장된 부모로 종료만 재검증. 검증 테스트 22건 동반). 승인 반영은 MSG-500(FR-EVENT-15)이라 이 행의 범위 밖이다 | ### ROUTE: AI 경로 추천 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 c1c1f45f..156b76d7 100644 --- a/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java +++ b/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java @@ -57,6 +57,12 @@ public enum EventErrorCode implements ErrorCodeIfs { SUBMISSION_IMAGE_UNSUPPORTED(13437, HttpStatus.UNSUPPORTED_MEDIA_TYPE, "jpg, png 이미지만 올릴 수 있습니다"), SUBMISSION_IMAGE_TOO_LARGE(13438, HttpStatus.PAYLOAD_TOO_LARGE, "이미지는 최대 10MB 입니다"), SUBMISSION_REQUIRED_FIELD_MISSING(13439, HttpStatus.BAD_REQUEST, "등록 유형에 필요한 항목이 올바르지 않습니다"), + + // 이벤트 참여형의 부모 회차 검증 (MSG-502). 존재를 은닉하지 않는 것은 승인 이벤트 목록이 행사 운영자 + // 전원에게 같은 전량을 보여줘 회차의 존재가 비밀이 아니기 때문이다 — 은닉 대상은 남의 신청(13430)뿐이다. + PARENT_EVENT_NOT_FOUND(13440, HttpStatus.NOT_FOUND, "참여할 이벤트를 찾을 수 없습니다"), + // 종료 판정(endsAt <= now)은 목록 노출 조건의 여집합이라 정각에도 둘이 갈리지 않는다. 상태 충돌 409 (13434 선례). + PARENT_EVENT_CLOSED(13441, HttpStatus.CONFLICT, "종료된 이벤트에는 참여를 신청할 수 없습니다"), ; private final Integer errorCode; diff --git a/src/main/java/com/msg/fillmap/event/submission/controller/EventSubmissionController.java b/src/main/java/com/msg/fillmap/event/submission/controller/EventSubmissionController.java index 12ce4255..16e546b4 100644 --- a/src/main/java/com/msg/fillmap/event/submission/controller/EventSubmissionController.java +++ b/src/main/java/com/msg/fillmap/event/submission/controller/EventSubmissionController.java @@ -58,8 +58,10 @@ public SuccessResponse issueImagePresign summary = "행사 등재 신청 제출", description = "심사 중 상태로 접수하고 신청 번호(FM-2026-XXXX 꼴)를 부여한다. 위치마다 대표 격자를 " + "서버가 계산해 저장하며, 위치 하나의 영역은 겹침을 한 번만 세는 합집합 기준 최대 81칸이다.\n\n" - + "유형별 필수 항목이 다르다 — FESTIVAL 은 주요 프로그램, POPUP 은 운영 시간이고 자기 유형이 " - + "아닌 항목이 실려 오면 거부한다. 위치에는 이름 필드가 없고 배열 순서가 곧 순번이다." + + "유형별 필수 항목이 다르다 — FESTIVAL 은 주요 프로그램, POPUP 은 운영 시간, EVENT 는 참여 방식과 " + + "참여할 승인 이벤트 회차(parentOccurrenceId)이고 자기 유형이 아닌 항목이 실려 오면 거부한다. " + + "EVENT 의 위치는 대표 위치 정확히 1곳이고, 참여할 회차가 이미 끝났으면 접수하지 않는다.\n\n" + + "위치에는 이름 필드가 없고 배열 순서가 곧 순번이다." ) @PostMapping public SuccessResponse submit( diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionCreateRequestDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionCreateRequestDto.java index 7e2120d2..2d480f96 100644 --- a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionCreateRequestDto.java +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionCreateRequestDto.java @@ -19,9 +19,14 @@ */ @Schema(description = "행사 등재 신청 제출 요청") public record EventSubmissionCreateRequestDto( - @Schema(description = "등록 유형 — FESTIVAL(지역축제) 또는 POPUP(팝업스토어)", example = "FESTIVAL") + @Schema(description = "등록 유형 — FESTIVAL(지역축제)·POPUP(팝업스토어)·EVENT(이벤트 참여형)", example = "FESTIVAL") @NotNull EventSubmissionType type, + @Schema(description = "참여할 승인 이벤트 회차 id — EVENT 전용 필수. 승인 이벤트 목록 응답의 occurrenceId 를 " + + "그대로 넣는다. 없는 회차면 13440, 이미 종료된 회차면 13441, 다른 유형에 실려 오면 13439", + example = "1", nullable = true) + Long parentOccurrenceId, + @Schema(description = "축제명 / 팝업명", example = "부산불꽃축제") @NotBlank @Size(max = 100) String title, @@ -37,10 +42,14 @@ public record EventSubmissionCreateRequestDto( @Schema(description = "운영 시간 — POPUP 전용 필수. FESTIVAL 에 실려 오면 13439", example = "11:00 ~ 20:00") @Size(max = 100) String operatingHours, - @Schema(description = "주요 프로그램 — FESTIVAL 전용 필수. POPUP 에 실려 오면 13439", + @Schema(description = "주요 프로그램 — FESTIVAL 전용 필수. 다른 유형에 실려 오면 13439", example = "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영") @Size(min = 10, max = 2000) String programDescription, + @Schema(description = "참여 방식 — EVENT 전용 필수. 다른 유형에 실려 오면 13439", + example = "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다") + @Size(min = 10, max = 2000) String participationMethod, + @Schema(description = "행사 소개", example = "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제") @NotBlank @Size(min = 10, max = 2000) String description, diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionDetailResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionDetailResponseDto.java index c21c419c..85387b17 100644 --- a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionDetailResponseDto.java +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionDetailResponseDto.java @@ -12,8 +12,8 @@ */ @Schema(description = "신청 상세", requiredProperties = {"id", "submissionNo", "type", "status", "title", "organizerName", "startsOn", "endsOn", - "operatingHours", "programDescription", "description", "imageUrl", "locations", "rejection", "history", - "updatedAt"}) + "operatingHours", "programDescription", "participationMethod", "parentEvent", "description", "imageUrl", + "locations", "rejection", "history", "updatedAt"}) public record EventSubmissionDetailResponseDto( @Schema(description = "신청 id", example = "7") Long id, @@ -45,6 +45,12 @@ public record EventSubmissionDetailResponseDto( @Schema(description = "주요 프로그램 — FESTIVAL 만 값이 있다", nullable = true) String programDescription, + @Schema(description = "참여 방식 — EVENT 만 값이 있다", nullable = true) + String participationMethod, + + @Schema(description = "참여할 부모 이벤트 — EVENT 만 값이 있다", nullable = true) + EventSubmissionParentEventResponseDto parentEvent, + @Schema(description = "행사 소개") String description, diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionForm.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionForm.java index 6a5b0c0c..b24736c7 100644 --- a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionForm.java +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionForm.java @@ -21,9 +21,15 @@ public interface EventSubmissionForm { /** POPUP 전용 필수. FESTIVAL 에 실려 오면 13439 다. */ String operatingHours(); - /** FESTIVAL 전용 필수. POPUP 에 실려 오면 13439 다. */ + /** FESTIVAL 전용 필수. POPUP·EVENT 에 실려 오면 13439 다. */ String programDescription(); + /** + * EVENT 전용 필수 (MSG-502). 다른 유형에 실려 오면 13439 다. + * 부모 회차는 여기 없다 — 재제출로 바꿀 수 없어(D-3) 재제출 DTO 에 필드 자체가 없기 때문이다. + */ + String participationMethod(); + String description(); /** pending 키. 재제출에서만 null 이 허용되고 그 뜻은 "기존 이미지 유지"다. */ diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionParentEventResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionParentEventResponseDto.java new file mode 100644 index 00000000..3b435a3d --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionParentEventResponseDto.java @@ -0,0 +1,17 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 이벤트 참여형 신청이 참여할 부모 이벤트 회차 (MSG-502 §API 4). 이름의 원천은 {@code event_occurrences.title} + * 하나라, 승인 이벤트 목록(MSG-501)에서 고른 이름과 상세에 보이는 이름이 어긋나지 않는다. + */ +@Schema(description = "참여할 부모 이벤트 회차", requiredProperties = {"occurrenceId", "name"}) +public record EventSubmissionParentEventResponseDto( + @Schema(description = "회차 id", example = "1") + Long occurrenceId, + + @Schema(description = "이벤트 이름", example = "부산국제영화제") + String name +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionUpdateRequestDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionUpdateRequestDto.java index e6c04e39..dd7c146e 100644 --- a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionUpdateRequestDto.java +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionUpdateRequestDto.java @@ -11,8 +11,10 @@ import jakarta.validation.constraints.Size; /** - * 반려본 수정 재제출 요청 (MSG-498 FR-13). 제출 요청에서 유형을 뺀 전체이고, 부분 수정이 아니라 전체 - * 교체다 — 신청 하나가 폼 하나로 쓰였다 폼 하나로 고쳐지는 단위라서다 (D-8). 유형을 바꾸려면 새로 제출한다. + * 반려본 수정 재제출 요청 (MSG-498 FR-13). 제출 요청에서 유형과 부모 회차를 뺀 전체이고, 부분 수정이 아니라 + * 전체 교체다 — 신청 하나가 폼 하나로 쓰였다 폼 하나로 고쳐지는 단위라서다 (D-8). 유형을 바꾸려면 새로 + * 제출한다. 이벤트 참여형의 부모 회차도 같은 이유로 불변이라 필드가 없다 (MSG-502 D-3) — 본문에 + * {@code parentOccurrenceId} 를 실어 보내도 역직렬화에서 버려지고 저장된 부모가 그대로 유지된다. *

* 이미지만 예외적으로 유지 선택이 있다 — {@code imageS3Key} 를 null 로 보내거나 생략하면 기존 이미지가 * 유지되고, pending 키를 보내면 교체다. 상세 응답이 저장 키를 노출하지 않으므로 클라이언트가 확정 키를 @@ -38,6 +40,10 @@ public record EventSubmissionUpdateRequestDto( @Schema(description = "주요 프로그램 — FESTIVAL 전용 필수", example = "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영") @Size(min = 10, max = 2000) String programDescription, + @Schema(description = "참여 방식 — EVENT 전용 필수. 부모 이벤트는 재제출로 바꿀 수 없어 이 요청에 필드가 없다", + example = "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다") + @Size(min = 10, max = 2000) String participationMethod, + @Schema(description = "행사 소개", example = "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제") @NotBlank @Size(min = 10, max = 2000) String description, diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java index bc97d738..7b4b9b69 100644 --- a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java @@ -77,6 +77,17 @@ public class EventSubmission { @Column(name = "program_description") private String programDescription; + @Column(name = "participation_method") + private String participationMethod; + + /** + * 참여 대상 승인 이벤트 회차 (MSG-502) — EVENT 유형에만 값이 있고 재제출로도 바뀌지 않는다 (D-3). + * 연관 없이 id 만 보관하는 것은 이 티켓이 부모에서 읽는 값이 상세의 title 하나뿐이라 조인이 필요 없어서다 + * ({@code userId} 와 같은 근거). 유형과 값의 짝은 DB CHECK(chk_event_sub_parent)가 강제한다. + */ + @Column(name = "parent_event_occurrence_id") + private Long parentEventOccurrenceId; + @Column(name = "description", nullable = false) private String description; @@ -96,19 +107,24 @@ public class EventSubmission { @OrderBy("displayOrder") private List locations = new ArrayList<>(); - private EventSubmission(String submissionNo, Long userId, EventSubmissionType type, LocalDateTime now) { + private EventSubmission(String submissionNo, Long userId, EventSubmissionType type, + Long parentEventOccurrenceId, LocalDateTime now) { this.submissionNo = submissionNo; this.userId = userId; this.type = type; + this.parentEventOccurrenceId = parentEventOccurrenceId; this.status = EventSubmissionStatus.IN_REVIEW; this.createdAt = now; this.updatedAt = now; } - /** 제출 (FR-10). 접수된 신청은 언제나 심사 중에서 시작한다. 폼 내용은 {@link #updateForm} 가 채운다. */ + /** + * 제출 (FR-10). 접수된 신청은 언제나 심사 중에서 시작한다. 폼 내용은 {@link #updateForm} 가 채운다. + * 부모 회차는 유형과 함께 여기서만 정해진다 — 둘 다 재제출로 바꿀 수 없어 {@link #updateForm} 에 없다. + */ public static EventSubmission submit(String submissionNo, Long userId, EventSubmissionType type, - LocalDateTime now) { - return new EventSubmission(submissionNo, userId, type, now); + Long parentEventOccurrenceId, LocalDateTime now) { + return new EventSubmission(submissionNo, userId, type, parentEventOccurrenceId, now); } /** @@ -116,13 +132,15 @@ public static EventSubmission submit(String submissionNo, Long userId, EventSubm * (D-8 — 유형만 불변이라 인자에 없다). */ public void updateForm(String title, String organizerName, LocalDate startsOn, LocalDate endsOn, - String operatingHours, String programDescription, String description, String imageKey, LocalDateTime now) { + String operatingHours, String programDescription, String participationMethod, String description, + String imageKey, LocalDateTime now) { this.title = title; this.organizerName = organizerName; this.startsOn = startsOn; this.endsOn = endsOn; this.operatingHours = operatingHours; this.programDescription = programDescription; + this.participationMethod = participationMethod; this.description = description; this.imageKey = imageKey; this.updatedAt = now; diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionType.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionType.java index 62e09ce7..7fb1ad82 100644 --- a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionType.java +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionType.java @@ -2,8 +2,8 @@ /** * 행사 등재 신청의 등록 유형 (MSG-498, PRD 유형 표). 유형이 유형별 필수 항목을 결정한다 — - * FESTIVAL 은 주요 프로그램(programDescription), POPUP 은 운영 시간(operatingHours)이다. - * 세 번째 유형(이벤트 참여형)은 v2.1 재편으로 MSG-501·502 로 분리돼 여기 없다 — 그쪽이 값을 추가한다. + * FESTIVAL 은 주요 프로그램(programDescription), POPUP 은 운영 시간(operatingHours), + * EVENT 는 참여 방식(participationMethod)과 부모 회차(parentEventOccurrenceId)다. */ public enum EventSubmissionType { @@ -11,5 +11,15 @@ public enum EventSubmissionType { FESTIVAL, /** 팝업스토어. */ - POPUP + POPUP, + + /** + * 이벤트 참여형 (MSG-502) — 승인된 이벤트 회차 아래에 참여를 붙이는 신청이다. + *

+ * 여기서 말하는 "이벤트"는 glossary 의 이벤트 카테고리(지역축제·팝업을 제외한 큰 행사)이고, + * 이름이 같은 {@code MissionType.EVENT}(축제 미션)와는 반대편을 가리킨다. 게다가 이 콘솔에서 + * 승인된 지역축제 신청이 {@code MissionType.EVENT} 미션이 되는 교차까지 있으므로 + * (PRD v2.2 확정 2) 두 상수를 같은 것으로 읽지 않는다. + */ + EVENT } diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java index dccd5bbd..54c60d6b 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java @@ -15,7 +15,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.msg.fillmap.event.entity.EventOccurrence; import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; import com.msg.fillmap.event.submission.dto.EventSubmissionDetailResponseDto; @@ -26,6 +28,7 @@ import com.msg.fillmap.event.submission.dto.EventSubmissionLocationRequestDto; import com.msg.fillmap.event.submission.dto.EventSubmissionLocationResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionMyListResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionParentEventResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionRejectionResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionStatusCountsResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionSubmitResponseDto; @@ -74,25 +77,29 @@ public class EventSubmissionServiceImpl implements EventSubmissionService { private final EventSubmissionImageStore imageStore; private final ZoneNameQueryService zoneNameQueryService; private final GridQueryService gridQueryService; + private final EventOccurrenceRepository occurrenceRepository; private final Clock clock; /** 프로덕션 생성자 — clock 을 systemUTC 로 고정해 전체 생성자로 위임한다 (EventVideoServiceImpl 선례). */ @Autowired public EventSubmissionServiceImpl(EventSubmissionRepository submissionRepository, EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, - ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService) { + ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService, + EventOccurrenceRepository occurrenceRepository) { this(submissionRepository, historyRepository, imageStore, zoneNameQueryService, gridQueryService, - Clock.systemUTC()); + occurrenceRepository, Clock.systemUTC()); } public EventSubmissionServiceImpl(EventSubmissionRepository submissionRepository, EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, - ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService, Clock clock) { + ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService, + EventOccurrenceRepository occurrenceRepository, Clock clock) { this.submissionRepository = submissionRepository; this.historyRepository = historyRepository; this.imageStore = imageStore; this.zoneNameQueryService = zoneNameQueryService; this.gridQueryService = gridQueryService; + this.occurrenceRepository = occurrenceRepository; this.clock = clock; } @@ -105,13 +112,13 @@ public EventSubmissionImagePresignResponseDto issueImagePresignedUrl(Long userId @Override @Transactional public EventSubmissionSubmitResponseDto submit(Long userId, EventSubmissionCreateRequestDto request) { - validateForm(request.type(), request); + validateForm(request.type(), request, request.parentOccurrenceId()); List locations = buildLocations(request.locations()); LocalDateTime now = LocalDateTime.now(clock); String imageKey = imageStore.confirm(userId, request.imageS3Key()); - EventSubmission submission = - EventSubmission.submit(nextSubmissionNo(), userId, request.type(), now); + EventSubmission submission = EventSubmission.submit(nextSubmissionNo(), userId, request.type(), + request.parentOccurrenceId(), now); applyForm(submission, request, imageKey, locations, now); submissionRepository.save(submission); @@ -153,6 +160,8 @@ public EventSubmissionDetailResponseDto getSubmission(Long userId, Long submissi submission.getEndsOn(), submission.getOperatingHours(), submission.getProgramDescription(), + submission.getParticipationMethod(), + toParentEvent(submission), submission.getDescription(), imageStore.presignGet(submission.getImageKey()), toLocationDtos(submission), @@ -178,8 +187,9 @@ public EventSubmissionDetailResponseDto getSubmission(Long userId, Long submissi @Transactional public EventSubmissionSubmitResponseDto resubmit(Long userId, Long submissionId, EventSubmissionUpdateRequestDto request) { - EventSubmissionType type = findOwned(userId, submissionId).getType(); - validateForm(type, request); + EventSubmission owned = findOwned(userId, submissionId); + // 부모는 재제출로 바뀌지 않으므로(D-3) 저장값을 그대로 검증에 넘긴다 — 종료(13441)만 다시 걸린다. + validateForm(owned.getType(), request, owned.getParentEventOccurrenceId()); List locations = buildLocations(request.locations()); LocalDateTime now = LocalDateTime.now(clock); @@ -204,6 +214,19 @@ public EventSubmissionSubmitResponseDto resubmit(Long userId, Long submissionId, return EventSubmissionSubmitResponseDto.from(submission); } + /** + * 부모 이벤트 (MSG-502 §API 4) — EVENT 신청만 값이 있다. 이름의 원천이 회차 title 하나라 모달에서 고른 + * 이름과 상세에 보이는 이름이 어긋나지 않는다. 부모 행의 존재는 FK 가 보장한다. + */ + private EventSubmissionParentEventResponseDto toParentEvent(EventSubmission submission) { + if (submission.getParentEventOccurrenceId() == null) { + return null; + } + return occurrenceRepository.findById(submission.getParentEventOccurrenceId()) + .map(parent -> new EventSubmissionParentEventResponseDto(parent.getId(), parent.getTitle())) + .orElseThrow(() -> new ApiException(EventErrorCode.PARENT_EVENT_NOT_FOUND)); + } + /** 존재 은닉의 단일 진입점 (FR-14) — 없는 신청과 남의 신청이 여기서 같은 13430 이 된다. */ private EventSubmission findOwned(Long userId, Long submissionId) { return submissionRepository.findByIdAndUserId(submissionId, userId) @@ -213,22 +236,54 @@ private EventSubmission findOwned(Long userId, Long submissionId) { private void applyForm(EventSubmission submission, EventSubmissionForm form, String imageKey, List locations, LocalDateTime now) { submission.updateForm(form.title(), form.organizerName(), form.startsOn(), form.endsOn(), - form.operatingHours(), form.programDescription(), form.description(), imageKey, now); + form.operatingHours(), form.programDescription(), form.participationMethod(), form.description(), + imageKey, now); submission.replaceLocations(locations); } /** - * 유형별 필수 항목과 기간 (§도메인 로직). 자기 유형이 아닌 필드는 무시하지 않고 거부한다 — 폼에 없는 - * 값이 저장되면 관리자 화면이 출처 불명 데이터를 그린다. + * 유형별 필수 항목과 기간과 부모 회차 (§도메인 로직). 자기 유형이 아닌 필드는 무시하지 않고 거부한다 — + * 폼에 없는 값이 저장되면 관리자 화면이 출처 불명 데이터를 그린다. + *

+ * 유형마다 항목이 정확히 하나씩 대응하므로 판정은 "값이 있다 == 그 유형이다" 등식들이다 (MSG-502 에서 + * 두 유형 이분기를 대체했다 — 유형이 늘어도 등식 한 줄이 는다). 부모 회차만 폼이 아니라 인자로 따로 + * 받는 것은 재제출 DTO 에 부모 필드가 없기 때문이다: 제출은 요청 본문 값이, 재제출은 저장값이 들어와 + * 같은 규칙을 탄다 (D-3). */ - private void validateForm(EventSubmissionType type, EventSubmissionForm form) { - boolean festival = type == EventSubmissionType.FESTIVAL; - boolean hasProgram = hasText(form.programDescription()); - boolean hasHours = hasText(form.operatingHours()); - if (festival ? (!hasProgram || hasHours) : (!hasHours || hasProgram)) { + private void validateForm(EventSubmissionType type, EventSubmissionForm form, Long parentOccurrenceId) { + boolean event = type == EventSubmissionType.EVENT; + if (hasText(form.programDescription()) != (type == EventSubmissionType.FESTIVAL) + || hasText(form.operatingHours()) != (type == EventSubmissionType.POPUP) + || hasText(form.participationMethod()) != event + || (parentOccurrenceId != null) != event) { + throw new ApiException(EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + // EVENT 는 대표 위치 정확히 1곳이다 (D-2). 0곳은 유형 무관 공통 규칙이라 여기서 세지 않고 + // buildLocations 의 13431 로 흘려보낸다 — 같은 실패를 두 코드로 내지 않기 위해서다. + if (event && form.locations() != null && form.locations().size() > 1) { throw new ApiException(EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); } validatePeriod(form.startsOn(), form.endsOn()); + validateParentEvent(type, parentOccurrenceId); + } + + /** + * 부모 이벤트 회차 검증 (MSG-502 §도메인 로직). 존재를 은닉하지 않는다 — 승인 이벤트 목록(MSG-501)이 + * 행사 운영자 전원에게 같은 전량을 보여주므로 회차의 존재는 비밀이 아니고, 은닉 대상은 남의 신청(13430)이다. + *

+ * 종료 판정 {@code endsAt <= now} 는 그 목록의 노출 조건({@code endsAt > now})의 정확한 여집합이라, + * 종료 정각을 포함한 어떤 시각에도 모달에 보이는 회차와 신청이 되는 회차가 일치한다. 재제출도 저장 부모로 + * 이 메서드를 다시 타는데, 저장 부모의 존재는 FK 가 보장하므로 13440 은 사실상 제출 전용이다. + */ + private void validateParentEvent(EventSubmissionType type, Long parentOccurrenceId) { + if (type != EventSubmissionType.EVENT) { + return; + } + EventOccurrence parent = occurrenceRepository.findById(parentOccurrenceId) + .orElseThrow(() -> new ApiException(EventErrorCode.PARENT_EVENT_NOT_FOUND)); + if (!parent.getEndsAt().isAfter(LocalDateTime.now(clock))) { + throw new ApiException(EventErrorCode.PARENT_EVENT_CLOSED); + } } /** diff --git a/src/main/resources/db/migration/V50__event_submission_participation.sql b/src/main/resources/db/migration/V50__event_submission_participation.sql new file mode 100644 index 00000000..1c126e61 --- /dev/null +++ b/src/main/resources/db/migration/V50__event_submission_participation.sql @@ -0,0 +1,19 @@ +-- 이벤트 참여형 (MSG-502): 승인 이벤트 회차 아래 참여를 신청한다. +-- 승인 시 부모 회차의 event_locations 로 반영하는 쪽은 MSG-500 (PRD v2.2 확정 1). +ALTER TABLE event_submissions + ADD COLUMN parent_event_occurrence_id BIGINT REFERENCES event_occurrences(id), + ADD COLUMN participation_method TEXT; + +COMMENT ON COLUMN event_submissions.parent_event_occurrence_id + IS '참여 대상 승인 이벤트 회차. EVENT 유형 전용 (MSG-502)'; +COMMENT ON COLUMN event_submissions.participation_method + IS '참여 방식 서술. EVENT 유형 전용, 최소 10자 (#100 준용)'; + +ALTER TABLE event_submissions DROP CONSTRAINT chk_event_sub_type; +ALTER TABLE event_submissions ADD CONSTRAINT chk_event_sub_type + CHECK (type IN ('FESTIVAL', 'POPUP', 'EVENT')); + +-- EVENT 행에는 부모가 반드시 있고 다른 유형 행에는 없다. MSG-500 approve 가 이 불변식 위에서 +-- 부모를 역참조하므로 DB 가 강제한다 (유형별 텍스트 필수는 V49 선례대로 앱 검증 13439). +ALTER TABLE event_submissions ADD CONSTRAINT chk_event_sub_parent + CHECK ((type = 'EVENT') = (parent_event_occurrence_id IS NOT NULL)); diff --git a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java index 08ec7f7b..a886459e 100644 --- a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java +++ b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java @@ -1,5 +1,7 @@ package com.msg.fillmap.event.submission; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.UUID; /** @@ -12,6 +14,9 @@ public final class EventSubmissionFixtures { public static final String GWANGALLI_RECT = rect(16859, 16861, 11509, 11515); public static final String GWANGALLI_CENTER = "16860_11512"; + /** 참여 방식 (MSG-502) — EVENT 전용 필수 서술 항목이다. */ + public static final String PARTICIPATION_METHOD = "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다"; + private EventSubmissionFixtures() { } @@ -27,6 +32,15 @@ public static String location(String... rects) { {"areaRects": [%s]}""".formatted(String.join(", ", rects)); } + /** + * 실행일 기준 상대 날짜 (EventSubmissionCommitBoundaryTest 선례). 기간을 고정 날짜로 박으면 그날이 지나는 + * 순간 모든 성공 경로가 기간 검증(13433)에서 먼저 깨진다 — 달력이 트리거인 시한폭탄이라 상대값으로 만든다. + * KST 오늘 이상이면 통과이고 UTC 오늘은 KST 오늘보다 앞서지 않으므로, 양수 offset 은 두 시간대 어디서도 안전하다. + */ + private static String daysFromToday(int days) { + return LocalDate.now(ZoneOffset.UTC).plusDays(days).toString(); + } + public static String pendingKey(long userId) { return "event-submissions/pending/%d/%s.jpg".formatted(userId, UUID.randomUUID()); } @@ -36,7 +50,7 @@ public static String festivalBody(long userId, String... locations) { } public static String festivalBody(String imageS3Key, String... locations) { - return body("부산불꽃축제", "2026-11-07", "2026-11-07", + return body("부산불꽃축제", daysFromToday(30), daysFromToday(30), "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", imageS3Key, locations); } @@ -47,12 +61,54 @@ public static String pastFestivalBody(long userId, String... locations) { } public static String festivalBodyWithTitle(long userId, String title, String... locations) { - return body(title, "2026-11-07", "2026-11-07", + return body(title, daysFromToday(30), daysFromToday(30), "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", pendingKey(userId), locations); } public static String festivalBodyWithDescription(long userId, String description, String... locations) { - return body("부산불꽃축제", "2026-11-07", "2026-11-07", description, pendingKey(userId), locations); + return body("부산불꽃축제", daysFromToday(30), daysFromToday(30), description, pendingKey(userId), locations); + } + + /** 이벤트 참여형 신청 본문 (MSG-502) — 부모 회차 id 와 참여 방식이 유형별 필수다. */ + public static String eventBody(long userId, long parentOccurrenceId, String... locations) { + return eventBodyWithMethod(userId, parentOccurrenceId, PARTICIPATION_METHOD, locations); + } + + public static String eventBodyWithMethod(long userId, long parentOccurrenceId, String participationMethod, + String... locations) { + return """ + { + "type": "EVENT", + "parentOccurrenceId": %d, + "title": "필맵 스탬프 투어", + "organizerName": "필맵 파트너스", + "startsOn": "%s", + "endsOn": "%s", + "participationMethod": "%s", + "description": "부산국제영화제 기간에 영화의전당 일대에서 진행하는 스탬프 투어입니다", + "imageS3Key": "%s", + "locations": [%s] + }""".formatted(parentOccurrenceId, daysFromToday(30), daysFromToday(39), participationMethod, + pendingKey(userId), String.join(", ", locations)); + } + + /** + * 이벤트 참여형 재제출 본문 (MSG-502). {@code parentOccurrenceId} 를 넘기면 본문에 실리는데, 재제출 DTO 에는 + * 그 필드가 없어 역직렬화에서 버려진다 — 부모 불변(D-3)을 테스트가 실제 요청으로 찍기 위한 손잡이다. + */ + public static String eventUpdateBody(String title, Long parentOccurrenceId, String... locations) { + return """ + { + %s"title": "%s", + "organizerName": "필맵 파트너스", + "startsOn": "%s", + "endsOn": "%s", + "participationMethod": "%s", + "description": "부산국제영화제 기간에 영화의전당 일대에서 진행하는 스탬프 투어입니다", + "locations": [%s] + }""".formatted(parentOccurrenceId == null ? "" : "\"parentOccurrenceId\": %d,\n\t\t\t".formatted( + parentOccurrenceId), title, daysFromToday(30), daysFromToday(39), PARTICIPATION_METHOD, + String.join(", ", locations)); } /** 재제출 본문 — 제출에서 type 을 뺀 전체다. imageS3Key 가 null 이면 필드 자체가 빠져 기존 이미지 유지 계약을 탄다. */ @@ -61,12 +117,13 @@ public static String updateBody(String title, String imageS3Key, String... locat { "title": "%s", "organizerName": "부산문화관광축제조직위원회", - "startsOn": "2026-11-07", - "endsOn": "2026-11-07", + "startsOn": "%s", + "endsOn": "%s", "programDescription": "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영", "description": "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", %s"locations": [%s] - }""".formatted(title, field("imageS3Key", imageS3Key), String.join(", ", locations)); + }""".formatted(title, daysFromToday(30), daysFromToday(30), field("imageS3Key", imageS3Key), + String.join(", ", locations)); } private static String body(String title, String startsOn, String endsOn, String description, diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java index e04b8014..879b98d0 100644 --- a/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java @@ -2,6 +2,10 @@ import static com.msg.fillmap.event.submission.EventSubmissionFixtures.GWANGALLI_CENTER; import static com.msg.fillmap.event.submission.EventSubmissionFixtures.GWANGALLI_RECT; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.PARTICIPATION_METHOD; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.eventBody; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.eventBodyWithMethod; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.eventUpdateBody; import static com.msg.fillmap.event.submission.EventSubmissionFixtures.festivalBody; import static com.msg.fillmap.event.submission.EventSubmissionFixtures.festivalBodyWithDescription; import static com.msg.fillmap.event.submission.EventSubmissionFixtures.festivalBodyWithTitle; @@ -54,6 +58,10 @@ import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.entity.EventSeries; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; import com.msg.fillmap.event.submission.entity.EventSubmission; import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; @@ -87,6 +95,12 @@ class EventSubmissionControllerTest { @Autowired private EventSubmissionRepository submissionRepository; + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -380,6 +394,162 @@ class Detail { } } + /** + * 이벤트 참여형 (MSG-502). 부모 회차와 신청이 실제로 이어지는지, 상세가 회차 title 을 읽어 오는지, 재제출이 + * 저장 부모를 다시 검증하는지가 전부 DB 동작이라 여기서 본다 — 부모 종료 정각 경계만 고정 Clock 이 필요해 + * EventSubmissionValidationTest 몫이다. + *

+ * 격리: 회차는 {@code msg502-*} 합성 자연키로 만들고 {@code @Transactional} 롤백에 정리를 맡긴다. + */ + @Nested + @DisplayName("이벤트 참여형") + class Participation { + + private long 이벤트_회차(String title) { + EventSeries series = seriesRepository.save( + new EventSeries("msg502-series-" + UUID.randomUUID().toString().substring(0, 8), title)); + EventOccurrence occurrence = new EventOccurrence(series, + "msg502-occ-" + UUID.randomUUID().toString().substring(0, 8)); + LocalDateTime startsAt = LocalDateTime.now(ZoneOffset.UTC).minusDays(5); + occurrence.update(series, title, "부산", startsAt, startsAt.plusDays(10), 16859, 16861, 11509, 11515); + return occurrenceRepository.saveAndFlush(occurrence).getId(); + } + + /** 부모 회차를 종료 상태로 민다 — starts_at < ends_at CHECK 를 지키려 시작도 함께 과거로 옮긴다. */ + private void 회차를_종료시킨다(long occurrenceId) { + entityManager.flush(); + LocalDateTime endsAt = LocalDateTime.now(ZoneOffset.UTC).minusDays(1); + jdbcTemplate.update(""" + UPDATE event_occurrences + SET starts_at = ?, ends_at = ?, visible_from = ? - INTERVAL '14 days' + WHERE id = ? + """, endsAt.minusDays(9), endsAt, endsAt.minusDays(9), occurrenceId); + entityManager.clear(); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("참여를 신청하면 부모 참조와 참여 방식이 저장되고 심사 중 상태가 된다") + void 이벤트_신청이_부모_참조와_참여_방식을_저장한다() throws Exception { + long occurrenceId = 이벤트_회차("부산국제영화제"); + + long id = 신청한다(organizer, eventBody(organizer.getId(), occurrenceId, location(GWANGALLI_RECT))); + + EventSubmission saved = 저장된_신청(id, organizer); + assertThat(saved.getType().name()).isEqualTo("EVENT"); + assertThat(saved.getParentEventOccurrenceId()).isEqualTo(occurrenceId); + assertThat(saved.getParticipationMethod()).isEqualTo(PARTICIPATION_METHOD); + assertThat(saved.getStatus().name()).isEqualTo("IN_REVIEW"); + assertThat(saved.getSubmissionNo()).matches("FM-\\d{4}-\\d{4,}"); + assertThat(saved.getLocations()).singleElement() + .extracting(EventSubmissionLocation::getRepresentativeGridId) + .isEqualTo(GWANGALLI_CENTER); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("없는 회차로 신청하면 13440, 종료된 회차로 신청하면 13441 이다") + void 없는_회차와_종료된_회차는_신청이_거부된다() throws Exception { + long occurrenceId = 이벤트_회차("부산국제영화제"); + 회차를_종료시킨다(occurrenceId); + + 신청_실패(eventBody(organizer.getId(), 999_999_999L, location(GWANGALLI_RECT)), 404, 13440); + 신청_실패(eventBody(organizer.getId(), occurrenceId, location(GWANGALLI_RECT)), 409, 13441); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("참여 방식이 10자 미만이거나 2000자를 넘으면 공통 400 이다") + void 참여_방식_길이_위반은_400이다() throws Exception { + long occurrenceId = 이벤트_회차("부산국제영화제"); + + for (String participationMethod : List.of("짧은설명", "가".repeat(2001))) { + 신청_실패(eventBodyWithMethod(organizer.getId(), occurrenceId, participationMethod, + location(GWANGALLI_RECT)), + 400, 400); + } + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청 상세에 참여 방식과 부모 이벤트 이름이 실린다") + void 이벤트_신청_상세에_부모_이벤트_이름이_실린다() throws Exception { + long occurrenceId = 이벤트_회차("부산국제영화제"); + long id = 신청한다(organizer, eventBody(organizer.getId(), occurrenceId, location(GWANGALLI_RECT))); + + mockMvc.perform(get(URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("EVENT")) + .andExpect(jsonPath("$.data.participationMethod").value(PARTICIPATION_METHOD)) + .andExpect(jsonPath("$.data.parentEvent.occurrenceId").value(occurrenceId)) + .andExpect(jsonPath("$.data.parentEvent.name").value("부산국제영화제")) + .andExpect(jsonPath("$.data.operatingHours").isEmpty()) + .andExpect(jsonPath("$.data.programDescription").isEmpty()); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("축제 신청 상세의 참여형 필드는 null 이다") + void 축제_신청_상세의_참여형_필드는_null이다() throws Exception { + long id = 축제를_신청한다(organizer); + + mockMvc.perform(get(URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participationMethod").isEmpty()) + .andExpect(jsonPath("$.data.parentEvent").isEmpty()); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("반려된 이벤트 신청을 수정하면 심사 중으로 돌아가고 본문의 부모 변경 시도는 무시된다") + void 재제출_본문의_부모_변경_시도는_무시되고_부모가_불변이다() throws Exception { + long occurrenceId = 이벤트_회차("부산국제영화제"); + long otherOccurrenceId = 이벤트_회차("서울세계불꽃축제"); + long id = 신청한다(organizer, eventBody(organizer.getId(), occurrenceId, location(GWANGALLI_RECT))); + 반려한다(id, "INFO", "기본 정보를 확인해 주세요"); + + mockMvc.perform(patch(URL + "/" + id) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(eventUpdateBody("필맵 스탬프 투어 2026", otherOccurrenceId, location(GWANGALLI_RECT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("IN_REVIEW")); + + EventSubmission saved = 저장된_신청(id, organizer); + assertThat(saved.getTitle()).isEqualTo("필맵 스탬프 투어 2026"); + assertThat(saved.getParentEventOccurrenceId()).isEqualTo(occurrenceId); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("재제출 시점에 저장된 부모가 종료됐으면 13441 이다 — 참여할 자리가 사라진 신청은 살릴 수 없다") + void 재제출_시점에_저장_부모가_종료됐으면_거부한다() throws Exception { + long occurrenceId = 이벤트_회차("부산국제영화제"); + long id = 신청한다(organizer, eventBody(organizer.getId(), occurrenceId, location(GWANGALLI_RECT))); + 반려한다(id, "INFO", "기본 정보를 확인해 주세요"); + 회차를_종료시킨다(occurrenceId); + + mockMvc.perform(patch(URL + "/" + id) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(eventUpdateBody("필맵 스탬프 투어 2026", null, location(GWANGALLI_RECT)))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13441)); + + // 검증 실패가 전이 앞이라 상태는 그대로 반려다. + assertThat(저장된_신청(id, organizer).getStatus().name()).isEqualTo("REJECTED"); + } + + private void 신청_실패(String body, int httpStatus, int developCode) throws Exception { + mockMvc.perform(post(URL) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().is(httpStatus)) + .andExpect(jsonPath("$.developCode").value(developCode)); + } + } + @Nested @DisplayName("수정 재제출") class Resubmit { diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionCommitBoundaryTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionCommitBoundaryTest.java index 7daec805..afc8e466 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionCommitBoundaryTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionCommitBoundaryTest.java @@ -119,9 +119,9 @@ private EventSubmissionLocationRequestDto location() { } private EventSubmissionCreateRequestDto createRequest() { - return new EventSubmissionCreateRequestDto(EventSubmissionType.FESTIVAL, "부산불꽃축제", + return new EventSubmissionCreateRequestDto(EventSubmissionType.FESTIVAL, null, "부산불꽃축제", "부산문화관광축제조직위원회", LocalDate.now(ZoneOffset.UTC).plusDays(30), - LocalDate.now(ZoneOffset.UTC).plusDays(31), null, "멀티불꽃쇼, 드론 라이트쇼 운영", + LocalDate.now(ZoneOffset.UTC).plusDays(31), null, "멀티불꽃쇼, 드론 라이트쇼 운영", null, "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", "event-submissions/pending/%d/%s.jpg".formatted(userId, UUID.randomUUID()), List.of(location())); } @@ -130,7 +130,7 @@ private EventSubmissionCreateRequestDto createRequest() { private EventSubmissionUpdateRequestDto updateRequest() { return new EventSubmissionUpdateRequestDto("부산불꽃축제 2026", "부산문화관광축제조직위원회", LocalDate.now(ZoneOffset.UTC).plusDays(30), LocalDate.now(ZoneOffset.UTC).plusDays(31), null, - "멀티불꽃쇼, 드론 라이트쇼 운영", "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", + "멀티불꽃쇼, 드론 라이트쇼 운영", null, "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", null, List.of(location())); } @@ -140,9 +140,9 @@ private EventSubmissionUpdateRequestDto updateRequest() { LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); EventSubmission submission = EventSubmission.submit( "FM-2026-%04d".formatted(submissionRepository.nextSubmissionSequence()), - userId, EventSubmissionType.FESTIVAL, now); + userId, EventSubmissionType.FESTIVAL, null, now); submission.updateForm("부산불꽃축제", "부산문화관광축제조직위원회", LocalDate.of(2026, 11, 7), - LocalDate.of(2026, 11, 7), null, "멀티불꽃쇼", "광안리 일원에서 열리는 부산 대표 불꽃 축제", + LocalDate.of(2026, 11, 7), null, "멀티불꽃쇼", null, "광안리 일원에서 열리는 부산 대표 불꽃 축제", "event-submissions/original/%d/kept.jpg".formatted(userId), now); submission.replaceLocations(List.of(new EventSubmissionLocation("16860_11512", List.of(new EventSubmissionAreaRect(16859, 16861, 11509, 11515))))); diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java index 3a31fd97..189983dd 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java @@ -15,9 +15,11 @@ import java.time.Clock; import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; @@ -26,7 +28,10 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.entity.EventSeries; import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; import com.msg.fillmap.event.submission.dto.EventSubmissionLocationRequestDto; @@ -54,17 +59,19 @@ class EventSubmissionValidationTest { private EventSubmissionRepository submissionRepository; private EventSubmissionImageStore imageStore; + private EventOccurrenceRepository occurrenceRepository; private EventSubmissionServiceImpl service; @BeforeEach void setUp() { submissionRepository = mock(EventSubmissionRepository.class); imageStore = mock(EventSubmissionImageStore.class); + occurrenceRepository = mock(EventOccurrenceRepository.class); given(submissionRepository.nextSubmissionSequence()).willReturn(7L); given(imageStore.confirm(anyLong(), anyString())).willReturn("event-submissions/original/42/a.jpg"); service = new EventSubmissionServiceImpl(submissionRepository, mock(EventSubmissionStatusHistoryRepository.class), imageStore, - mock(ZoneNameQueryService.class), mock(GridQueryService.class), KST_NEW_DAY); + mock(ZoneNameQueryService.class), mock(GridQueryService.class), occurrenceRepository, KST_NEW_DAY); } private EventSubmissionCreateRequestDto festival() { @@ -75,9 +82,15 @@ private EventSubmissionCreateRequestDto festival() { private EventSubmissionCreateRequestDto request(EventSubmissionType type, String programDescription, String operatingHours, LocalDate startsOn, LocalDate endsOn, List locations) { - return new EventSubmissionCreateRequestDto(type, "부산불꽃축제", "부산문화관광축제조직위원회", - startsOn, endsOn, operatingHours, programDescription, "광안리 일원에서 열리는 부산 대표 불꽃 축제", - "event-submissions/pending/42/a.jpg", locations); + return request(type, null, programDescription, operatingHours, null, startsOn, endsOn, locations); + } + + private EventSubmissionCreateRequestDto request(EventSubmissionType type, Long parentOccurrenceId, + String programDescription, String operatingHours, String participationMethod, LocalDate startsOn, + LocalDate endsOn, List locations) { + return new EventSubmissionCreateRequestDto(type, parentOccurrenceId, "부산불꽃축제", "부산문화관광축제조직위원회", + startsOn, endsOn, operatingHours, programDescription, participationMethod, + "광안리 일원에서 열리는 부산 대표 불꽃 축제", "event-submissions/pending/42/a.jpg", locations); } private EventSubmissionCreateRequestDto withLocations(List locations) { @@ -343,6 +356,208 @@ class SubmissionNo { } } + /** + * 이벤트 참여형 (MSG-502). 부모 종료 판정이 서버 시각과의 비교라 정각 경계를 고정 Clock 으로만 잡을 수 + * 있고, 그 Clock 을 실 컨텍스트에 꽂을 수 없어 여기(목)에서 검증한다 — 저장·상세·재제출의 DB 동작은 + * EventSubmissionControllerTest 의 이벤트 참여형 묶음이 실 DB 로 본다. + */ + @Nested + @DisplayName("이벤트 참여형 (MSG-502)") + class Participation { + + /** 고정 Clock 의 서버 시각 — 부모 종료 판정의 기준이다. */ + private static final LocalDateTime NOW = LocalDateTime.of(2026, 11, 6, 16, 30); + + private static final long PARENT_ID = 1L; + + private EventSubmissionCreateRequestDto 이벤트_신청(Long parentOccurrenceId, + List locations) { + return request(EventSubmissionType.EVENT, parentOccurrenceId, null, null, + "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다", + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 15), locations); + } + + private EventSubmissionCreateRequestDto 이벤트_신청() { + return 이벤트_신청(PARENT_ID, List.of(location(rect(16859, 16861, 11509, 11515)))); + } + + /** 부모 회차 스텁 — 이 검증이 읽는 것은 종료 시각 하나다. */ + private void 부모가_있다(LocalDateTime endsAt) { + EventOccurrence parent = new EventOccurrence(new EventSeries("msg502-series", "부산국제영화제"), + "msg502-occ"); + parent.update(parent.getSeries(), "부산국제영화제", "부산", endsAt.minusDays(9), endsAt, 1, 2, 1, 2); + given(occurrenceRepository.findById(PARENT_ID)).willReturn(Optional.of(parent)); + } + + private void 거부된다(EventSubmissionCreateRequestDto request, EventErrorCode errorCode) { + assertThatThrownBy(() -> service.submit(USER_ID, request)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", errorCode); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("없는 회차로 참여를 신청하면 13440 이다 — 존재를 은닉하지 않는다") + void 없는_회차로_참여_신청하면_거부한다() { + given(occurrenceRepository.findById(PARENT_ID)).willReturn(Optional.empty()); + + 거부된다(이벤트_신청(), EventErrorCode.PARENT_EVENT_NOT_FOUND); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("종료된 회차로 참여를 신청하면 13441 이다") + void 종료된_회차로_참여_신청하면_거부한다() { + 부모가_있다(NOW.minusDays(1)); + + 거부된다(이벤트_신청(), EventErrorCode.PARENT_EVENT_CLOSED); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("종료 정각의 회차는 신청이 거부된다 — 승인 이벤트 목록에서 빠지는 순간과 같은 경계다") + void 종료_정각의_회차는_참여_신청이_거부된다() { + 부모가_있다(NOW); + + 거부된다(이벤트_신청(), EventErrorCode.PARENT_EVENT_CLOSED); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("영상 업로드 유예 중인 회차도 신청이 거부된다 — 유예는 이미 종료 이후다") + void 유예_중인_회차도_참여_신청이_거부된다() { + 부모가_있다(NOW.minusDays(3)); + + 거부된다(이벤트_신청(), EventErrorCode.PARENT_EVENT_CLOSED); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("종료 직전 회차는 신청이 된다 — 목록 노출 조건과 같은 판정이다") + void 종료_직전_회차는_참여_신청이_된다() { + 부모가_있다(NOW.plusSeconds(1)); + + assertThatCode(() -> service.submit(USER_ID, 이벤트_신청())).doesNotThrowAnyException(); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청에 부모 회차가 없으면 13439 다") + void 이벤트_신청에_부모_회차가_없으면_거부한다() { + 거부된다(이벤트_신청(null, List.of(location(rect(100, 100, 200, 200)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("축제 신청에 부모 회차가 실려 오면 13439 다 — 자기 유형이 아닌 필드다") + void 축제_신청에_부모_회차가_실려_오면_거부한다() { + 거부된다(request(EventSubmissionType.FESTIVAL, PARENT_ID, "멀티불꽃쇼, 드론 라이트쇼", null, null, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), + List.of(location(rect(100, 100, 200, 200)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("축제 신청에 참여 방식이 실려 오면 13439 다") + void 축제_신청에_참여_방식이_실려_오면_거부한다() { + 거부된다(request(EventSubmissionType.FESTIVAL, null, "멀티불꽃쇼, 드론 라이트쇼", null, + "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다", + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), + List.of(location(rect(100, 100, 200, 200)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청에 참여 방식이 없으면 13439 다") + void 이벤트_신청에_참여_방식이_없으면_거부한다() { + 부모가_있다(NOW.plusDays(10)); + + 거부된다(request(EventSubmissionType.EVENT, PARENT_ID, null, null, null, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 15), + List.of(location(rect(100, 100, 200, 200)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청에 주요 프로그램이나 운영 시간이 실려 오면 13439 다") + void 이벤트_신청에_다른_유형의_항목이_실려_오면_거부한다() { + 부모가_있다(NOW.plusDays(10)); + String 참여_방식 = "부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다"; + + 거부된다(request(EventSubmissionType.EVENT, PARENT_ID, "멀티불꽃쇼, 드론 라이트쇼", null, 참여_방식, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 15), + List.of(location(rect(100, 100, 200, 200)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + + 거부된다(request(EventSubmissionType.EVENT, PARENT_ID, null, "11:00 ~ 20:00", 참여_방식, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 15), + List.of(location(rect(100, 100, 200, 200)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청의 위치는 1곳이면 통과하고 대표 격자가 계산된다") + void 이벤트_신청의_위치는_1곳이면_통과한다() { + 부모가_있다(NOW.plusDays(10)); + + EventSubmission submission = submitted(이벤트_신청()); + + assertThat(submission.getLocations()).singleElement() + .extracting(EventSubmissionLocation::getRepresentativeGridId) + .isEqualTo("16860_11512"); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청에 위치가 2곳이면 13439 다 — 대표 위치는 한 곳이다") + void 이벤트_신청에_위치가_2곳이면_거부한다() { + 부모가_있다(NOW.plusDays(10)); + + 거부된다(이벤트_신청(PARENT_ID, + List.of(location(rect(100, 100, 200, 200)), location(rect(300, 300, 400, 400)))), + EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청에 위치가 없으면 13431 이다 — 유형 무관 공통 규칙 그대로다") + void 이벤트_신청에_위치가_없으면_거부한다() { + 부모가_있다(NOW.plusDays(10)); + + 거부된다(이벤트_신청(PARENT_ID, List.of()), EventErrorCode.INVALID_SUBMISSION_AREA); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 위치도 81칸 상한이 적용된다 — 기존 검증 경로를 분기 없이 탄다") + void 이벤트_위치도_81칸_상한이_적용된다() { + 부모가_있다(NOW.plusDays(10)); + + 거부된다(이벤트_신청(PARENT_ID, List.of(location(rect(100, 108, 200, 208), rect(300, 300, 400, 400)))), + EventErrorCode.SUBMISSION_AREA_LIMIT_EXCEEDED); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이벤트 신청이 부모 참조와 참여 방식을 저장하고 심사 중 상태와 FM 꼴 번호를 받는다") + void 이벤트_신청이_부모_참조와_참여_방식을_저장한다() { + 부모가_있다(NOW.plusDays(10)); + + EventSubmission submission = submitted(이벤트_신청()); + + assertThat(submission.getParentEventOccurrenceId()).isEqualTo(PARENT_ID); + assertThat(submission.getParticipationMethod()) + .isEqualTo("부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다"); + assertThat(submission.getStatus().name()).isEqualTo("IN_REVIEW"); + assertThat(submission.getSubmissionNo()).isEqualTo("FM-2026-0007"); + } + } + @Nested @DisplayName("대표 이미지") class Image {