diff --git a/.claude/docs/status.md b/.claude/docs/status.md index edf54f85..80a1bc94 100644 --- a/.claude/docs/status.md +++ b/.claude/docs/status.md @@ -124,6 +124,7 @@ - **없는 것**: 댓글·도움돼요 API(441)와 그 경로의 상호작용 잠금 배선(13422 — 가드는 442에 있음), 익명 heartbeat rate limit(공개 근사값이라 부풀리기 수용 — 스펙 D4) - 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-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/explainers/MSG-498-submission-flow.html b/docs/explainers/MSG-498-submission-flow.html new file mode 100644 index 00000000..0710fccc --- /dev/null +++ b/docs/explainers/MSG-498-submission-flow.html @@ -0,0 +1,174 @@ +MSG-498 행사 등재 신청 흐름 + + +
+
FillMap BE · Explainer · MSG-498
+

행사 신청이 반려됐다 다시 심사로 돌아가기까지

+

행사 운영자가 콘솔에서 행사를 신청하고 반려본을 고쳐 다시 내는 API 5개(MSG-498). 알아둘 것은 다섯이다 — 재제출 전이의 순서, 81칸을 세는 방식, 연관관계를 양방향으로 잡은 이유, 이미지가 커밋 경계에서 하는 일, 그리고 인가 코드가 한 줄도 없는 이유.

+ +

1전체 흐름 — 신청은 폼 하나로 만들어졌다 폼 하나로 고쳐진다

+
+
sequenceDiagram
+    autonumber
+    participant O as 행사 운영자(콘솔)
+    participant S3 as S3
+    participant API as EventSubmissionService
+    participant DB as PostgreSQL
+    O->>API: ① POST /image/presigned-url (jpg·png, 10MB)
+    API-->>O: uploadUrl + pending 키
+    O->>S3: ② PUT 업로드 (서버 경유 없음)
+    O->>API: ③ POST /event-submissions (폼 + pending 키)
+    API->>S3: HEAD 실측 → original 로 복사(uuid 신규)
+    API->>DB: 신청 + 위치 + 사각형 + 이력(IN_REVIEW)
+    API-->>O: FM-2026-0007 · IN_REVIEW
+    Note over API,DB: 관리자 심사(MSG-500)가 APPROVED / REJECTED 로 보낸다
+    O->>API: ④ GET /{id} — 반려 항목 코드와 사유 확인
+    O->>API: ⑤ PATCH /{id} (유형 빼고 전체 교체)
+    API->>DB: 조건부 UPDATE → 위치 통째 교체 + 이력(IN_REVIEW)
+    API-->>O: 같은 신청 번호 · IN_REVIEW
+
    +
  • 재제출은 부분 수정이 아니다 — 신청 하나가 폼 하나로 쓰였다 폼 하나로 고쳐지는 단위라, 위치 목록은 통째로 갈아끼우고 대표 격자를 전부 다시 계산한다. 검증 3규칙도 제출과 똑같이 다시 탄다.
  • +
  • 유형만 못 바꾼다 — 유형이 바뀌면 필수 항목이 통째로 달라져 사실상 새 신청이다. 바꾸려면 새로 낸다(반려된 기존 건은 남아 이력이 보존된다).
  • +
  • 신청 번호는 불변FM-{KST 연도}-{4자리 순번}, 순번은 전역 시퀀스라 연도별 리셋 기계가 없다. 9999를 넘으면 자릿수가 자연히 늘어난다.
  • +
+
+ +

2재제출 전이의 순서 계약

+
+

"반려일 때만 재제출"을 읽고 나서 쓰면 동시 요청 둘이 모두 REJECTED를 관찰하고 둘 다 성공한다(이력 두 줄, 진 요청이 남의 이미지를 삭제). 그래서 검사와 갱신이 한 문장이다.

+
UPDATE EventSubmission s SET s.status = IN_REVIEW, s.updatedAt = :now
+ WHERE s.id = :id AND s.userId = :userId AND s.status = REJECTED
+
    +
  1. 술어에 userId가 있다남의 행은 어떤 경로로도 수정되지 않는다 — 뒤의 재로드나 롤백에 기대는 구조가 아니다.
  2. +
  3. 영향 행 0이면 소유 조회로 분기행이 없으면 13430, 있는데 REJECTED가 아니면 13434(동시 재제출의 패자 포함). id + status로만 분기하면 남의 REJECTED 행이 13434로 새어 존재가 드러난다 정보 노출 — 분기 기준이 반드시 소유 조회여야 하는 이유.
  4. +
  5. 재로드는 UPDATE 뒤순서를 뒤집어 UPDATE 전에 로드한 엔티티를 고쳐 flush하면, 벌크 UPDATE가 영속성 컨텍스트를 우회한 탓에 스테일한 REJECTED가 도로 덮어써진다. clearAutomatically = true가 그 스냅숏을 비운다.
  6. +
  7. 선행 조회는 읽기 전용유형은 바꿀 수 없으므로 검증 기준이 저장값이다. 그래서 UPDATE보다 앞에 소유 조회가 한 번 있는데, 아무것도 수정하지 않아 ③의 덮어쓰기 경로를 만들지 않는다.
  8. +
+
존재 은닉은 코드 경로 하나로 성립한다 — 모든 조회가 findByIdAndUserId다. 없는 id와 남의 id가 같은 빈 결과가 되어 응답이 갈릴 자리 자체가 없다. 테스트는 두 응답 본문을 문자열로 대조한다.
+
+ +

381칸은 합집합으로 센다

+
+

위치 하나의 영역 상한은 격자 81칸인데, 사각형 넓이의 합이 아니라 전개한 격자 집합의 크기로 판정한다.

+
+ + + + +
입력합산으로 세면합집합으로 세면
9×9 사각형 하나81 통과81 통과
같은 9×9를 두 번 그림162 거부81 통과
9×9 + 떨어진 1칸82 거부82 거부
+

같은 영역을 어떤 사각형 조합으로 그렸든 결과가 같아야 한다 — 합산으로 세면 정당한 81칸 영역이 그리는 방식에 따라 거부되는 비결정성이 생긴다. 대표 격자 산출기(RepresentativeGridResolver)가 사각형이 아니라 격자 집합을 입력으로 받는 것과 같은 원칙이다.

+
    +
  • 전개 전에 거대 사각형을 거른다 — 사각형 하나의 칸 수를 long으로 먼저 구해 81을 넘으면 전개 없이 거부. 이게 없으면 1..99999 사각형 하나가 메모리를 태운다 OOM
  • +
  • 인덱스 범위 검사가 그보다 앞 (0 초과 100,000 미만) — 대표 격자 산출기의 long 오버플로 안전 논증이 이 상한 위에 서 있다. 그래서 상수를 RepresentativeGridResolver로 승격해 시더와 신청 접수가 같은 값 하나를 쓴다(사본 금지).
  • +
  • 대표 격자는 서버가 정한다 — 홀수 직사각형이면 정중앙(21칸 예시 → 16860_11512), 아니면 영역 중심 최근접. 신청자가 지정하는 경로는 없다.
  • +
+
+ +

4왜 양방향으로 잡나 (단방향으로 가봤다가 배운 것)

+
+

신청 ↔ 위치는 양방향이다. 연관 주인은 FK를 가진 자식(EventSubmissionLocation.submission)이고, 부모 컬렉션은 mappedBy로 읽기 쪽이다.

+
// 자식 — 연관 주인
+@ManyToOne(fetch = FetchType.LAZY, optional = false)
+@JoinColumn(name = "event_submission_id", nullable = false)
+private EventSubmission submission;
+
+// 부모 — mappedBy 읽기 쪽 (상한 20, 결정 D-10)
+@OneToMany(mappedBy = "submission", cascade = ALL, orphanRemoval = true, fetch = LAZY)
+@OrderBy("displayOrder")
+private List<EventSubmissionLocation> locations = new ArrayList<>();
+
+ + + + + +
양방향(채택)단방향 @OneToMany + @JoinColumn
관행김영한 교재의 "일대다 단방향 지양 · 다대일을 연관 주인으로", Vlad Mihalcea의 선호 순위(@ManyToOne ≥ 양방향 > 단방향 @OneToMany)와 합치 이어받는 사람의 기대와 같음주류가 권하지 않는 형태 — 이 도메인 하나만 레포에서 다른 모양이 된다 읽는 비용
연관 주인FK를 가진 쪽이 주인이라 매핑과 스키마가 같은 방향을 본다FK는 자식 테이블에 있는데 주인은 부모 — 방향이 어긋난 채로 성립한다
동기화두 참조를 맞춰야 하지만 편의 메서드에 가둔다replaceLocations가 컬렉션·부모 참조·순번을 한 곳에서 세팅하고 서비스는 그것만 부른다맞출 참조가 하나뿐. 단 이 이점은 편의 메서드 하나로 상쇄되는 크기다
프록시·순환연관 필드를 equals/hashCode/toString에서 제외해 막는다(컨벤션 영속 계층 3항) — 규칙으로 지키는 자리필드가 없어 규칙 자체가 불필요 구조로 차단
+

대신 지켜야 하는 것 셋. ① 컬렉션에 크기 상한 근거 주석(위치 20개) — 컨벤션이 컬렉션 매핑에 요구한다. ② 연관 필드는 equals/hashCode/toString 밖. ③ 참조 동기화는 편의 메서드 밖으로 새지 않게. 셋 다 코드에 주석으로 근거를 달아 뒀고, MSG-500이 붙일 매핑도 같은 형태를 쓴다.

+
왕복에서 건진 실측: 중간에 단방향(@OneToMany + @JoinColumn)으로 한 번 갔다가 되돌렸는데, 그때 통합 테스트 26건이 NOT NULL 위반 없이 통과했다. "단방향 일대다는 자식을 빈 FK로 INSERT한 뒤 UPDATE해서 NOT NULL에 걸린다"는 통설은 nullable을 지정하지 않은 경우의 이야기고, @JoinColumn(nullable = false)면 Hibernate가 INSERT에 FK를 함께 싣는다. 그러니 이번 선택은 동작 문제를 피한 게 아니라 관행을 따른 것이다 — 근거를 실제보다 세게 적어두면 다음 사람이 잘못된 이유로 이 결정을 인용한다. 스펙 결정 기록 D-12.
+
+ +

5대표 이미지 — 커밋 경계에서 벌어지는 일

+
+

이미지 복사(S3)는 트랜잭션 밖 부수효과라, DB 커밋과 어긋나는 두 방향을 각각 막는다.

+ + + + + +
시점하는 일안 하면
확정 중내 pending 프리픽스 → HEAD 실존 → 실측 크기 → original/ 복사(uuid 신규 발급)지어낸 키·0바이트·10MB 초과가 대표 이미지가 된다. uuid를 재사용하면 두 신청이 한 객체를 공유해 재제출 삭제가 남의 이미지를 지운다
롤백 시afterCompletion(STATUS_ROLLED_BACK)에서 방금 복사한 original 삭제아무도 참조하지 않는 고아가 영구히 남는다(라이프사이클은 pending 전용)
커밋 후복사에 성공한 pending 삭제 · 재제출로 밀려난 이전 확정본 삭제같은 pending 키로 두 번 신청할 수 있다(삭제해 두면 재확정 시도가 HEAD 실패 → 13436)
+

STATUS_UNKNOWN(커밋 결과 불명)에는 지우지 않는다 — 커밋됐을 수 있는 이미지를 지우면 유실이고, 고아는 비용 문제일 뿐이다. 커밋 후 정리는 전부 베스트 에포트라 실패해도 신청은 유효하다.

+

재제출의 이미지 유지: imageS3Key를 생략하거나 null로 보내면 기존 이미지 유지, pending 키를 보내면 교체. 상세 응답이 저장 키를 노출하지 않으므로(열람은 presigned GET 하나) 클라이언트가 확정 키를 알 수도, 보낼 수도 없다.

+
배포 전 필요: event-submissions/pending/ 프리픽스의 S3 라이프사이클 만료 규칙(확정 없이 버려진 업로드 회수). 확정본 event-submissions/original/은 공개 읽기를 열지 않는다 — 열람자가 신청 소유자와 관리자뿐이라 presigned GET으로 충분하다.
+
+ +

6인가 코드가 한 줄도 없다

+
+

이 티켓은 역할 검사를 새로 짜지 않는다. /api/org/** 프리픽스 아래에 경로를 만드는 것만으로 두 겹이 자동으로 걸린다.

+
    +
  • SecurityConfig matcher(MSG-496) — 비로그인 401, USER·ADMIN 403. ADMIN도 막힌다(콘솔은 행사 운영자 전용).
  • +
  • 비밀번호 게이트 인터셉터(MSG-497) — 초기 비밀번호 상태의 ORG 계정은 403·2441. 매 요청 DB를 봐서 변경 즉시 풀린다.
  • +
  • 소유권(FR-14)만 서비스 계층 몫 — "내 신청인가"는 경로가 아니라 데이터 질문이라 matcher가 답할 수 없다.
  • +
+
그래서 인가 테스트가 "코드 검증"이 아니라 전제 확인이다 — 새 실경로에 프리픽스 규칙이 실제로 걸리는지 4건으로 확인한다. 필터·인터셉터는 핸들러 앞에서 거절하므로 경로에 오타가 있어도 401·403은 그대로 나온다. 그래서 ORG 200 성공 케이스가 실경로 존재의 시금석이다.
+
+ +
+정본: docs/spec/MSG-498.md(결정 기록 12건) · PRD docs/prd/event-submission.md(v2.1) · 코드 com.msg.fillmap.event.submission.* · 스키마 V49__event_submissions.sql
+검증: 신규 테스트 55건(단위 25 · 통합 30) green, event.*·global.* 415건 회귀 green (2026-08-29). 심사·승인은 MSG-500, 이벤트 참여형은 MSG-501·502 +
+
diff --git a/docs/prd/event-submission.md b/docs/prd/event-submission.md index 437adcee..a1fa987d 100644 --- a/docs/prd/event-submission.md +++ b/docs/prd/event-submission.md @@ -2,6 +2,9 @@ > 티켓: 미발행 · 작성일: 2026-08-27 · 작성: prd-writer > 상태: 검토됨 (2026-08-28 승인. ver 14 v2 시안 `15525:8613`과 피그마 댓글, 게이트 문답 4건 반영) (2026-08-28 MSG-501 최소 개정: 모달 조회 FR·API 표면 추가) +> 개정: 2026-08-28 v2.1. 세 번째 등록 유형을 이벤트 참여형으로 재편했다(승인 이벤트 선택 후 +> 참여 신청, MSG-501·MSG-502 분리. MSG-498은 지역축제·팝업스토어 한정). 근거는 지라 MSG-498 +> 코멘트, 사용자 승인. 개정 범위는 유형 재편 반영에 한정한다 ## 1. 문제 상황 @@ -20,7 +23,7 @@ 확인(운영자 6-1), 비로그인 진입점 섹션(`/ops` 랜딩·지도 홈 레일 슬롯)이 새로 생겼다. 행사 운영자가 직접 신청하고 관리자가 심사해 승인하는 구조다. 이 방향은 구 행사방 PRD(`docs/prd/event-room.md`)의 "운영자용 행사 등록 화면은 만들지 않는다" 결정을 뒤집는다. 그 결정의 전제는 등재 대상이 초대형 행사 몇 개뿐이라는 -것이었는데, 시안은 등록 유형을 지역축제와 팝업스토어와 행사방까지 넓혔다. +것이었는데, 시안은 등록 유형을 지역축제와 팝업스토어와 이벤트 참여형까지 넓혔다. 용어는 glossary.md "행사 등재" 항목을 따른다. 행사를 등록하는 외부 주체는 언제나 두 단어를 붙여 "행사 운영자"라 부르고, 필맵 쪽은 사용자 대면 문구에서 운영팀, 심사와 계정 발급 주체로는 @@ -73,7 +76,7 @@ JWT role 클레임 또는 로그인 응답의 role 필드로 한다. 서버 몫 | ID | 요구사항 | 우선순위 | |----|----------|----------| -| FR-7 | 행사 운영자는 등록 유형(지역축제, 팝업스토어, 행사방) 중 하나를 골라 행사를 신청할 수 있다. 유형마다 기본 정보 항목이 다르다(아래 표) | Must | +| FR-7 | 행사 운영자는 등록 유형(지역축제, 팝업스토어, 이벤트 참여형) 중 하나를 골라 행사를 신청할 수 있다. 유형마다 기본 정보 항목이 다르다(아래 표). 이벤트 참여형은 독립 행사를 새로 만드는 신청이 아니라 승인된 이벤트를 골라 참여를 신청하는 구조다(2026-08-28 v2.1 재편, 표 아래 설명) | Must | | FR-8 | 행사 위치는 주소가 아니라 지도 위 격자 사각형 영역으로 지정한다. 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있다. 형식은 기존 행사 시드의 areaRects[^2]와 같다. 위치에 이름을 붙이지 않고, 순번과 지역 라벨(격자 표시명 재료)로만 식별한다(피그마 댓글 #102) | Must | | FR-24 | 위치 하나가 쓸 수 있는 격자는 사각형들을 합쳐 최대 81칸(9×9 유래)이다. 초과는 제출에서 막는다(피그마 댓글 #106·#109 "1km로 합의", v2 시안 반영 완료. 미해결 질문 2 해소) | Must | | FR-9 | 위치의 대표 격자[^3]는 신청자가 정하지 않고 서버가 계산한다 | Must | @@ -90,12 +93,19 @@ JWT role 클레임 또는 로그인 응답의 role 필드로 한다. 서버 몫 |------|------| | 지역축제 | 축제명, 주최 기관, 축제 기간, 주요 프로그램, 축제 소개, 대표 이미지 | | 팝업스토어 | 팝업명, 브랜드/운영사, 운영 기간, 운영 시간, 팝업 소개, 대표 이미지 | -| 행사방 | 행사방 이름, 운영 주체, 공개 기간, 참여 방식, 행사방 소개, 커버 이미지 | +| 이벤트 참여형 | 이름(참여로 생기는 하위 실체의 명칭은 용어 정리 중), 운영 주체, 공개 기간, 참여 방식, 소개, 커버 이미지. 입력 전에 시·도 칩과 승인 이벤트 목록 모달([3-1] 신규)에서 부모 이벤트를 고른다 | 이미지는 권장 16:9, JPG 또는 PNG, 최대 10MB(시안 문구). 주요 프로그램 같은 서술 항목은 구조화하지 않고 자유 문자열로 받되 최소 10자를 요구한다(피그마 댓글 #100 "String으로 받고 최소 10자 제한", [4A] 주요 프로그램 필드 앵커). +등록 유형 재편(2026-08-28 v2.1, 사용자 승인): 세 번째 유형이 구 "행사방"에서 **이벤트 +참여형**으로 바뀌었다. 이벤트는 지역축제와 팝업스토어를 제외한 큰 행사 카테고리를 가리키는 +확정 용어다(MSG-503). 유형 카드를 누르면 페이지 이동 대신 모달이 뜨고, 시·도 칩과 승인 이벤트 +목록(건수, 유형 배지, 기간, 장소 라벨, 이름 검색)에서 부모를 고른 뒤 입력을 계속한다. 티켓 +배분은 승인 이벤트 목록 조회가 MSG-501, 참여 신청이 MSG-502이고, MSG-498은 지역축제와 +팝업스토어 2개 독립 유형만 다룬다. + ### 심사 (관리자) | ID | 요구사항 | 우선순위 | @@ -156,7 +166,7 @@ classDiagram } class EventSubmission { <<신규 엔티티>> - +유형 (지역축제/팝업스토어/행사방) + +유형 (지역축제/팝업스토어/이벤트 참여형) +유형별 기본 정보 +위치 목록 (areaRects) +상태 (심사 중/승인/반려) @@ -225,13 +235,14 @@ admin/events 2개(목록·unpublish), auth/password 4개(status·change·reset-r 작성자(강정민) 확인으로 2건이 더 닫혔다. 남은 미확정은 2건이며 각각 게이트가 걸린 티켓을 명시했다. -- [ ] **승인 행사 노출 방식과 행사방 유형의 등록 구조** (시안 열린 판단 1, 미확정 유지): - 2026-08-28 작성자 설명으로 방향은 잡혔다. 행사방 유형(4C)은 독립 행사를 새로 만드는 게 - 아니라 지역을 고르고 기존 큰 행사(행사방)를 고른 뒤 그 하위 위치(부산물)를 추가하는 - 구조인데, v2 [4C] 디자인에는 아직 반영되지 않았다. 지역축제와 팝업스토어 승인분의 노출 - 형태(행사방 편입 / 칩 / 규모별 분기)는 여전히 열려 있다. **MSG-498(4C 입력 구조)과 - MSG-500(approve) 스펙 착수 전 확정이 게이트다.** 확정되면 SRS FR-EVENT-01의 등재 기준 - 서술을 함께 고친다 +- [ ] **승인 행사 노출 방식과 이벤트 참여형의 세부** (시안 열린 판단 1, 미확정 유지): + 2026-08-28 v2.1 디자인 개정으로 입력 구조는 잡혔다. 세 번째 유형은 독립 행사를 새로 만드는 + 게 아니라 시·도와 승인 이벤트를 모달([3-1] 신규)에서 고른 뒤 그 아래 참여를 신청하는 + 구조다. 지역축제와 팝업스토어 승인분의 노출 형태(이벤트 편입 / 칩 / 규모별 분기)와 참여로 + 이벤트 아래 생기는 하위 실체의 명칭은 여전히 열려 있다. **MSG-502(참여 신청)와 + MSG-500(approve) 스펙 착수 전 확정이 게이트다.** v2.1 재편으로 이벤트 참여형이 빠진 + MSG-498에는 이 게이트가 걸리지 않는다. 확정되면 SRS FR-EVENT-01의 등재 기준 서술을 함께 + 고친다 - [ ] **승인 후 일정 수정 정책** (시안 열린 판단 3): 2026-08-28 유보 확정. MVP에서는 구현하지 않고 시안대로 "운영팀에 문의"로 안내한다. A 재신청 재심사 / B 관리자 직접 수정 / C 항목별 분리 중 후속 결정이 나면 그때 티켓을 새로 판다 diff --git a/docs/rtm.md b/docs/rtm.md index 9b5f1bd9..8518acd2 100644 --- a/docs/rtm.md +++ b/docs/rtm.md @@ -5,7 +5,7 @@ **병합 충돌이 나면 손으로 합치지 말고 재생성한다.** 어느 쪽이든 골라 충돌만 없앤 뒤(`git checkout --ours docs/rtm.md` 등) 스크립트를 다시 돌려 그 결과를 커밋한다. 이 표는 두 원천에서 계산되는 값이라 양쪽 diff를 섞으면 어느 쪽과도 다른 상태가 된다. -요약: FR 288건 중 테스트 연결 254건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 27건, 성격상 테스트 비대상 7건 별도) +요약: FR 288건 중 테스트 연결 256건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 25건, 성격상 테스트 비대상 7건 별도) | 요구사항 ID | SRS 상태 | 검증 테스트 | |---|---|---| @@ -199,8 +199,8 @@ | FR-EVENT-10 | 구현됨 | EventInteractionLockTest, EventLifecycleGuardTest, EventNotificationSubscriptionRepositoryTest, EventSeederScheduleChangeTest, EventVideoQueryServiceTest, EventVideoUploadConcurrencyTest, EventVideoUploadServiceTest | | FR-EVENT-11 | 구현됨 | EventViewerCacheFailureControllerTest, EventViewerControllerTest, EventViewerServiceImplTest | | FR-EVENT-12 | 구현됨 | MissionAwardQueryTest, MissionProgressQueryTest, MissionVideoCountQueryTest, MissionVideoListQueryTest, MissionVisitedGridQueryTest | -| FR-EVENT-13 | 계획 | (없음) | -| FR-EVENT-14 | 계획 | (없음) | +| FR-EVENT-13 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest, EventSubmissionImageStoreTest, EventSubmissionValidationTest | +| FR-EVENT-14 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest | | FR-EVENT-15 | 계획 | (없음) | | FR-EVENT-16 | 구현됨 | EventOccurrenceRepositoryTest, OrgEventControllerTest, OrgEventQueryServiceTest | | FR-ROUTE-01 | 진행 중 | AnonymousReadAccessHttpTest, RouteCandidateCollectorTest, RouteControllerTest, RouteIntentClientTest, RouteRecommendServiceTest | @@ -278,8 +278,8 @@ | FR-AUTH-11 | 구현됨 | AuthControllerTest, AuthServiceTest | | FR-AUTH-12 | 계획 | (없음) | | FR-AUTH-13 | 구현됨 | AdminOrgAccountControllerTest, AdminOrgAccountRequestControllerTest, AuthControllerTest, AuthServiceTest, InitialPasswordResendConcurrencyTest, OrgAccountApprovalConcurrencyTest, OrgAccountRequestControllerTest, OrgAccountRequestPersistenceTest | -| FR-AUTH-14 | 구현됨 | JwtTokenProviderTest, OidcLoginServiceTest, OrgAccountControllerTest, OrgAuthorizationTest, PasswordControllerTest, UserProfileIntegrationTest, UserRoleOrgPersistenceTest | -| FR-AUTH-15 | 구현됨 | AdminOrgAccountControllerTest, OrgAccountControllerTest, OrgAccountSecurityPersistenceTest, PasswordControllerTest | +| FR-AUTH-14 | 구현됨 | EventSubmissionAuthorizationTest, JwtTokenProviderTest, OidcLoginServiceTest, OrgAccountControllerTest, OrgAuthorizationTest, PasswordControllerTest, UserProfileIntegrationTest, UserRoleOrgPersistenceTest | +| FR-AUTH-15 | 구현됨 | AdminOrgAccountControllerTest, EventSubmissionAuthorizationTest, OrgAccountControllerTest, OrgAccountSecurityPersistenceTest, PasswordControllerTest | | FR-AUTH-16 | 구현됨 | InitialPasswordResendConcurrencyTest, JwtTokenProviderTest, MailSenderConfigurationTest, PasswordControllerTest, PasswordResetInvalidationFailureTest, RedisPasswordResetTokenStoreTest, RefreshTokenServiceTest | | FR-USER-01 | 구현됨 | UserProfileControllerTest, UserProfileIntegrationTest | | FR-USER-02 | 구현됨 | UserProfileControllerTest, UserProfileIntegrationTest | @@ -322,8 +322,6 @@ - FR-EVENT-03 (폐기됨) - FR-EVENT-04 (폐기됨) - FR-EVENT-05 (폐기됨) -- FR-EVENT-13 (계획) -- FR-EVENT-14 (계획) - FR-EVENT-15 (계획) - FR-ROUTE-15 (계획) - FR-NOTI-12 (계획) diff --git a/docs/spec/MSG-498.md b/docs/spec/MSG-498.md new file mode 100644 index 00000000..d43475c8 --- /dev/null +++ b/docs/spec/MSG-498.md @@ -0,0 +1,767 @@ +# MSG-498: 행사 운영자가 콘솔에서 행사를 신청하고 반려본을 고쳐 다시 낸다 + +**Owner**: B (event 도메인은 Owner B다, CLAUDE.md 협업 원칙의 `com.msg.fillmap.event.*` 배정 기준. +위치의 표시명 재료 계산에 Owner A 제공 계약인 `ZoneNameQueryService`와 +`GridQueryService.resolveRegionNames`를 소비하지만 시그니처 변경이 없어 공동 판정이 아니다) + +> 요구사항 정본: `docs/prd/event-submission.md` (상태 검토됨, 2026-08-28 승인. 같은 브랜치에 실린 +> v2.1 개정이 유형 재편을 반영한다). 이 스펙은 그중 FR-7, FR-8, FR-9, FR-10, FR-11, FR-12, +> FR-13, FR-14, FR-24의 서버 몫을 다룬다. URL 계약은 `/api/org/**` 통일이 정본이다 +> (MSG-496 결정 기록 1, 티켓 설명의 `/api/event-submissions` 표기는 폐기됨). +> Codex 교차 리뷰 4라운드 반영 완료(라운드 4는 2026-08-29 사용자 확정분). + +## 개요 + +행사 운영자[^1]가 콘솔에서 등록 유형(지역축제, 팝업스토어)을 골라 행사를 신청하고, 자기 신청의 +상태와 반려 사유를 확인하고, 반려본을 수정해 재제출하는 ORG 전용 API 4개와 대표 이미지 업로드용 +presign 1개를 만든다. 신청 저장 스키마(`event_submissions` 계열)는 이 티켓이 소유하고, 반려 +사유를 쓰는 쪽(관리자 심사)은 MSG-500이다. 세 번째 유형(이벤트 참여형)은 v2.1 재편으로 +MSG-501(승인 이벤트 목록)과 MSG-502(참여 신청)에 분리됐다. + +## 배경 · 목표 + +- 지금 행사 등재는 `seed/events.json` 수동 시드뿐이라 외부 행사 운영자가 자기 행사를 올릴 + 창구가 없다(PRD 1절). 이 티켓이 신청 접수 쪽 절반을 만들고, 심사와 승인 반영은 MSG-500이 잇는다. +- 목표: 행사 운영자가 신청 제출부터 반려 후 재제출까지 콘솔 흐름을 서버 API로 완주할 수 있는 상태. + +## 성공 기준 + +1. ORG 토큰으로 지역축제, 팝업스토어 신청을 제출하면 심사 중 상태와 신청 번호(`FM-2026-XXXX`꼴)가 + 부여되고, 위치마다 대표 격자[^2]가 서버 계산으로 저장된다. +2. 검증 3규칙이 동작한다. 위치당 사각형 합산 81칸 초과는 거부(FR-24), 위치에 이름 필드가 아예 + 없으며(피그마 #102), 서술 항목은 String 최소 10자다(피그마 #100). +3. 내 신청 목록에 상태별 건수가 실리고, 상세에 상태 이력과 반려 항목 코드, 사유가 실린다. +4. 없는 신청과 남의 신청의 실패 응답이 완전히 같다(존재 은닉[^3], FR-14). +5. 반려 상태에서만 수정 재제출이 되고, 재제출하면 심사 중으로 돌아간다(FR-13). +6. 아래 테스트 시나리오가 전부 green이다. + +## API 명세 + +전부 `/api/org/event-submissions` 아래다. SecurityConfig는 무변경이다. MSG-496이 넣은 +`requestMatchers("/api/org/**").hasRole("ORG")` matcher가 새 경로를 자동으로 덮으므로 비로그인 +401, USER와 ADMIN 토큰 403이 이미 보장된다. 소유권 검사(FR-14)는 서비스 계층이 한다. + +### 1. `POST /api/org/event-submissions/image/presigned-url` : 대표 이미지 presign 발급 + +MSG-373 프로필 이미지의 2단 업로드[^4] 흐름을 미러링한다. 요청은 +`EventSubmissionImagePresignRequestDto`(extension, contentType, contentLength), 응답은 +`EventSubmissionImagePresignResponseDto`로 필드 구성은 `ProfileImagePresignResponseDto` 선례와 +같다(presigned URL, pending 키, 만료). + +- 허용 형식: **jpg, jpeg, png만**. 시안 문구가 "JPG 또는 PNG"라 프로필 이미지와 달리 webp를 + 받지 않는다. 확장자와 contentType 쌍 불일치 또는 허용 밖이면 13437. +- 크기 상한: **10MB**(시안 문구). 선언 크기 초과면 13438. +- pending 키: `event-submissions/pending/{userId}/{uuid}.{ext}`. 확정 없이 버려진 pending은 + 라이프사이클이 쓸어간다(프로필 이미지 선례). 운영 절차로 이 프리픽스의 S3 라이프사이클 규칙 + 추가가 필요하다(작업 로그에 런북으로 남길 것). + +### 2. `POST /api/org/event-submissions` : 신청 제출 + +요청 `EventSubmissionCreateRequestDto`: + +```json +{ + "type": "FESTIVAL", + "title": "부산불꽃축제", + "organizerName": "부산문화관광축제조직위원회", + "startsOn": "2026-11-07", + "endsOn": "2026-11-07", + "programDescription": "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영", + "description": "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", + "imageS3Key": "event-submissions/pending/12/3f2b….jpg", + "locations": [ + { + "areaRects": [ + { "minGridY": 16859, "maxGridY": 16861, "minGridX": 11509, "maxGridX": 11515 } + ] + } + ] +} +``` + +- `type`: `FESTIVAL`(지역축제) 또는 `POPUP`(팝업스토어). 그 외 값은 400. +- 유형별 항목 매핑(PRD 유형 표): title은 축제명/팝업명, organizerName은 주최 기관/브랜드·운영사, + startsOn과 endsOn은 축제 기간/운영 기간이다. `programDescription`(주요 프로그램)은 FESTIVAL + 전용 필수, `operatingHours`(운영 시간)는 POPUP 전용 필수다. 자기 유형이 아닌 필드가 오면 + 무시하지 않고 13439로 거부한다(폼에 없는 값이 저장되면 관리자 화면이 출처 불명 데이터를 그린다. + 위 예시는 FESTIVAL이라 operatingHours가 없다). +- 서술 항목(`programDescription`, `description`)은 구조화 없이 String으로 받고 **최소 10자**다 + (피그마 #100). 10자 미만은 Bean Validation `@Size(min = 10)`로 공통 400. +- 길이 상한: `title`, `organizerName`, `operatingHours`는 `@Size(max = 100)`(DDL VARCHAR(100) + 정합), `description`과 `programDescription`은 `@Size(max = 2000)`이다(Codex 리뷰 4 반영, + 2026-08-29 사용자 확정). 레포의 사용자 입력 텍스트는 예외 없이 상한이 있고(댓글 500, 신고 + 상세 500, 경로 추천 500), 2000자는 그 4배라 축제 프로그램 서술에 충분하다. DDL은 TEXT + 유지(검증 계약만 바뀌고 DDL 변경 없음). 초과는 전부 Bean Validation 공통 400이다(DB 예외로 + 500이 나는 구멍 봉쇄). +- **위치에 이름 필드가 없다**(피그마 #102). 위치는 배열 순서가 곧 순번이고(서버가 1부터 + `display_order`로 저장), 화면 식별은 순번과 지역 라벨(표시명 재료[^5], 상세 응답 동봉)로 한다. +- `imageS3Key`는 제출에서 필수다(pending 키만 허용, 확정 흐름은 아래 도메인 로직). +- `areaRects`[^6] 형식은 `seed/events.json`과 같은 (minGridY, maxGridY, minGridX, maxGridX) + 정수 사각형이다. 검증과 대표 격자 계산은 아래 도메인 로직 참조. +- 응답 `EventSubmissionSubmitResponseDto`: `{ "id": 7, "submissionNo": "FM-2026-0007", "status": "IN_REVIEW" }`. + +에러 케이스: 13431(영역 형태 위반), 13432(81칸 초과), 13433(기간 위반), 13435/13436(이미지 키 +위반), 13439(유형별 필수 항목 위반). 상세는 아래 에러 코드 표. + +### 3. `GET /api/org/event-submissions/my` : 내 신청 목록과 상태별 건수 + +콘솔 홈 현황 카드와 최근 신청 목록의 재료다. 페이지네이션 없음(내부 소수 사용자, PRD 비기능 +성능 절이 별도 목표를 두지 않는다). 정렬은 `createdAt` 내림차순이다. + +응답 `EventSubmissionMyListResponseDto`: + +```json +{ + "counts": { "inReview": 2, "approved": 1, "rejected": 1 }, + "submissions": [ + { + "id": 7, "submissionNo": "FM-2026-0007", "type": "FESTIVAL", + "title": "부산불꽃축제", "status": "REJECTED", + "startsOn": "2026-11-07", "endsOn": "2026-11-07", + "updatedAt": "2026-08-28T02:11:00Z" + } + ] +} +``` + +### 4. `GET /api/org/event-submissions/{id}` : 신청 상세 + +기본 정보 전부, 위치 목록(순번, 대표 격자, 표시명 재료, areaRects 원본, 칸 수), 상태, 반려 +항목 코드와 사유, 상태 이력을 돌려준다. + +```json +{ + "id": 7, "submissionNo": "FM-2026-0007", "type": "FESTIVAL", "status": "REJECTED", + "title": "…", "organizerName": "…", "startsOn": "2026-11-07", "endsOn": "2026-11-07", + "operatingHours": null, "programDescription": "…", "description": "…", + "imageUrl": "https://…presigned GET…", + "locations": [ + { + "order": 1, "representativeGridId": "16860_11512", + "zoneName": null, "zoneCell": null, "regionName": "부산 수영구 광안동", + "cellCount": 21, + "areaRects": [ { "minGridY": 16859, "maxGridY": 16861, "minGridX": 11509, "maxGridX": 11515 } ] + } + ], + "rejection": { "reasonCodes": ["AREA", "INFO"], "reasonText": "…" }, + "history": [ + { "status": "IN_REVIEW", "reasonCodes": null, "reasonText": null, "changedAt": "2026-08-28T01:00:00Z" }, + { "status": "REJECTED", "reasonCodes": ["AREA", "INFO"], "reasonText": "…", "changedAt": "2026-08-28T02:00:00Z" } + ] +} +``` + +- **존재 은닉(FR-14)**: 없는 id와 남의 id 모두 같은 13430 응답이다. 조회는 항상 + `id + userId` 쌍으로 하므로 코드 경로 자체가 하나다. +- `rejection`은 현재 상태가 REJECTED일 때만 값이 있고 아니면 null이다. 값은 이력의 최신 행에서 + 읽는다(반려 사유의 저장 원천은 이력 테이블 하나, 결정 D-3). 재제출 뒤에도 과거 반려는 + `history` 안에 그대로 남아 FR-12의 이력 확인을 채운다. +- `imageUrl`은 presigned GET이다. 저장 키는 내부 값이라 노출하지 않고, 발급 방식과 TTL은 + `ThumbnailUrlPresigner` 선례 상수를 재사용한다. +- `areaRects`는 제출 원본 그대로다(재제출 폼 프리필과 관리자 검토 재료). 표시명 재료 3종은 + 대표 격자 기준으로 서버가 계산해 동봉하고, FE 조립 규칙은 기존 계약 그대로 + `zoneName ? zoneName + " " + zoneCell : regionName`이다. +- `updatedAt`, `changedAt` 같은 시각은 전부 `LocalDateTime`(UTC Z 코덱), 기간 라벨은 + `LocalDate`다(시각 컨벤션). + +### 5. `PATCH /api/org/event-submissions/{id}` : 반려본 수정 재제출 + +- **반려(REJECTED) 상태에서만** 허용한다. 심사 중이거나 승인된 신청이면 13434(409)다. +- 요청 `EventSubmissionUpdateRequestDto`는 제출 요청에서 `type`을 뺀 전체다. **유형은 바꿀 수 + 없다**(결정 D-8). 나머지는 부분 수정이 아니라 전체 교체다. 위치 목록은 통째로 갈아끼우고 + 대표 격자를 전부 재계산한다(신청 하나가 폼 하나로 쓰였다 폼 하나로 고쳐지는 단위라서다). +- 이미지만 예외적으로 유지 선택이 있다. **`imageS3Key`를 null로 보내거나 생략하면 기존 이미지 + 유지, pending 키를 보내면 교체다**(Codex 2라운드 반영. 상세 응답이 저장 키를 노출하지 않아 + 클라이언트는 확정 키를 알 수 없고, 알 필요도 없어야 한다. 확정 키를 직접 보내는 경로는 두지 + 않는다). 교체 시 이전 확정 객체는 커밋 후 삭제한다(프로필 이미지 선례). +- **REJECTED에서 IN_REVIEW로의 전이는 소유권 술어를 포함한 조건부 UPDATE 하나로 원자화하고, + 실행 순서가 계약이다**(Codex 리뷰 1·2·3라운드 반영). ① 먼저 JPQL + `@Modifying(clearAutomatically = true) UPDATE EventSubmission s SET s.status = IN_REVIEW … + WHERE s.id = :id AND s.userId = :userId AND s.status = REJECTED`(또는 동등한 flush/clear + 처리)를 실행한다. 술어에 userId가 들어 있어 **남의 행은 어떤 경로로도 수정되지 않는다** + (뒤의 재로드나 롤백에 기대는 구조가 아니다). ② **영향 행이 0이면 소유 조회(id + userId)로 + 분기한다.** 행이 없으면 13430(존재 은닉 단일 응답), 행이 있는데 REJECTED가 아니면 13434 + (동시 재제출의 패자 포함). 분기 기준이 소유 조회라 남의 id와 없는 id는 상태와 무관하게 같은 + 13430을 받고 FR-14가 유지된다(id + status로만 걸면 남의 REJECTED 행이 13434로 새어 존재가 + 드러난다). ③ UPDATE가 이겼으면 애그리거트를 **재로드**해 위치 교체와 이력 추가를 수행한다. + 순서를 뒤집어 UPDATE 전에 로드해 둔 엔티티를 flush하면, 벌크 UPDATE가 영속성 컨텍스트를 + 우회하므로 스테일 엔티티의 REJECTED 상태가 도로 덮어써질 수 있다. 검사와 갱신이 한 문장이라 + 동시 PATCH 2건이 둘 다 REJECTED를 관찰하고 둘 다 성공하는 경합(이력 중복, 진 요청의 이미지 + 삭제)이 성립하지 않는다. `@Version` 같은 신규 컬럼은 두지 않는다(이 전이 하나에는 조건부 + UPDATE로 충분하다). 애그리거트 교체, 이력 추가, 이전 이미지 삭제 예약은 이 UPDATE가 이긴 + 트랜잭션 안에서만 실행한다. +- 성공하면 상태가 IN_REVIEW로 돌아가고 이력에 IN_REVIEW 행이 추가된다. 신청 번호는 불변이다. +- 검증 규칙은 제출과 동일하게 전부 다시 탄다. 존재 은닉도 동일하다(남의 id는 13430). +- 응답은 제출과 같은 `EventSubmissionSubmitResponseDto`다. + +## 도메인 로직 + +### 영역 검증과 81칸 상한 (FR-8, FR-24) + +1. 위치가 0개이거나 **20개를 넘거나**(결정 D-10), 위치에 사각형이 0개이거나, 사각형의 min이 + max보다 크거나, **격자 인덱스가 허용 범위 밖이면** 13431. 인덱스 허용 범위는 기존 + `EventSeeder.requireIndex`와 같은 **0 초과 100,000 미만**(`GRID_INDEX_UPPER_EXCLUSIVE = + 100_000` 실측)이다. 상수가 EventSeeder에 private로 있으므로 공용 위치로 승격해 재사용하고 + 시더도 그것을 쓰게 한다(같은 값의 사본 금지). 이 검사가 `Integer.MAX_VALUE` 같은 극단값을 + resolver 산술에 닿기 전에 걸러낸다(resolver의 long 오버플로 안전 논증이 "인덱스 상한 + 100,000" 전제 위에 서 있다). +2. 사각형 하나의 칸 수 `(maxY - minY + 1) × (maxX - minX + 1)`를 long 산술로 먼저 구해 81을 + 넘으면 전개 없이 13432로 즉시 거부한다(거대 사각형을 전개하다 메모리를 태우는 일 방지. + 위치당 사각형 수도 81개를 넘으면 13431이다. 사각형마다 최소 1칸이므로 81개 초과는 합집합 + 초과가 아니면 전부 중복 입력이고, 정상 드로잉 UI에서 나올 수 없는 형태다). +3. 위치의 사각형들을 `Set`로 전개해 **합집합 크기가 81을 넘으면 13432**. 겹치는 + 사각형은 Set이 자연히 한 번만 센다. 판정을 표현(사각형 조합)이 아니라 기하(격자 집합)에 + 거는 것은 `RepresentativeGridResolver`와 같은 원칙이다. + +### 대표 격자 서버 계산 (FR-9) + +위치마다 위 전개 집합으로 `RepresentativeGridResolver.resolve(cells, null)`을 호출해 +`representative_grid_id`로 저장한다. designated는 항상 null이므로 결과는 홀수 직사각형 정중앙 +아니면 중심 최근접이다. 81칸 상한 검증을 통과한 집합만 들어오므로 resolver의 셀 수 전제 +(상한 2,500) 안이다. + +### 기간 검증 (결정 D-6) + +`startsOn > endsOn`이면 13433. `endsOn`이 오늘(KST 날짜) 이전이어도 13433이다. 이미 끝난 +행사는 심사할 의미가 없고 관리자 큐만 오염시킨다. 진행 중 행사의 신청(시작일 과거)은 허용한다. +오늘 판정은 주입받은 `Clock`에서 `LocalDate.now(clock.withZone(Asia/Seoul))` 형태로 얻는다 +(인자 없는 now() 금지 컨벤션). + +### 신청 번호 (FR-10, 결정 D-4) + +`FM-{KST 연도}-{4자리 0패딩 순번}` 꼴이다(예 `FM-2026-0007`). 순번은 PostgreSQL 시퀀스 +`event_submission_no_seq` 하나에서 뽑는 전역 증가 값이고 연도별로 리셋하지 않는다. 리셋 +기계 없이 UNIQUE가 보장되고, PRD가 요구하는 것은 "FM-2026-XXXX꼴" 형태뿐이다. 9999를 넘으면 +자릿수가 자연 확장된다(`%04d`). 연도는 사용자 대면 라벨이라 KST 기준이다(uploadDate 선례와 +같은 성격). + +### 상태와 이력 (FR-10, FR-12, FR-13) + +- 상태 enum `EventSubmissionStatus`: `IN_REVIEW`(심사 중), `APPROVED`(승인), `REJECTED`(반려). + 전이는 제출 시 IN_REVIEW, 재제출 시 REJECTED에서 IN_REVIEW 복귀 둘뿐이고(복귀 전이는 조건부 + UPDATE로 원자화한다, API 명세 5절), APPROVED와 REJECTED로 보내는 쪽은 MSG-500이다. +- 전이마다 `event_submission_status_history`에 행을 추가한다(제출 시 IN_REVIEW 행 포함). + 반려 행에만 reasonCodes와 reasonText가 실리고, 그 외 상태 행에서 사유는 NULL이다(DDL CHECK로 + 강제). 이 티켓은 반려 행을 **읽기만** 하고, 쓰는 쪽은 MSG-500이다. +- 반려 항목 코드 enum `EventSubmissionReasonCode`: `PERIOD`, `AREA`, `IMAGE`, `INFO`(FR-19). + 1개 이상 강제는 쓰는 쪽(MSG-500)의 검증 몫이고 이 티켓은 저장 형태만 정의한다. + +### 대표 이미지 확정 + +제출과 재제출에서 `imageS3Key`가 pending 키면: 내 pending 프리픽스 아래 허용 확장자 키인지 +검사(위반 13435), S3 HEAD로 실체 확인(없으면 13436, **실측 contentLength가 0이어도 13436** +이다. 빈 객체가 대표 이미지로 저장되는 경로 차단, Codex 리뷰 4 반영), 실측 크기 10MB 초과면 +13438, 통과하면 `event-submissions/original/{userId}/{uuid}.{ext}`로 복사해 그 키를 저장한다. +매직 바이트(JPEG/PNG 시그니처) 검증은 미채택이다. 프로필 이미지 선례(MSG-373)도 하지 않는 +검증이고, contentType이 presign 서명에 묶여 이미지 타입으로 고정되므로 깨진 이미지의 영향은 +신청자 본인 이미지의 표시 불량뿐이다(XSS 경로 아님). 도입한다면 프로필과 묶은 공통 유틸이 +맞아 이 티켓 범위 밖이다. + +**확정 키의 uuid는 복사 시점에 새로 발급한다**(pending 키의 uuid 재사용 금지). 신청마다 독립 +객체를 소유하므로 두 신청이 같은 객체를 공유하는 상태 자체가 성립하지 않고, 재제출의 이전 +이미지 삭제가 다른 신청의 이미지를 지우는 경로가 없다(Codex 리뷰 1 반영). **복사에 성공한 +pending 객체는 커밋 후 삭제해** 같은 pending 키의 재확정을 차단한다(재확정 시도는 HEAD 실패라 +13436이다. 프로필 선례는 pending을 라이프사이클에 맡기지만 여기는 재사용 차단이 목적이라 즉시 +지우고, 삭제 시점은 프로필의 이전 이미지 삭제와 같은 커밋 후다). 이 pending 삭제는 정리 목적 +베스트 에포트다. 실패해도 신청은 유효하고, 남은 pending은 라이프사이클이 쓸어가며, 잔여 +리스크는 중복 신청뿐이라 콘솔 가시성과 심사로 충분하다(2라운드 판정). + +**복사와 커밋 사이의 롤백 보상**(Codex 2라운드 반영): original 복사는 커밋 전에 일어나므로, +이후 트랜잭션이 롤백되면 방금 복사한 original이 어디서도 참조되지 않는 고아로 남는다. +`VideoServiceImpl.deleteOnRollback`(MSG-247) 선례를 그대로 따른다. afterCompletion에서 +`STATUS_ROLLED_BACK`일 때만 방금 복사한 original 키를 deleteQuietly 베스트 에포트로 지우고, +`STATUS_UNKNOWN`(커밋 결과 불명)은 남긴다(커밋됐을 수 있는 이미지를 지우면 유실이고, 고아는 +비용 문제일 뿐이다). uuid를 시도마다 새로 발급하므로 목적지 공유가 없어 이 보상이 다른 시도의 +객체를 지울 수 없다(선례 주석의 불변식 그대로). + +검증 순서와 예외 처리 구조는 `UserServiceImpl.updateProfileImage`를 그대로 따른다. original +프리픽스는 공개 읽기를 열지 않는다(열람자가 신청 소유자와 관리자뿐이라 presigned GET으로 +충분하다). + +## 데이터 모델 + +Flyway 1건, **`V49__event_submissions.sql`**. 처음 V47로 잡았으나 병렬 레인 MSG-499(V48)가 +먼저 머지돼 V49로 재번호했다(2026-08-29, PR #243 리뷰 지적). V48이 이미 적용된 dev·prod에서 +낮은 번호의 새 마이그레이션은 Flyway가 out-of-order로 무시해 테이블이 안 만들어지기 때문이다 +— CI(새 DB)는 순서대로 적용돼 통과하니 CI green이 이 문제를 못 잡는다. + +```sql +-- 행사 등재 v2 (MSG-498): 행사 운영자 등록 신청. 심사·승인 반영은 MSG-500. +-- 격자 컬럼은 grids 를 FK 하지 않는다 (lazy insert 전략상 행 존재 비보장, V39 선례). + +CREATE SEQUENCE event_submission_no_seq; + +CREATE TABLE event_submissions ( + id BIGSERIAL PRIMARY KEY, + submission_no VARCHAR(20) NOT NULL UNIQUE, -- "FM-2026-0007" + user_id BIGINT NOT NULL REFERENCES users(id), -- 신청자 (role=ORG) + type VARCHAR(20) NOT NULL, -- EventSubmissionType + status VARCHAR(20) NOT NULL, -- EventSubmissionStatus + title VARCHAR(100) NOT NULL, -- 축제명 / 팝업명 + organizer_name VARCHAR(100) NOT NULL, -- 주최 기관 / 브랜드·운영사 + starts_on DATE NOT NULL, -- KST 날짜 라벨 (기간) + ends_on DATE NOT NULL, + operating_hours VARCHAR(100), -- POPUP 전용 (event_locations 선례 타입) + program_description TEXT, -- FESTIVAL 전용, 최소 10자 (#100) + description TEXT NOT NULL, -- 소개, 최소 10자 (#100) + image_key VARCHAR(255) NOT NULL, -- 확정 프리픽스 S3 키 + created_at TIMESTAMP NOT NULL, -- UTC (MSG-376 체계, 앱이 Clock 으로 기록) + updated_at TIMESTAMP NOT NULL, + + -- 이벤트 참여형(MSG-502)은 값 추가 + 부모 이벤트 컬럼 추가로 확장한다. 선반영하지 않는다. + CONSTRAINT chk_event_sub_type CHECK (type IN ('FESTIVAL', 'POPUP')), + CONSTRAINT chk_event_sub_status CHECK (status IN ('IN_REVIEW', 'APPROVED', 'REJECTED')), + CONSTRAINT chk_event_sub_period CHECK (starts_on <= ends_on) +); +CREATE INDEX idx_event_submissions_user ON event_submissions(user_id); + +CREATE TABLE event_submission_locations ( + id BIGSERIAL PRIMARY KEY, + event_submission_id BIGINT NOT NULL REFERENCES event_submissions(id) ON DELETE CASCADE, + display_order INTEGER NOT NULL, -- 요청 배열 순서, 1부터. 위치 이름 없음 (#102) + representative_grid_id VARCHAR(20) NOT NULL -- 서버 계산 (FR-9) +); +CREATE INDEX idx_event_sub_locations_submission ON event_submission_locations(event_submission_id); + +-- 제출 원본 사각형. 승인 시 event_locations·event_location_grids 로 전개하는 쪽은 MSG-500. +CREATE TABLE event_submission_location_rects ( + event_submission_location_id BIGINT NOT NULL + REFERENCES event_submission_locations(id) ON DELETE CASCADE, + min_grid_y INTEGER NOT NULL, + max_grid_y INTEGER NOT NULL, + min_grid_x INTEGER NOT NULL, + max_grid_x INTEGER NOT NULL, + + CONSTRAINT chk_event_sub_rect_y CHECK (min_grid_y <= max_grid_y), + CONSTRAINT chk_event_sub_rect_x CHECK (min_grid_x <= max_grid_x) +); +CREATE INDEX idx_event_sub_rects_location + ON event_submission_location_rects(event_submission_location_id); + +CREATE TABLE event_submission_status_history ( + id BIGSERIAL PRIMARY KEY, + event_submission_id BIGINT NOT NULL REFERENCES event_submissions(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL, + reason_codes VARCHAR(30), -- 쉼표 연결 "AREA,INFO" (쓰기는 MSG-500) + reason_text TEXT, + created_at TIMESTAMP NOT NULL, + + CONSTRAINT chk_event_sub_hist_status CHECK (status IN ('IN_REVIEW', 'APPROVED', 'REJECTED')), + -- 반려 행에는 사유 두 벌이 반드시 있고, 그 외 상태 행에는 없다 (FR-19 저장 계약) + CONSTRAINT chk_event_sub_hist_reason_required + CHECK (status <> 'REJECTED' OR (reason_codes IS NOT NULL AND reason_text IS NOT NULL)), + CONSTRAINT chk_event_sub_hist_reason_absent + CHECK (status = 'REJECTED' OR (reason_codes IS NULL AND reason_text IS NULL)) +); +CREATE INDEX idx_event_sub_history_submission + ON event_submission_status_history(event_submission_id); +``` + +엔티티 매핑(신규 코드라 연관관계 허용, 2026-08-19 개정): + +- `EventSubmission` → `@OneToMany(cascade = ALL, orphanRemoval = true) List` + (LAZY, `@OrderBy("displayOrder")`). 근거 주석: 신청은 폼 단위로 통째로 만들어지고 통째로 + 교체되는 애그리거트이고 위치 수 상한이 20이다(결정 D-10). PATCH의 전체 교체가 + orphanRemoval 하나로 끝난다. **양방향이고 연관 주인은 자식 쪽 `@ManyToOne(fetch = LAZY)`다** + — 부모 컬렉션은 `mappedBy = "submission"`이고, 양쪽 참조 동기화와 순번 부여는 편의 메서드 + `replaceLocations` 한 곳이 맡는다(결정 D-12). +- `EventSubmissionLocation`의 사각형은 `@ElementCollection`(rects 테이블은 PK 없는 컬렉션 + 테이블, embeddable `AreaRect(minGridY, maxGridY, minGridX, maxGridX)`). 위치당 최대 81개로 + 상한이 분명하다. +- `user_id`는 연관 없이 `Long userId`로 둔다. 이 티켓의 어떤 응답도 User 데이터를 싣지 않아 + 조인이 필요 없다(MSG-500의 관리자 화면이 기관명을 필요로 하면 그쪽에서 읽기 전용 연관을 단다). +- `EventSubmissionStatusHistory`는 연관 없이 `eventSubmissionId`로 저장하는 append 로그다. + 조회는 파생 쿼리 `findByEventSubmissionIdOrderByIdAsc` 하나다. +- `reason_codes`는 `List` ↔ 쉼표 문자열 `AttributeConverter`로 + 매핑한다(값 4종 고정, 순서 무의미, JPA 표준 매핑만 사용. JSONB로 하면 방언 의존이 생기는데 + 얻는 것이 없다). +- 상태별 건수는 JPQL `SELECT s.status, COUNT(s) FROM EventSubmission s WHERE s.userId = :userId GROUP BY s.status`. + 재제출 전이는 `@Modifying(clearAutomatically = true)` 조건부 UPDATE(API 명세 5절, 술어에 + userId 포함)다. native는 신청 번호 시퀀스 `nextval` 하나뿐이다(D-4의 PostgreSQL 시퀀스 채번 — + 영속 계층 수칙 4항이 명시 허용하는 PG 전용 기능. 그 외 전 쿼리는 파생 쿼리와 JPQL로 충분하다. + 구현 리뷰에서 "native 없음" 초안 문장과의 모순 지적을 받아 정정, 2026-08-29). + +## 에러 코드 + +**event 13xxx 잔여 사용으로 판정한다.** 신청은 event 패키지 소속 도메인이라 대역 신설 사유가 +없고, 따라서 `response-pattern.md` 표 갱신 선행 커밋도 필요 없다(신규 대역일 때만 요구되는 +절차다). `EventErrorCode`에 상수를 추가하며, 13423~13429를 건너뛰고 **13430대 블록**을 쓴다. +13420·13421이 MSG-442 초안에서 쓰였다 통합 폐기된 이력이 있어(enum 주석 명문) 그 부근을 피해 +신청 블록임이 번호로 읽히게 한다. + +| developCode | 상수 | HTTP | 메시지 | 발생 | +|---|---|---|---|---| +| 13430 | `SUBMISSION_NOT_FOUND` | 404 | 신청을 찾을 수 없습니다 | 없는 id, 남의 id (은닉 단일 응답, 상태 무관) | +| 13431 | `INVALID_SUBMISSION_AREA` | 400 | 유효하지 않은 위치 영역입니다 | 위치 0개, 위치 21개 이상, 사각형 0개, min>max, 격자 인덱스 범위 밖, 위치당 사각형 81개 초과 | +| 13432 | `SUBMISSION_AREA_LIMIT_EXCEEDED` | 400 | 위치 하나의 영역은 최대 81칸입니다 | 합집합 81칸 초과 (FR-24) | +| 13433 | `INVALID_SUBMISSION_PERIOD` | 400 | 행사 기간이 유효하지 않습니다 | 시작>종료, 종료일이 KST 오늘 이전 | +| 13434 | `SUBMISSION_NOT_EDITABLE` | 409 | 반려된 신청만 수정할 수 있습니다 | 내 신청이 REJECTED 아닌 상태에 PATCH (상태 충돌 409, 13409 선례) | +| 13435 | `SUBMISSION_IMAGE_KEY_INVALID` | 400 | 유효하지 않은 이미지 키입니다 | 내 pending 프리픽스 밖, 허용 밖 확장자 키 | +| 13436 | `SUBMISSION_IMAGE_NOT_UPLOADED` | 400 | 업로드되지 않은 이미지입니다 | S3 HEAD 실패, 실측 0바이트 객체 | +| 13437 | `SUBMISSION_IMAGE_UNSUPPORTED` | 415 | jpg, png 이미지만 올릴 수 있습니다 | presign의 확장자·contentType 위반 | +| 13438 | `SUBMISSION_IMAGE_TOO_LARGE` | 413 | 이미지는 최대 10MB 입니다 | presign 선언 크기, 확정 실측 크기 초과 | +| 13439 | `SUBMISSION_REQUIRED_FIELD_MISSING` | 400 | 등록 유형에 필요한 항목이 올바르지 않습니다 | FESTIVAL에 programDescription 부재, POPUP에 operatingHours 부재, 반대 유형 필드 존재 | + +형식 수준 위반(필수 문자열 공백, 10자 미만, 100자·2000자 초과, null)은 Bean Validation이 공통 +400으로 처리한다(`MethodArgumentNotValidException` 경로, 신규 코드 불요). mustChange 게이트 +차단(2441)은 MSG-497의 `PasswordChangeGateInterceptor`가 내는 auth 대역 응답이라 이 표 밖이다. + +## 계약 변경 + +없음. `GridQueryService`, `HotZoneService`, `UserGridQueryService`, `UserOidcCommandService` +어느 것도 시그니처가 바뀌지 않는다. `ZoneNameQueryService.resolver()`와 +`GridQueryService.resolveRegionNames`는 기존 시그니처 그대로 소비만 한다 +(`EventQueryServiceImpl`이 이미 쓰는 패턴 재사용, 요청당 리졸버 1회 규칙 준수). + +## 결정 기록 + +- **D-1 패키지**: `com.msg.fillmap.event.submission` 하위에 controller, dto, entity, repository, + service를 둔다. event 도메인 소속(대역 공유, MSG-500이 event 도메인 생성으로 잇는 연속선)이되 + 기존 event 패키지가 60개 파일이 넘어 flat 합류는 혼잡하다. `event.seed`, `event.support` 하위 + 패키지 선례를 따른다. 에러 코드만 기존 `event.exception.EventErrorCode`에 합류한다(도메인당 + enum 하나, 대역 관리 단일화). +- **D-2 developCode**: 13xxx 잔여 사용, 13430대 블록. 표 갱신 커밋 불요(위 에러 코드 절). +- **D-3 반려 사유의 저장 원천은 이력 테이블 하나**: 신청 행에 현재 반려 사유를 중복 저장하지 + 않는다. 상세의 "반려 항목" 카드는 이력 최신 행에서 읽는다. 두 곳에 저장하면 MSG-500이 쓰기 + 시점에 둘을 맞춰야 하는 정합 부담이 생긴다. +- **D-4 신청 번호**: 전역 시퀀스 + KST 연도 라벨, 연도별 리셋 없음(위 도메인 로직 절). +- **D-5 대표 이미지**: MSG-373 presign 2단 흐름 미러링, jpg/jpeg/png(웹P 제외), 10MB, 확정 + 프리픽스는 비공개 + presigned GET. 확정 시 uuid 신규 발급 + 복사 성공한 pending의 커밋 후 + 삭제(정리 목적 베스트 에포트)로 신청마다 객체를 독점 소유한다(공유 객체가 없어 재제출의 + 이전 이미지 삭제가 다른 신청에 닿을 수 없다, Codex 리뷰 1 반영). 롤백 시 방금 복사한 + original은 `deleteOnRollback` 선례(MSG-247)로 보상 삭제한다(Codex 리뷰 2 반영). 확정 HEAD + 에서 0바이트 객체는 13436으로 거부하고, 매직 바이트 검증은 미채택이다(Codex 리뷰 4 반영, + 근거는 대표 이미지 확정 절). +- **D-6 기간 검증**: 시작 ≤ 종료, 종료일은 KST 오늘 이상. 시작일 과거는 허용(진행 중 행사). +- **D-7 81칸 판정은 합집합**: 사각형 합산이 아니라 전개 격자 집합 크기로 판정한다. 겹침을 + 두 번 세면 정당한 81칸 영역이 그리는 방식에 따라 거부되는 비결정성이 생긴다. +- **D-8 PATCH는 유형 불변 전체 교체**: 유형이 바뀌면 유형별 필수 항목이 통째로 달라져 사실상 + 새 신청이다. 유형을 바꾸려면 새로 제출한다(반려된 기존 건은 그대로 남아 이력이 보존된다). + 재제출 전이는 소유권 술어(userId)를 포함한 조건부 UPDATE로 원자화하고, 실패 분기는 소유 + 조회 기준(없으면 13430, 상태 위반이면 13434)이며, 재로드가 UPDATE 뒤다(API 명세 5절, + Codex 리뷰 1·2·3 반영). 이미지 유지는 imageS3Key null/생략으로 표현한다(확정 키 직접 전달 + 경로 없음). +- **D-9 MSG-502 확장 여지**: type CHECK 재정의와 부모 이벤트 컬럼 추가로 열어 둔다. 부모 컬럼 + 선반영 금지(지라 코멘트 ② 명문). +- **D-10 위치 수 상한 20**: 시안과 PRD에 명시 수치가 없어 스펙에서 정한다(Codex 리뷰 2 반영). + 근거: 위치 카드 목록 UI는 순번 나열이라 수십 개를 전제하지 않고, 기존 시드의 최대가 회차당 + 3위치다. 20은 실사용을 막지 않으면서 무제한 엔티티 생성(위치 x 사각형 81개 x 셀 전개)을 + 차단하는 값이다. 초과는 13431. 상한을 늘릴 일이 생기면 상수 하나 수정이다. +- **D-11 서술 필드 상한 2000자**: `description`·`programDescription`에 `@Size(max = 2000)` + (Codex 리뷰 4 반영, 2026-08-29 사용자 확정). 레포의 사용자 입력 텍스트 상한 선례(댓글 500, + 신고 상세 500, 경로 추천 500)를 따르되 서술 성격을 감안해 4배로 잡았다. DDL은 TEXT 유지. +- **D-12 신청 ↔ 위치는 양방향으로 매핑한다 — 연관 주인은 자식 `@ManyToOne`** (2026-08-29 사용자 + 확정). 자식 `EventSubmissionLocation.submission`이 `@ManyToOne(fetch = LAZY)` 연관 주인이고, + 부모는 `@OneToMany(mappedBy = "submission", cascade = ALL, orphanRemoval = true)`로 컬렉션을 + 들고 있으며, 양쪽 참조는 편의 메서드(`EventSubmission.replaceLocations` → 패키지 전용 + `attachTo`)가 한 곳에서 맞춘다. + 근거는 **주류 관행과의 합치**다. 김영한 교재는 일대다 단방향(`@OneToMany + @JoinColumn`)을 + 지양하고 다대일을 연관 주인으로 두라고 가르치고, Vlad Mihalcea의 매핑 선호 순위도 + `@ManyToOne` ≥ 양방향 `@OneToMany` > 단방향 `@OneToMany`다. 코드를 이어받을 사람의 기대와 + 어긋나지 않는 쪽을 택했다 — 이 도메인 하나가 레포에서 혼자 다른 모양이 되면 그 자체가 유지보수 + 비용이다. + 경위: 구현 초판이 이 양방향이었고, 중간에 "역방향을 습관으로 달지 않는다"는 판단으로 단방향 + `@OneToMany + @JoinColumn`에 한 번 갔다가 같은 날 위 근거로 되돌렸다. 그 왕복에서 남길 실측이 + 하나 있다 — **`@JoinColumn(nullable = false)`이면 단방향에서도 Hibernate가 자식 INSERT에 FK를 + 함께 싣는다.** "단방향 일대다는 INSERT 후 UPDATE라 NOT NULL에 걸린다"는 통설은 `nullable`을 + 지정하지 않은 경우의 이야기이고, 단방향 상태로 돌린 통합 테스트 26건이 NOT NULL 위반 없이 + 통과했다. 즉 이번 선택은 동작 문제를 피한 것이 아니라 **관행을 따른 것**이다. + 양방향이라 따라오는 수칙 셋은 그대로 지킨다 — 컬렉션 매핑에 크기 상한 근거 주석(위치 20개, + D-10 · 컨벤션 영속 계층 2항 요구), 연관 필드는 `equals`/`hashCode`/`toString`에서 제외(3항), + 참조 동기화는 편의 메서드 밖으로 새지 않게 캡슐화(서비스는 `replaceLocations`만 부른다). + MSG-500이 붙일 매핑도 같은 형태를 쓴다. + +## 비범위 + +- 관리자 심사 API(approve, reject)와 승인 시 event 도메인(시리즈, 회차, 위치) 생성: MSG-500. + 이 스펙의 스키마 중 APPROVED 상태와 반려 행 쓰기는 MSG-500이 처음 실행한다. +- 이벤트 참여형 신청과 승인 이벤트 목록: MSG-501, MSG-502. +- 임시 저장: 미포함 확정(PRD 비목표). +- 위치 단위 승인 여부 상태값: 만들지 않는다(피그마 #107, 신청 전체 상태로 충분 확정). +- mustChange 게이트 구현: MSG-497이 이미 develop에 머지돼(PR #238, 2026-08-28) + `PasswordChangeGateInterceptor`(auth/web)가 `/api/org/**` 프리픽스 전역을 차단한다 + (mustChange=true면 403, developCode 2441). 이 티켓의 신규 엔드포인트도 프리픽스로 자동 + 커버되므로 게이트 코드는 만들지 않고 **커버 확인 테스트만 추가한다**(테스트 시나리오 참조). + 이 워크트리 베이스(56e413e1)에는 인터셉터가 아직 없어 develop 리베이스로 들어온다. + +## 미해결 질문 + +없음. 판단 여지가 있던 지점(기간의 과거 거부, 유형 변경 불가, 신청 번호 리셋 없음, 위치 수 +상한)은 결정 기록 D-4, D-6, D-8, D-10으로 확정했다. 리뷰에서 이견이 나오면 그 항목만 재논의한다. + +## 테스트 시나리오 + +JUnit 5와 AssertJ, 한국어 백틱 메서드명. 구현 시 `// 검증: FR-…` 주석으로 요구사항 ID를 단다. + +```text +// 영역 검증 (검증: FR-8, FR-24) +위치_사각형_합산이_81칸이면_통과한다() // 경계값 +위치_사각형_합산이_82칸이면_거부한다() // 13432 +겹치는_사각형은_한_번만_센다() // 합집합 판정 (D-7) +사각형_하나가_81칸을_넘으면_전개_없이_거부한다() // 13432, 선검사 경로 +사각형의_min이_max보다_크면_거부한다() // 13431 +위치가_없거나_사각형이_없으면_거부한다() // 13431 +격자_인덱스가_허용_범위_밖이면_거부한다() // 13431, 0 이하·100000 이상 경계 +위치가_20개면_통과하고_21개면_거부한다() // 13431, D-10 경계 + +// 기간·항목 검증 (검증: FR-7, 피그마 #100) +종료일이_시작일보다_빠르면_거부한다() // 13433 +종료일이_KST_기준_오늘_이전이면_거부한다() // 13433, 고정 Clock +축제_신청에_주요_프로그램이_없으면_거부한다() // 13439 +팝업_신청에_운영_시간이_없으면_거부한다() // 13439 +축제_신청에_운영_시간이_실려_오면_거부한다() // 13439, 반대 유형 필드 +소개가_10자_미만이면_400이다() // Bean Validation 공통 경로 +제목이_100자를_넘으면_400이다() // Bean Validation, DDL 상한 정합 +소개가_2000자를_넘으면_400이다() // Bean Validation, D-11 경계 + +// 제출 (검증: FR-9, FR-10) +신청하면_심사_중_상태와_FM꼴_신청_번호가_부여된다() +신청_번호는_제출마다_증가하고_겹치지_않는다() +홀수_직사각형_위치는_정중앙이_대표_격자가_된다() // resolver 경로 1 +직사각형이_아닌_위치는_중심_최근접이_대표_격자가_된다() // resolver 경로 3 +신청하면_이력에_심사_중_행이_남는다() +pending_이미지_키가_확정_프리픽스로_복사되어_저장된다() // 확정 키 uuid 는 신규 발급 (D-5) +같은_pending_키의_재확정은_실패한다() // 13436, 복사 성공한 pending 은 커밋 후 삭제 +남의_pending_키로_신청하면_거부한다() // 13435 +0바이트_이미지_객체는_거부한다() // 13436, HEAD 실측 크기 0 (Codex 4R) +복사_성공_후_커밋이_실패하면_원본이_정리된다() // 롤백 보상, STATUS_ROLLED_BACK 한정 + +// presign (검증: 이미지 규격) +webp_확장자_presign_요청은_거부한다() // 13437, 프로필과 달리 불허 +10MB_초과_선언은_거부한다() // 13438 + +// 내 목록 (검증: FR-11) +내_신청_목록에_상태별_건수가_실린다() +목록은_최신_제출부터_온다() +남의_신청은_목록과_건수에_잡히지_않는다() + +// 상세 (검증: FR-12, FR-14) +상세에_위치_순번과_표시명_재료와_칸수가_실린다() +반려된_신청_상세에_반려_코드_배열과_사유가_실린다() +없는_신청과_남의_신청의_실패_응답이_같다() // 13430, body 동일 대조 + +// 수정 재제출 (검증: FR-13, FR-14) +반려된_신청을_수정하면_심사_중으로_돌아간다() // 이력 추가·신청 번호 불변 +심사_중_신청의_수정은_거부한다() // 13434 +승인된_신청의_수정은_거부한다() // 13434 +동시_재제출은_한_건만_성공한다() // 조건부 UPDATE 원자 전이, 진 쪽 13434 +수정하면_위치와_대표_격자가_전체_교체된다() +수정에서_이미지_키를_생략하면_기존_이미지가_유지된다() // null/생략 = 유지 계약 +남의_신청_수정_요청도_같은_실패_응답이다() // 13430. 남의 REJECTED 신청 포함: + // UPDATE 술어의 userId 가 수정을 막고, + // 소유 조회 분기가 13434 로 새지 않음을 함께 검증 + +// 인가 회귀 (검증: FR-5 접점, MSG-496 matcher 실경로 확인) +비로그인으로_신청_API_접근은_401이다() +USER_토큰으로_신청_API_접근은_403이다() +mustChange_상태의_ORG_토큰으로_신청_제출은_차단된다() // 검증: FR-21. 403·2441, + // MSG-497 인터셉터의 프리픽스 자동 커버 확인 +``` + +S3가 걸린 테스트는 프로필 이미지 테스트 선례의 수단(mock S3Client·S3Presigner)을 따르고, +직렬화 검증이 필요하면 `@JsonTest`를 쓴다(`new ObjectMapper()` 손조립 금지). + +--- + +[^1]: 행사 운영자: 행사를 등록하는 외부 주체(지자체, 팝업 운영사, 축제 대행사). 서버 역할값 + ORG다. "행사"를 떼고 운영자라고만 쓰면 필맵 쪽 사람을 가리키므로 붙여 쓴다(glossary.md). +[^2]: 대표 격자: 위치의 영상이 실제로 연결되는 격자 하나. 홀수 행렬 직사각형이면 정중앙, + 아니면 영역 중심에 가장 가까운 포함 격자다. 계산기는 `global.geo.RepresentativeGridResolver` + (MSG-459에서 공용 승격)를 그대로 재사용한다. +[^3]: 존재 은닉: 없는 자원과 권한 없는 자원의 실패 응답을 똑같이 만들어, 응답 차이로 남의 + 데이터 존재 여부를 추측하지 못하게 하는 방식. 이 도메인의 13404 선례와 같은 원칙이다. +[^4]: 2단 업로드: 서버가 서명한 임시 업로드 URL(presigned URL)을 먼저 받아 클라이언트가 S3에 + 직접 파일을 올리고, 받은 키로 확정 API를 부르는 흐름. 파일이 서버를 경유하지 않는다. +[^5]: 표시명 재료: 격자를 사람이 읽는 이름으로 조립하는 세 값(zoneName, zoneCell, regionName). + 서버가 계산해 격자를 담는 응답에 동봉하고, FE는 조립만 한다(MSG-341, MSG-349 계약). +[^6]: areaRects: 격자 인덱스로 표현한 사각형 배열. 사각형 하나는 (minGridY, maxGridY, minGridX, + maxGridX) 네 정수이고, 기존 행사 시드 `seed/events.json`이 쓰는 형식 그대로다. + +--- + +## 작업 로그 + +### 2026-08-29 — 구현 완료 (Owner B) + +모듈 5개로 나눠 구현했다. 스키마·에러 코드(V49 + `EventErrorCode` 13430대 + 격자 인덱스 상한 공용 +승격) → 엔티티·리포지토리 → 서비스·DTO·컨트롤러 → 단위 테스트 → 통합·동시성 테스트 순이고, +신규 테스트는 59건(단위 25 · 통합 34)이다. `event.*`와 `global.*` 회귀 green. +Codex 교차 리뷰 3라운드에서 채택 3건(복사 보상 등록 순서, 목록 건수 파생, 리스트 원소 null 400)을 +반영했고, 두 건은 회귀 테스트를 먼저 깨뜨려 비공허성을 확인한 뒤 고쳤다. + +#### 1. 데이터 저장 위치 + +**PostgreSQL** — `V49__event_submissions.sql`이 시퀀스 1개와 테이블 4개를 만든다. + +| 객체 | 저장하는 것 | 강제 | +|---|---|---| +| `event_submission_no_seq` | 신청 번호의 전역 순번 (연도별 리셋 없음) | — | +| `event_submissions` | 신청 본문. `submission_no` UNIQUE, `user_id` FK, 유형·상태 문자열, 기간 `DATE`, `image_key`(확정 프리픽스), `created_at`·`updated_at`(UTC) | `chk_event_sub_type`(FESTIVAL·POPUP) · `chk_event_sub_status`(3값) · `chk_event_sub_period`(`starts_on <= ends_on`) | +| `event_submission_locations` | 위치. `display_order`(1부터) · `representative_grid_id`(서버 계산). **이름 컬럼 없음** | 부모 FK `ON DELETE CASCADE` | +| `event_submission_location_rects` | 제출 원본 사각형(PK 없는 컬렉션 테이블) | `chk_event_sub_rect_y`·`chk_event_sub_rect_x`(min ≤ max), 부모 FK CASCADE | +| `event_submission_status_history` | 상태 전이 append 로그. 반려 행만 `reason_codes`(쉼표 문자열)·`reason_text` 보유 | `chk_event_sub_hist_reason_required`·`..._absent`(반려 행에만 사유가 있다) | + +격자 컬럼은 `grids`를 FK 하지 않는다(lazy insert라 행 존재 비보장, V39 선례). + +**S3** — 버킷은 `aws.s3.bucket` 하나를 공유하고 프리픽스로 가른다. + +- 업로드 대기: `event-submissions/pending/{userId}/{uuid}.{jpg|jpeg|png}` — presign이 발급하고, + 확정에 성공하면 커밋 후 삭제된다. 남은 것은 라이프사이클이 회수한다(런북 참조). +- 확정본: `event-submissions/original/{userId}/{uuid}.{ext}` — **uuid는 복사 시점에 새로 발급**한다. + 공개 읽기를 열지 않고 열람은 presigned GET(`ThumbnailUrlPresigner`, TTL 10분)뿐이다. + +#### 2. 실행 쿼리 (실측) + +`SPRING_JPA_SHOW_SQL=true`로 통합 테스트를 돌려 캡처한 생성 SQL이다(추정 아님). + +신청 번호 — 유일한 native. PostgreSQL 시퀀스 함수라 JPA 표준에 동등 표현이 없다. + +```sql +SELECT nextval('event_submission_no_seq') +``` + +재제출의 상태 복귀 — JPQL `@Modifying(clearAutomatically = true)`가 만드는 조건부 UPDATE. +술어에 `user_id`와 `status`가 함께 들어가 검사와 갱신이 한 문장이다. + +```sql +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' +``` + +존재 은닉 조회(`findByIdAndUserId` + `@EntityGraph("locations")`) — 위치를 left join으로 함께 +읽어 상세가 위치 수만큼 쿼리를 내지 않는다. + +```sql +select es1_0.id, ..., l1_0.id, l1_0.display_order, l1_0.representative_grid_id, ... + from event_submissions es1_0 + left join event_submission_locations l1_0 on es1_0.id=l1_0.event_submission_id + where es1_0.id=? and es1_0.user_id=? order by l1_0.display_order +``` + +내 목록(파생 쿼리) — 상태별 건수는 이 결과에서 센다(GROUP BY 쿼리 없음). + +```sql +select es1_0.id, ..., es1_0.user_id from event_submissions es1_0 + where es1_0.user_id=? order by es1_0.created_at desc, es1_0.id desc +``` + +이력 조회와 사각형 로딩 — 이력은 파생 쿼리 1문, 사각형은 `@BatchSize(20)`가 위치별 1문을 묶는다. + +```sql +select essh1_0.id, essh1_0.created_at, essh1_0.event_submission_id, essh1_0.reason_codes, + essh1_0.reason_text, essh1_0.status + from event_submission_status_history essh1_0 + where essh1_0.event_submission_id=? order by essh1_0.id + +select r1_0.event_submission_location_id, r1_0.max_grid_x, r1_0.max_grid_y, + r1_0.min_grid_x, r1_0.min_grid_y + from event_submission_location_rects r1_0 where r1_0.event_submission_location_id=? +``` + +쓰기 — 제출은 INSERT 3종(신청 → 위치 → 사각형), 재제출의 위치 전체 교체는 orphanRemoval이 +DELETE 2문을 내고 다시 INSERT한다. + +```sql +insert into event_submissions (created_at, description, ends_on, image_key, operating_hours, + organizer_name, program_description, starts_on, status, submission_no, title, type, + updated_at, user_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +insert into event_submission_locations (display_order, representative_grid_id, + event_submission_id) values (?, ?, ?) +insert into event_submission_location_rects (event_submission_location_id, max_grid_x, + max_grid_y, min_grid_x, min_grid_y) values (?, ?, ?, ?, ?) +insert into event_submission_status_history (created_at, event_submission_id, reason_codes, + reason_text, status) values (?, ?, ?, ?, ?) + +delete from event_submission_location_rects where event_submission_location_id=? +delete from event_submission_locations where id=? +``` + +위치의 FK는 자식 INSERT에 함께 실린다(연관 주인이 자식 `@ManyToOne`이라 뒤따르는 UPDATE가 없다, +결정 D-12). native는 시퀀스 하나뿐이고 나머지는 전부 파생 쿼리와 JPQL이다. + +#### 3. 예외 흐름 + +전부 `ApiException`으로 던져지고 `GlobalExceptionHandler`가 `ErrorCodeIfs`의 status·code·message로 +변환한다. 컨트롤러·서비스에 try-catch 기반 응답 조립이 없다. + +| developCode | 던지는 곳 | HTTP | +|---|---|---| +| 13430 `SUBMISSION_NOT_FOUND` | `EventSubmissionServiceImpl.findOwned` (상세·재제출의 소유 조회가 빈 결과) | 404 | +| 13431 `INVALID_SUBMISSION_AREA` | `buildLocations`(위치 0·21개 이상) · `expand`(사각형 0·82개 이상) · `validateRect`(min>max) · `requireIndex`(0 이하·100,000 이상) | 400 | +| 13432 `SUBMISSION_AREA_LIMIT_EXCEEDED` | `expand` — 사각형 하나의 칸 수 선검사(전개 전) 또는 전개 집합 크기 82 이상 | 400 | +| 13433 `INVALID_SUBMISSION_PERIOD` | `validatePeriod`(시작>종료, 종료일이 KST 오늘 이전) | 400 | +| 13434 `SUBMISSION_NOT_EDITABLE` | `resubmit` — 조건부 UPDATE 영향 행 0 + 소유 조회 성공(상태 위반·동시 재제출 패자) | 409 | +| 13435 `SUBMISSION_IMAGE_KEY_INVALID` | `EventSubmissionImageStore.validatePendingKey`(남의 프리픽스·확장자 없음·허용 밖) | 400 | +| 13436 `SUBMISSION_IMAGE_NOT_UPLOADED` | `requireObjectExists`(NoSuchKey 또는 404 S3Exception) · `confirm`(실측 0바이트) | 400 | +| 13437 `SUBMISSION_IMAGE_UNSUPPORTED` | `presign` — 확장자·contentType 쌍 위반 | 415 | +| 13438 `SUBMISSION_IMAGE_TOO_LARGE` | `presign`(선언 크기) · `confirm`(HEAD 실측 크기) | 413 | +| 13439 `SUBMISSION_REQUIRED_FIELD_MISSING` | `validateForm` — 유형별 필수 부재 또는 반대 유형 필드 존재 | 400 | + +형태 수준 위반은 도메인에 닿기 전에 끝난다. `@Valid` 실패는 +`MethodArgumentNotValidException` → 공통 400(필드별 메시지 조합)이고, 여기에 필수 문자열 공백, +서술 10자 미만, 100자·2000자 초과, **리스트 원소 null**(`List<@NotNull @Valid ...>`)이 들어간다. +정의되지 않은 `type` 문자열은 역직렬화 실패라 `HttpMessageNotReadableException` → 공통 400이다. + +정리용 S3 호출은 예외를 밖으로 내보내지 않는다. `deleteQuietly`가 `SdkException`을 잡아 로그만 +남기고(고아 객체는 비용 문제일 뿐 도메인 불변식이 아니다), 그래서 커밋된 신청이 정리 실패 때문에 +500으로 뒤집히지 않는다. 반면 확정 경로의 `copyObject`·`headObject` 실패는 그대로 전파돼 롤백된다. + +#### 4. 빈 동작 + +`EventSubmissionServiceImpl`은 `@Service` 프록시이고 쓰기 경로 두 개(`submit`·`resubmit`)가 +`@Transactional`, 조회 두 개가 `@Transactional(readOnly = true)`다. presign 발급만 트랜잭션이 +없다(DB를 건드리지 않는 로컬 서명이라 열 이유가 없다). `Clock`은 생성자 주입이고 프로덕션 +생성자가 `Clock.systemUTC()`를 고정한다(인자 없는 `now()` 금지 컨벤션, `EventVideoServiceImpl` 선례). + +`EventSubmissionImageStore`가 `TransactionSynchronization` 훅 3종을 등록한다. + +| 훅 | 등록 시점 | 실행 시점 | 하는 일 | +|---|---|---|---| +| `afterCompletion` 보상 | `copyObject` **호출 직전** | 트랜잭션 롤백 | 방금 만든 확정본 삭제 | +| `afterCommit` pending 정리 | 복사 성공 직후 | 커밋 후 | 확정에 쓴 pending 삭제 | +| `afterCommit` 이전본 정리 | 재제출에서 이미지 교체가 확정된 뒤 | 커밋 후 | 밀려난 이전 확정본 삭제 | + +보상 등록이 복사 호출보다 **앞**인 것이 계약이다(Codex 2라운드). 목적지 키는 복사 전에 이미 +정해져 있고, S3가 복사를 마쳤는데 응답이 유실·타임아웃되면 등록이 뒤일 때 고아가 영구히 남는다. +복사가 진짜 실패했다면 보상은 없는 키 삭제라 no-op다(DeleteObject는 404에도 성공). +`STATUS_UNKNOWN`(커밋 결과 불명)에는 **지우지 않는다** — 커밋됐을 수 있는 이미지를 지우면 유실이고 +고아는 비용 문제다. 확정 키가 시도마다 새 uuid라 이 보상이 다른 시도의 객체에 닿을 수 없다. + +경계 특이점 둘. ① 세 훅 모두 트랜잭션이 없으면 다르게 행동한다 — `afterCommit`은 즉시 실행으로 +폴백하고(`VideoServiceImpl` 패턴), 보상은 **아무것도 하지 않는다**(즉시 실행하면 방금 복사한 원본을 +그 자리에서 지운다). ② 재제출의 벌크 UPDATE는 영속성 컨텍스트를 우회하므로 +`clearAutomatically = true`가 스냅숏을 비우고, 그 뒤 애그리거트를 재로드해야 상태가 스테일하지 않다. +그래서 이 트랜잭션 안에서는 UPDATE 앞뒤로 같은 행을 두 번 읽는다(앞은 유형 확인용 읽기 전용). + +#### 미채택으로 남긴 리뷰 지적 + +- **트랜잭션 안 S3 호출** — 확정 경로가 커밋 전에 HEAD·복사를 호출해 DB 커넥션을 네트워크 왕복 + 동안 붙들고 있다. 프로필 이미지(MSG-373)와 완전히 같은 구조라 이 티켓만 다른 모양으로 고치면 + 두 경로가 갈라진다. **후속 티켓 후보**로 남긴다(둘을 같이 옮기는 작업이라야 의미가 있다). +- **pending 키 멱등화** — 같은 pending 키로 두 번 제출하면 신청이 둘 생긴다는 지적. 스펙 단계에서 + 3회, 구현 리뷰에서 3회 등판했고 매번 기각했다. 복사 성공한 pending을 커밋 후 지우므로 두 번째 + 시도는 HEAD 실패로 13436이 되고, 남는 위험은 "사용자가 폼을 두 번 제출"뿐이라 콘솔 목록 가시성과 + 관리자 심사로 충분하다. 멱등 키를 도입하면 저장·만료·충돌 응답 규약이 새로 생기는데 얻는 것이 + 그만큼이 아니다. + +#### 운영 런북 + +- **배포 전 필요**: S3 라이프사이클 규칙에 `event-submissions/pending/` 프리픽스 만료 추가 + (확정 없이 버려진 업로드 회수 — 프로필 이미지 선례와 같은 성격). 없으면 pending이 무한히 쌓인다. +- `event-submissions/original/`에는 **공개 읽기를 열지 않는다.** 열람자가 신청 소유자와 관리자뿐이라 + presigned GET으로 충분하고, 공개로 열면 키를 아는 누구나 심사 자료를 볼 수 있다. +- 새 환경변수·설정은 없다. 버킷·리전은 기존 `aws.*`를 그대로 쓴다. +- 로컬 공유 DB에는 이 스크립트가 **V47로 먼저 적용**됐다(테스트 실행의 부수효과 — 499가 V48로 민 + 원인이기도 하다). V49 재번호 후 `flyway_schema_history`의 해당 행을 V49로 갱신해 정합을 맞췄다 + (파일 내용 불변이라 checksum은 그대로 유효). dev·prod는 처음부터 V49로 적용된다. + +#### MSG-500 인계 조건 + +- **위치를 소비할 때 같은 검증을 통과한 값임을 전제해도 된다.** 저장된 사각형은 접수 시점에 인덱스 + 범위·min ≤ max·합집합 81칸을 통과했고 `representative_grid_id`는 서버가 계산한 값이다. 승인이 + `event_locations`·`event_location_grids`로 전개할 때 재검증 없이 써도 되지만, **전개 결과의 셀 수는 + 다시 확인하는 편이 좋다** — 행사 위치 쪽 상한(2,500칸)과 신청 상한(81칸)이 다른 값이다. +- **반려 사유의 저장 원천은 이력 테이블 하나다(D-3).** 상세의 `rejection`은 이력을 id 오름차순으로 + 읽어 **마지막 행**에서 만든다. MSG-500이 반려를 쓸 때 반드시 새 행을 append해야 하고(기존 행 갱신 + 금지), 반려 행에는 코드와 본문 두 벌이 함께 있어야 한다(DDL CHECK가 강제한다). 상태 컬럼과 이력을 + 같은 트랜잭션에서 함께 바꾸지 않으면 상세가 "반려인데 사유 없음"을 잠깐 그린다. diff --git a/docs/srs-changelog.md b/docs/srs-changelog.md index 352878dd..28d752b4 100644 --- a/docs/srs-changelog.md +++ b/docs/srs-changelog.md @@ -141,3 +141,4 @@ union이 붙어 양쪽 행이 그대로 남는다. 2026-08-14에 같은 자리 | 2026-08-28 | FR-EVENT-16 등재 (진행 중). 등록 유형 "이벤트"의 참여 대상인 승인 이벤트 목록 조회를 등재했다. 목록 범위는 이벤트 카테고리(지역축제와 팝업스토어 제외)의 종료 전 회차만으로 사용자 확정(시안 배지는 구판 잔재), 노출 시작 전 예정 회차도 콘솔에는 보인다. FR-EVENT-13의 세 번째 유형 서술(행사방)을 v2.1 이벤트 참여형으로 고치는 개정은 MSG-498 레인이 실을 몫이라 이번에 손대지 않았다 | MSG-501 등재 (2026-08-28 사용자 확정), PRD `docs/prd/event-submission.md` FR-26 최소 개정 | | 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) | diff --git a/docs/srs.md b/docs/srs.md index b3e79e27..fcdb3125 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -392,8 +392,8 @@ 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 | 행사 운영자는 등록 유형(지역축제, 팝업스토어, 행사방) 중 하나를 골라 행사를 신청할 수 있고 유형마다 기본 정보 항목이 다르다. 위치는 지도 격자 사각형 영역으로 지정하며 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있고 대표 격자는 신청자가 아니라 서버가 계산한다. 접수된 신청은 심사 중 상태가 되고 신청 번호가 부여된다 | 계획 | PRD `docs/prd/event-submission.md` 초안 (2026-08-27, 웹 ver 13 행사 등재 v2 시안 `15473:3730`). 위치 영역 형식은 기존 시드 areaRects와 동일. 위치당 영역 크기 상한과 임시 저장 여부는 미확정(미해결 질문) | -| FR-EVENT-14 | 행사 운영자는 자기 신청의 목록과 상태별 건수, 상세(상태 변경 이력과 반려 사유 포함)를 조회할 수 있다. 다른 운영자의 신청은 존재를 은닉하는 단일 실패 응답이다. 반려된 신청만 수정해 재제출할 수 있고 재제출하면 심사 중 상태로 돌아간다 | 계획 | PRD `docs/prd/event-submission.md` 초안 (2026-08-27) | +| 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-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 레인 몫이라 여기서 손대지 않는다 | 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 2ba39d36..c1c1f45f 100644 --- a/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java +++ b/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java @@ -41,6 +41,22 @@ public enum EventErrorCode implements ErrorCodeIfs { // 엉뚱한 경계(종료)를 지목해서, 종료 후 30일 동안 댓글을 달아 온 사용자가 31일째에 "종료돼서 // 안 된다"는 답을 듣는 모순도 있었다. developCode·HttpStatus 는 불변이고 표시 문자열만 바꿨다. EVENT_INTERACTION_LOCKED(13422, HttpStatus.CONFLICT, "행사 영상 댓글·도움돼요가 마감되었습니다"), + + // 행사 등재 신청 블록 (MSG-498). 13423~13429 를 비워 두는 것은 폐기 이력이 있는 13420·13421 부근을 + // 피해 번호만 봐도 신청 블록임이 읽히게 하기 위해서다. + // 없는 신청과 남의 신청이 같은 코드를 쓰는 것은 존재 은닉이다 (FR-14) — 조회를 항상 id + userId 쌍으로 + // 하므로 두 경우의 코드 경로 자체가 하나이고, 응답이 갈릴 여지가 없다. + SUBMISSION_NOT_FOUND(13430, HttpStatus.NOT_FOUND, "신청을 찾을 수 없습니다"), + INVALID_SUBMISSION_AREA(13431, HttpStatus.BAD_REQUEST, "유효하지 않은 위치 영역입니다"), + SUBMISSION_AREA_LIMIT_EXCEEDED(13432, HttpStatus.BAD_REQUEST, "위치 하나의 영역은 최대 81칸입니다"), + INVALID_SUBMISSION_PERIOD(13433, HttpStatus.BAD_REQUEST, "행사 기간이 유효하지 않습니다"), + // 권한이 아니라 신청의 현재 상태와 요청이 충돌하는 거절이라 409 다 (13409 · 11409 선례). + SUBMISSION_NOT_EDITABLE(13434, HttpStatus.CONFLICT, "반려된 신청만 수정할 수 있습니다"), + SUBMISSION_IMAGE_KEY_INVALID(13435, HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키입니다"), + SUBMISSION_IMAGE_NOT_UPLOADED(13436, HttpStatus.BAD_REQUEST, "업로드되지 않은 이미지입니다"), + 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, "등록 유형에 필요한 항목이 올바르지 않습니다"), ; private final Integer errorCode; diff --git a/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java b/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java index 2957c265..4236c23d 100644 --- a/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java +++ b/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java @@ -62,9 +62,6 @@ public class EventSeeder implements ApplicationRunner { /** 위치당 고유 셀 상한 (5km × 5km 상당) — zones 의 남북 26행 캡과 같은 성격의 입력 상한. */ static final int MAX_CELLS_PER_LOCATION = 2500; - /** EPSG:5179 국내 정의역을 넉넉히 포함하는 방어 상한. 정수 제곱 산술의 오버플로 여유도 여기서 나온다. */ - private static final int GRID_INDEX_UPPER_EXCLUSIVE = 100_000; - /** 일정 변경 알림 문구 (MSG-442 발송 표 확정값). */ private static final String SCHEDULE_CHANGED_BODY = "행사 일정이 변경됐어요. 새 일정을 확인해 보세요"; @@ -302,9 +299,9 @@ private void validateRect(EventSeed.Rect rect, String context) { } private void requireIndex(Integer index, String context) { - if (index == null || index <= 0 || index >= GRID_INDEX_UPPER_EXCLUSIVE) { + if (index == null || index <= 0 || index >= RepresentativeGridResolver.GRID_INDEX_UPPER_EXCLUSIVE) { throw new IllegalStateException("%s 의 격자 인덱스 %s 가 허용 범위(0 초과 %d 미만) 밖입니다".formatted( - context, index, GRID_INDEX_UPPER_EXCLUSIVE)); + context, index, RepresentativeGridResolver.GRID_INDEX_UPPER_EXCLUSIVE)); } } 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 new file mode 100644 index 00000000..12ce4255 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/controller/EventSubmissionController.java @@ -0,0 +1,113 @@ +package com.msg.fillmap.event.submission.controller; + +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.PatchMapping; +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.RestController; + +import lombok.RequiredArgsConstructor; + +import com.msg.fillmap.auth.jwt.AuthPrincipal; +import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionDetailResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionMyListResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionSubmitResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionUpdateRequestDto; +import com.msg.fillmap.event.submission.service.EventSubmissionService; +import com.msg.fillmap.response.SuccessResponse; + +/** + * 행사 등재 신청 API (MSG-498). 역할 인가는 SecurityConfig 의 {@code /api/org/**} matcher 가 전담하고 + * (MSG-496 — 비로그인 401, USER·ADMIN 403), 초기 비밀번호 상태의 계정은 게이트 인터셉터(MSG-497)가 여기 + * 닿기 전에 막는다. 컨트롤러에 역할 검사가 없는 이유이고, 내 신청인지의 판정(FR-14)은 서비스 계층 몫이다. + */ +@Tag(name = "행사 등재 신청 (Org Submission)", description = "행사 운영자가 행사를 신청하고 반려본을 고쳐 다시 낸다.") +@RestController +@RequestMapping("/api/org/event-submissions") +@RequiredArgsConstructor +public class EventSubmissionController { + + private final EventSubmissionService eventSubmissionService; + + @Operation( + summary = "대표 이미지 presigned URL 발급", + description = "받은 uploadUrl 로 S3 에 직접 PUT 업로드한 뒤, 응답의 s3Key 를 신청 제출·재제출 요청의 " + + "imageS3Key 로 넘긴다. jpg·jpeg·png 만 받고 상한은 10MB 다." + ) + @PostMapping("/image/presigned-url") + public SuccessResponse issueImagePresignedUrl( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal, + @Valid @RequestBody EventSubmissionImagePresignRequestDto request + ) { + return SuccessResponse.of(eventSubmissionService.issueImagePresignedUrl(principal.userId(), request)); + } + + @Operation( + summary = "행사 등재 신청 제출", + description = "심사 중 상태로 접수하고 신청 번호(FM-2026-XXXX 꼴)를 부여한다. 위치마다 대표 격자를 " + + "서버가 계산해 저장하며, 위치 하나의 영역은 겹침을 한 번만 세는 합집합 기준 최대 81칸이다.\n\n" + + "유형별 필수 항목이 다르다 — FESTIVAL 은 주요 프로그램, POPUP 은 운영 시간이고 자기 유형이 " + + "아닌 항목이 실려 오면 거부한다. 위치에는 이름 필드가 없고 배열 순서가 곧 순번이다." + ) + @PostMapping + public SuccessResponse submit( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal, + @Valid @RequestBody EventSubmissionCreateRequestDto request + ) { + return SuccessResponse.of(eventSubmissionService.submit(principal.userId(), request)); + } + + @Operation( + summary = "내 신청 목록", + description = "콘솔 홈 현황 카드와 최근 신청 목록의 재료다. 상태별 건수는 내 신청 전체 기준이고 " + + "목록은 최신 제출 순이다. 페이지네이션은 없다." + ) + @GetMapping("/my") + public SuccessResponse getMySubmissions( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal + ) { + return SuccessResponse.of(eventSubmissionService.getMySubmissions(principal.userId())); + } + + @Operation( + summary = "신청 상세", + description = "기본 정보와 위치 목록(순번·대표 격자·표시명 재료·제출 원본 사각형), 상태 이력, 반려 " + + "항목과 사유를 돌려준다. 반려 항목은 현재 상태가 반려일 때만 값이 있고, 과거 반려는 재제출 " + + "뒤에도 이력에 남는다.\n\n" + + "없는 신청과 남의 신청은 완전히 같은 실패 응답이다 — 응답 차이로 남의 신청 존재를 추측할 수 없다." + ) + @GetMapping("/{submissionId}") + public SuccessResponse getSubmission( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal, + @Parameter(description = "신청 id", example = "7") @PathVariable Long submissionId + ) { + return SuccessResponse.of(eventSubmissionService.getSubmission(principal.userId(), submissionId)); + } + + @Operation( + summary = "반려본 수정 재제출", + description = "반려된 신청만 수정할 수 있고, 재제출하면 상태가 심사 중으로 돌아간다(신청 번호는 그대로다). " + + "부분 수정이 아니라 전체 교체이고 등록 유형은 바꿀 수 없다 — 유형을 바꾸려면 새로 제출한다.\n\n" + + "imageS3Key 를 생략하거나 null 로 보내면 기존 대표 이미지가 유지되고, 새 pending 키를 보내면 교체된다." + ) + @PatchMapping("/{submissionId}") + public SuccessResponse resubmit( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrincipal principal, + @Parameter(description = "신청 id", example = "7") @PathVariable Long submissionId, + @Valid @RequestBody EventSubmissionUpdateRequestDto request + ) { + return SuccessResponse.of(eventSubmissionService.resubmit(principal.userId(), submissionId, request)); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionAreaRectDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionAreaRectDto.java new file mode 100644 index 00000000..3b8bdce1 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionAreaRectDto.java @@ -0,0 +1,38 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotNull; + +import com.msg.fillmap.event.submission.entity.EventSubmissionAreaRect; + +/** + * 위치 영역 사각형 하나 (MSG-498). 격자 인덱스 네 정수이고 형식은 seed/events.json 과 같다. + * 제출 요청과 상세 응답이 같은 타입을 쓴다 — 상세의 사각형이 "제출 원본 그대로"라 형태가 같아야 재제출 + * 폼 프리필이 변환 없이 성립하기 때문이다. + */ +@Schema(description = "위치 영역 사각형 (격자 인덱스). 위치 하나의 합집합은 최대 81칸이다.", + requiredProperties = {"minGridY", "maxGridY", "minGridX", "maxGridX"}) +public record EventSubmissionAreaRectDto( + @Schema(description = "격자 행 인덱스 최소", example = "16859") + @NotNull Integer minGridY, + + @Schema(description = "격자 행 인덱스 최대", example = "16861") + @NotNull Integer maxGridY, + + @Schema(description = "격자 열 인덱스 최소", example = "11509") + @NotNull Integer minGridX, + + @Schema(description = "격자 열 인덱스 최대", example = "11515") + @NotNull Integer maxGridX +) { + + public static EventSubmissionAreaRectDto from(EventSubmissionAreaRect rect) { + return new EventSubmissionAreaRectDto(rect.getMinGridY(), rect.getMaxGridY(), + rect.getMinGridX(), rect.getMaxGridX()); + } + + public EventSubmissionAreaRect toEntity() { + return new EventSubmissionAreaRect(minGridY, maxGridY, minGridX, maxGridX); + } +} 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 new file mode 100644 index 00000000..7e2120d2 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionCreateRequestDto.java @@ -0,0 +1,54 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDate; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import com.msg.fillmap.event.submission.entity.EventSubmissionType; + +/** + * 행사 등재 신청 제출 요청 (MSG-498 FR-7). 유형별 항목 매핑은 title = 축제명/팝업명, + * organizerName = 주최 기관/브랜드·운영사, startsOn·endsOn = 축제 기간/운영 기간이다. + * 서술 항목은 구조화 없이 String 이고 최소 10자다 (피그마 #100). + */ +@Schema(description = "행사 등재 신청 제출 요청") +public record EventSubmissionCreateRequestDto( + @Schema(description = "등록 유형 — FESTIVAL(지역축제) 또는 POPUP(팝업스토어)", example = "FESTIVAL") + @NotNull EventSubmissionType type, + + @Schema(description = "축제명 / 팝업명", example = "부산불꽃축제") + @NotBlank @Size(max = 100) String title, + + @Schema(description = "주최 기관 / 브랜드·운영사", example = "부산문화관광축제조직위원회") + @NotBlank @Size(max = 100) String organizerName, + + @Schema(description = "행사 시작일 (KST 날짜)", example = "2026-11-07") + @NotNull LocalDate startsOn, + + @Schema(description = "행사 종료일 (KST 날짜). 오늘 이전이면 13433", example = "2026-11-07") + @NotNull LocalDate endsOn, + + @Schema(description = "운영 시간 — POPUP 전용 필수. FESTIVAL 에 실려 오면 13439", example = "11:00 ~ 20:00") + @Size(max = 100) String operatingHours, + + @Schema(description = "주요 프로그램 — FESTIVAL 전용 필수. POPUP 에 실려 오면 13439", + example = "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영") + @Size(min = 10, max = 2000) String programDescription, + + @Schema(description = "행사 소개", example = "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제") + @NotBlank @Size(min = 10, max = 2000) String description, + + @Schema(description = "대표 이미지의 pending S3 키. presign 발급 응답의 s3Key 를 그대로 넣는다.", + example = "event-submissions/pending/12/3f0c1f2e-....jpg") + @NotBlank String imageS3Key, + + @Schema(description = "행사 위치 목록. 1개 이상 20개 이하이고 이름 필드가 없다.") + List<@NotNull @Valid EventSubmissionLocationRequestDto> locations +) implements EventSubmissionForm { +} 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 new file mode 100644 index 00000000..c21c419c --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionDetailResponseDto.java @@ -0,0 +1,66 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 신청 상세 (MSG-498 FR-12). 없는 신청과 남의 신청은 여기 오지 못하고 같은 13430 을 받는다 (FR-14). + * 저장 S3 키는 내부 값이라 노출하지 않고 열람은 presigned GET 하나로 한다. + */ +@Schema(description = "신청 상세", + requiredProperties = {"id", "submissionNo", "type", "status", "title", "organizerName", "startsOn", "endsOn", + "operatingHours", "programDescription", "description", "imageUrl", "locations", "rejection", "history", + "updatedAt"}) +public record EventSubmissionDetailResponseDto( + @Schema(description = "신청 id", example = "7") + Long id, + + @Schema(description = "신청 번호", example = "FM-2026-0007") + String submissionNo, + + @Schema(description = "등록 유형", example = "FESTIVAL") + String type, + + @Schema(description = "신청 상태", example = "REJECTED") + String status, + + @Schema(description = "축제명 / 팝업명") + String title, + + @Schema(description = "주최 기관 / 브랜드·운영사") + String organizerName, + + @Schema(description = "행사 시작일", example = "2026-11-07") + LocalDate startsOn, + + @Schema(description = "행사 종료일", example = "2026-11-07") + LocalDate endsOn, + + @Schema(description = "운영 시간 — POPUP 만 값이 있다", nullable = true) + String operatingHours, + + @Schema(description = "주요 프로그램 — FESTIVAL 만 값이 있다", nullable = true) + String programDescription, + + @Schema(description = "행사 소개") + String description, + + @Schema(description = "대표 이미지 열람용 presigned GET URL") + String imageUrl, + + @Schema(description = "위치 목록 — 순번 오름차순") + List locations, + + @Schema(description = "현재 반려 사유 — 상태가 REJECTED 일 때만 값이 있다", nullable = true) + EventSubmissionRejectionResponseDto rejection, + + @Schema(description = "상태 이력 — 발생 순") + List history, + + @Schema(description = "마지막 변경 시각 (UTC)", example = "2026-08-28T02:11:00Z") + LocalDateTime updatedAt +) { +} 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 new file mode 100644 index 00000000..6a5b0c0c --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionForm.java @@ -0,0 +1,33 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDate; +import java.util.List; + +/** + * 제출과 재제출이 공유하는 신청 폼 (MSG-498). 재제출이 부분 수정이 아니라 전체 교체라 두 요청의 내용이 + * 같고, 다른 것은 유형뿐이다(제출에만 있고 재제출에서는 바꿀 수 없다, D-8). 검증과 저장이 두 벌로 + * 갈라지지 않게 서비스는 이 타입 하나만 본다. + */ +public interface EventSubmissionForm { + + String title(); + + String organizerName(); + + LocalDate startsOn(); + + LocalDate endsOn(); + + /** POPUP 전용 필수. FESTIVAL 에 실려 오면 13439 다. */ + String operatingHours(); + + /** FESTIVAL 전용 필수. POPUP 에 실려 오면 13439 다. */ + String programDescription(); + + String description(); + + /** pending 키. 재제출에서만 null 이 허용되고 그 뜻은 "기존 이미지 유지"다. */ + String imageS3Key(); + + List locations(); +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionHistoryResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionHistoryResponseDto.java new file mode 100644 index 00000000..1db006e2 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionHistoryResponseDto.java @@ -0,0 +1,39 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import com.msg.fillmap.event.submission.entity.EventSubmissionReasonCode; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatusHistory; + +/** + * 상태 이력 한 줄 (MSG-498 FR-12). 재제출로 상태가 심사 중으로 돌아가도 과거 반려 행은 여기 그대로 남아 + * "어떤 이유로 반려됐었는지"를 계속 확인할 수 있다. + */ +@Schema(description = "신청 상태 이력 항목", + requiredProperties = {"status", "reasonCodes", "reasonText", "changedAt"}) +public record EventSubmissionHistoryResponseDto( + @Schema(description = "전이 후 상태", example = "REJECTED") + String status, + + @Schema(description = "반려 항목 코드 — 반려 행에만 있고 그 외에는 null", nullable = true) + List reasonCodes, + + @Schema(description = "반려 사유 본문 — 반려 행에만 있고 그 외에는 null", nullable = true) + String reasonText, + + @Schema(description = "전이 시각 (UTC)", example = "2026-08-28T02:00:00Z") + LocalDateTime changedAt +) { + + public static EventSubmissionHistoryResponseDto from(EventSubmissionStatusHistory history) { + List codes = history.getReasonCodes(); + return new EventSubmissionHistoryResponseDto( + history.getStatus().name(), + codes == null ? null : codes.stream().map(Enum::name).toList(), + history.getReasonText(), + history.getCreatedAt()); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionImagePresignRequestDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionImagePresignRequestDto.java new file mode 100644 index 00000000..b344cb59 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionImagePresignRequestDto.java @@ -0,0 +1,21 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@Schema(description = "행사 신청 대표 이미지 업로드용 presigned URL 발급 요청 (MSG-498)") +public record EventSubmissionImagePresignRequestDto( + @Schema(description = "이미지 파일 확장자 (점 없이). jpg, jpeg, png 만 — 시안 문구가 \"JPG 또는 PNG\"라 webp 는 받지 않는다", + example = "jpg") + @NotBlank String extension, + + @Schema(description = "이미지 MIME 타입. 확장자와 쌍이 맞아야 한다", example = "image/jpeg") + @NotBlank String contentType, + + @Schema(description = "업로드할 파일 크기(바이트). 10MB 초과 시 거부", example = "1048576") + @NotNull @Positive Long contentLength +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionImagePresignResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionImagePresignResponseDto.java new file mode 100644 index 00000000..170fb32e --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionImagePresignResponseDto.java @@ -0,0 +1,20 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행사 신청 대표 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 " + + "s3Key 를 신청 제출·재제출 요청의 imageS3Key 로 전달한다.", + requiredProperties = {"uploadUrl", "s3Key", "expiresInSec"}) +public record EventSubmissionImagePresignResponseDto( + @Schema(description = "S3 에 직접 PUT 업로드할 presigned URL", + example = "https://bucket.s3.amazonaws.com/event-submissions/pending/...") + String uploadUrl, + + @Schema(description = "업로드 대상 S3 객체 키. 제출·재제출 요청에 그대로 전달한다.", + example = "event-submissions/pending/12/3f0c1f2e-....jpg") + String s3Key, + + @Schema(description = "presigned URL 유효 시간(초)", example = "600") + long expiresInSec +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionLocationRequestDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionLocationRequestDto.java new file mode 100644 index 00000000..db2c5421 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionLocationRequestDto.java @@ -0,0 +1,23 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +/** + * 신청 위치 하나 (MSG-498). 이름 필드가 없다 — 배열 순서가 곧 순번이고(서버가 1부터 매긴다) 화면 + * 식별은 순번과 지역 라벨로 한다 (피그마 #102). 영역이 비었거나 상한을 넘는 경우는 13431·13432 로 + * 도메인이 판정하므로 여기에 크기 제약을 걸지 않는다. 다만 원소 null 은 여기서 막는다 — + * 목록·원소가 있는지는 도메인 규칙이지만 {@code [null]} 은 형태가 깨진 입력이라 공통 400 이 맞고, + * 통과시키면 위치 자리 null 은 역참조 500, 사각형 자리 null 은 형식 오류가 13431 로 잘못 보고된다 + * (Codex 구현 리뷰 2R). + */ +@Schema(description = "신청 위치 — 영역 사각형 목록만 담는다 (이름 없음)") +public record EventSubmissionLocationRequestDto( + @Schema(description = "영역 사각형 목록. 겹쳐도 되고 합집합 크기로 81칸 상한을 판정한다.") + List<@NotNull @Valid EventSubmissionAreaRectDto> areaRects +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionLocationResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionLocationResponseDto.java new file mode 100644 index 00000000..b0f965d9 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionLocationResponseDto.java @@ -0,0 +1,36 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 신청 상세의 위치 하나 (MSG-498 FR-12). 이름이 없으므로 화면은 순번과 표시명 재료로 식별한다. + * 표시명 조립 규칙은 기존 계약 그대로 {@code zoneName ? zoneName + " " + zoneCell : regionName} 이다. + */ +@Schema(description = "신청 위치 상세", + requiredProperties = {"order", "representativeGridId", "zoneName", "zoneCell", "regionName", "cellCount", + "areaRects"}) +public record EventSubmissionLocationResponseDto( + @Schema(description = "위치 순번 — 제출 배열 순서대로 1부터", example = "1") + int order, + + @Schema(description = "서버가 계산한 대표 격자 id", example = "16860_11512") + String representativeGridId, + + @Schema(description = "구역 표시명 — 구역 밖이면 null", example = "서면", nullable = true) + String zoneName, + + @Schema(description = "구역 안 칸 이름 — 구역 밖이면 null", example = "A-14", nullable = true) + String zoneCell, + + @Schema(description = "행정동 이름 — 무귀속이면 null", example = "부산 수영구 광안동", nullable = true) + String regionName, + + @Schema(description = "영역 합집합 칸 수 — 최대 81", example = "21") + int cellCount, + + @Schema(description = "제출 원본 사각형 — 재제출 폼 프리필 재료라 보낸 그대로다") + List areaRects +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionMyListResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionMyListResponseDto.java new file mode 100644 index 00000000..d28bb33f --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionMyListResponseDto.java @@ -0,0 +1,16 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** 내 신청 목록 응답 (MSG-498 FR-11). 페이지네이션 없음 — 내부 소수 사용자다. 정렬은 최신 제출 순이다. */ +@Schema(description = "내 신청 목록과 상태별 건수", requiredProperties = {"counts", "submissions"}) +public record EventSubmissionMyListResponseDto( + @Schema(description = "상태별 건수 — 내 신청 전체 기준") + EventSubmissionStatusCountsResponseDto counts, + + @Schema(description = "신청 목록 — 최신 제출 순") + List submissions +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectionResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectionResponseDto.java new file mode 100644 index 00000000..707effab --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectionResponseDto.java @@ -0,0 +1,29 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import com.msg.fillmap.event.submission.entity.EventSubmissionReasonCode; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatusHistory; + +/** + * 현재 반려 사유 (MSG-498 FR-12). 현재 상태가 REJECTED 일 때만 값이 있고 아니면 null 이다. + * 값의 출처는 이력의 최신 행 하나다 — 신청 행에 사유를 중복 저장하지 않는다 (D-3). + */ +@Schema(description = "반려 항목과 사유", requiredProperties = {"reasonCodes", "reasonText"}) +public record EventSubmissionRejectionResponseDto( + @Schema(description = "반려 항목 코드 — PERIOD, AREA, IMAGE, INFO", example = "[\"AREA\", \"INFO\"]") + List reasonCodes, + + @Schema(description = "반려 사유 본문") + String reasonText +) { + + /** 이력의 반려 행 하나에서 만든다 — 사유의 저장 원천이 이력 테이블 하나라서다 (D-3). */ + public static EventSubmissionRejectionResponseDto from(EventSubmissionStatusHistory history) { + List codes = history.getReasonCodes(); + return new EventSubmissionRejectionResponseDto( + codes == null ? null : codes.stream().map(Enum::name).toList(), history.getReasonText()); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionStatusCountsResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionStatusCountsResponseDto.java new file mode 100644 index 00000000..e42ebdb1 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionStatusCountsResponseDto.java @@ -0,0 +1,17 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** 콘솔 홈 현황 카드의 상태별 건수 (MSG-498 FR-11). 해당 상태가 없으면 0 이다 (필드 자체는 항상 있다). */ +@Schema(description = "내 신청의 상태별 건수", requiredProperties = {"inReview", "approved", "rejected"}) +public record EventSubmissionStatusCountsResponseDto( + @Schema(description = "심사 중 건수", example = "2") + long inReview, + + @Schema(description = "승인 건수", example = "1") + long approved, + + @Schema(description = "반려 건수", example = "1") + long rejected +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionSubmitResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionSubmitResponseDto.java new file mode 100644 index 00000000..141f9d0f --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionSubmitResponseDto.java @@ -0,0 +1,24 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import com.msg.fillmap.event.submission.entity.EventSubmission; + +/** 제출·재제출 응답 (MSG-498). 재제출도 같은 형태다 — 신청 번호는 불변이고 상태만 심사 중으로 돌아간다. */ +@Schema(description = "신청 접수 결과", requiredProperties = {"id", "submissionNo", "status"}) +public record EventSubmissionSubmitResponseDto( + @Schema(description = "신청 id", example = "7") + Long id, + + @Schema(description = "신청 번호 — FM-{KST 연도}-{4자리 순번}", example = "FM-2026-0007") + String submissionNo, + + @Schema(description = "신청 상태", example = "IN_REVIEW") + String status +) { + + public static EventSubmissionSubmitResponseDto from(EventSubmission submission) { + return new EventSubmissionSubmitResponseDto( + submission.getId(), submission.getSubmissionNo(), submission.getStatus().name()); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionSummaryResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionSummaryResponseDto.java new file mode 100644 index 00000000..a587226b --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionSummaryResponseDto.java @@ -0,0 +1,50 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +import com.msg.fillmap.event.submission.entity.EventSubmission; + +/** 내 신청 목록의 한 줄 (MSG-498 FR-11). 카드가 그리는 값만 담고 위치·이력은 상세에서 준다. */ +@Schema(description = "내 신청 목록 항목", + requiredProperties = {"id", "submissionNo", "type", "title", "status", "startsOn", "endsOn", "updatedAt"}) +public record EventSubmissionSummaryResponseDto( + @Schema(description = "신청 id", example = "7") + Long id, + + @Schema(description = "신청 번호", example = "FM-2026-0007") + String submissionNo, + + @Schema(description = "등록 유형", example = "FESTIVAL") + String type, + + @Schema(description = "축제명 / 팝업명", example = "부산불꽃축제") + String title, + + @Schema(description = "신청 상태", example = "REJECTED") + String status, + + @Schema(description = "행사 시작일", example = "2026-11-07") + LocalDate startsOn, + + @Schema(description = "행사 종료일", example = "2026-11-07") + LocalDate endsOn, + + @Schema(description = "마지막 변경 시각 (UTC)", example = "2026-08-28T02:11:00Z") + LocalDateTime updatedAt +) { + + public static EventSubmissionSummaryResponseDto from(EventSubmission submission) { + return new EventSubmissionSummaryResponseDto( + submission.getId(), + submission.getSubmissionNo(), + submission.getType().name(), + submission.getTitle(), + submission.getStatus().name(), + submission.getStartsOn(), + submission.getEndsOn(), + submission.getUpdatedAt()); + } +} 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 new file mode 100644 index 00000000..e6c04e39 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionUpdateRequestDto.java @@ -0,0 +1,51 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDate; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 반려본 수정 재제출 요청 (MSG-498 FR-13). 제출 요청에서 유형을 뺀 전체이고, 부분 수정이 아니라 전체 + * 교체다 — 신청 하나가 폼 하나로 쓰였다 폼 하나로 고쳐지는 단위라서다 (D-8). 유형을 바꾸려면 새로 제출한다. + *

+ * 이미지만 예외적으로 유지 선택이 있다 — {@code imageS3Key} 를 null 로 보내거나 생략하면 기존 이미지가 + * 유지되고, pending 키를 보내면 교체다. 상세 응답이 저장 키를 노출하지 않으므로 클라이언트가 확정 키를 + * 알 수 없고, 알 필요도 없어야 한다. + */ +@Schema(description = "반려본 수정 재제출 요청 — 유형을 뺀 전체 교체") +public record EventSubmissionUpdateRequestDto( + @Schema(description = "축제명 / 팝업명", example = "부산불꽃축제") + @NotBlank @Size(max = 100) String title, + + @Schema(description = "주최 기관 / 브랜드·운영사", example = "부산문화관광축제조직위원회") + @NotBlank @Size(max = 100) String organizerName, + + @Schema(description = "행사 시작일 (KST 날짜)", example = "2026-11-07") + @NotNull LocalDate startsOn, + + @Schema(description = "행사 종료일 (KST 날짜). 오늘 이전이면 13433", example = "2026-11-07") + @NotNull LocalDate endsOn, + + @Schema(description = "운영 시간 — POPUP 전용 필수", example = "11:00 ~ 20:00") + @Size(max = 100) String operatingHours, + + @Schema(description = "주요 프로그램 — FESTIVAL 전용 필수", example = "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영") + @Size(min = 10, max = 2000) String programDescription, + + @Schema(description = "행사 소개", example = "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제") + @NotBlank @Size(min = 10, max = 2000) String description, + + @Schema(description = "대표 이미지의 pending S3 키. 생략하거나 null 이면 기존 이미지를 유지한다.", + example = "event-submissions/pending/12/3f0c1f2e-....jpg") + String imageS3Key, + + @Schema(description = "행사 위치 목록. 통째로 갈아끼우고 대표 격자를 전부 재계산한다.") + List<@NotNull @Valid EventSubmissionLocationRequestDto> locations +) implements EventSubmissionForm { +} 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 new file mode 100644 index 00000000..bc97d738 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java @@ -0,0 +1,145 @@ +package com.msg.fillmap.event.submission.entity; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 행사 등재 신청 (event_submissions, MSG-498). 신청 번호는 전역 시퀀스 + KST 연도 라벨이라 발급 뒤 불변이고, + * 재제출로도 바뀌지 않는다 (D-4). + *

+ * 위치는 {@code @OneToMany}(cascade ALL · orphanRemoval)로 애그리거트에 담는다 — 신청은 폼 하나로 통째로 + * 만들어졌다 폼 하나로 통째로 고쳐지는 단위이고 위치 수 상한이 20이라(D-10), 재제출의 전체 교체가 + * {@link #replaceLocations} 한 번으로 끝난다. + *

+ * 반려 사유는 여기 두지 않는다 (D-3) — 저장 원천은 이력 테이블 하나이고, 상세의 "반려 항목"은 이력 최신 + * 행에서 읽는다. 두 곳에 두면 MSG-500 이 쓰기마다 둘을 맞춰야 한다. + * {@code userId} 를 연관으로 잡지 않은 것은 이 티켓의 어떤 응답도 User 데이터를 싣지 않아 조인이 필요 + * 없어서다. + */ +@Entity +@Table(name = "event_submissions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventSubmission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "submission_no", length = 20, nullable = false, unique = true) + private String submissionNo; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 20, nullable = false) + private EventSubmissionType type; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private EventSubmissionStatus status; + + @Column(name = "title", length = 100, nullable = false) + private String title; + + @Column(name = "organizer_name", length = 100, nullable = false) + private String organizerName; + + @Column(name = "starts_on", nullable = false) + private LocalDate startsOn; + + @Column(name = "ends_on", nullable = false) + private LocalDate endsOn; + + @Column(name = "operating_hours", length = 100) + private String operatingHours; + + @Column(name = "program_description") + private String programDescription; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "image_key", length = 255, nullable = false) + private String imageKey; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + // 컬렉션 매핑의 근거(컨벤션 영속 계층 2항): 신청은 폼 하나로 통째로 만들어졌다 통째로 교체되는 + // 애그리거트이고 위치 수 상한이 20이라(D-10) 크기가 분명하다. 연관 주인은 FK 를 가진 자식 쪽 + // @ManyToOne 이고 여기는 mappedBy 읽기 쪽이다 — 그래야 자식 INSERT 한 번으로 FK 가 채워진다. + @OneToMany(mappedBy = "submission", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @OrderBy("displayOrder") + private List locations = new ArrayList<>(); + + private EventSubmission(String submissionNo, Long userId, EventSubmissionType type, LocalDateTime now) { + this.submissionNo = submissionNo; + this.userId = userId; + this.type = type; + this.status = EventSubmissionStatus.IN_REVIEW; + this.createdAt = now; + this.updatedAt = now; + } + + /** 제출 (FR-10). 접수된 신청은 언제나 심사 중에서 시작한다. 폼 내용은 {@link #updateForm} 가 채운다. */ + public static EventSubmission submit(String submissionNo, Long userId, EventSubmissionType type, + LocalDateTime now) { + return new EventSubmission(submissionNo, userId, type, now); + } + + /** + * 폼 내용 전체 교체 (제출·재제출 공용). 재제출이 부분 수정이 아니라 전체 교체라 두 경로가 같은 메서드다 + * (D-8 — 유형만 불변이라 인자에 없다). + */ + public void updateForm(String title, String organizerName, LocalDate startsOn, LocalDate endsOn, + String operatingHours, String programDescription, 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.description = description; + this.imageKey = imageKey; + this.updatedAt = now; + } + + /** + * 위치 목록 통째 교체 — 기존 행은 orphanRemoval 로 사라진다. 양방향 참조를 맞추는 편의 메서드이기도 하다: + * 자식의 부모 참조({@code submission})와 부모의 컬렉션을 여기 한 곳에서만 함께 세팅해, 한쪽만 갱신된 + * 상태가 만들어질 자리를 없앤다. 순번도 여기서 매긴다 — "배열 순서가 곧 순번"이 위치의 유일한 식별 + * 수단(이름이 없다)이라 그 불변식을 애그리거트 밖에 맡기지 않는다. + */ + public void replaceLocations(List newLocations) { + locations.clear(); + int displayOrder = 1; + for (EventSubmissionLocation location : newLocations) { + location.attachTo(this, displayOrder++); + locations.add(location); + } + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionAreaRect.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionAreaRect.java new file mode 100644 index 00000000..f3bcde4e --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionAreaRect.java @@ -0,0 +1,40 @@ +package com.msg.fillmap.event.submission.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신청 위치의 제출 원본 사각형 한 개 (MSG-498). 격자 인덱스 정수 넷이고 형식은 seed/events.json 과 같다. + * 저장하는 이유는 재제출 폼 프리필과 관리자 검토 재료라 "사용자가 그린 그대로"가 필요해서다 — + * 판정(81칸 상한·대표 격자)은 전개한 격자 집합으로 하지 이 표현으로 하지 않는다. + */ +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventSubmissionAreaRect { + + @Column(name = "min_grid_y", nullable = false) + private Integer minGridY; + + @Column(name = "max_grid_y", nullable = false) + private Integer maxGridY; + + @Column(name = "min_grid_x", nullable = false) + private Integer minGridX; + + @Column(name = "max_grid_x", nullable = false) + private Integer maxGridX; + + public EventSubmissionAreaRect(Integer minGridY, Integer maxGridY, Integer minGridX, Integer maxGridX) { + this.minGridY = minGridY; + this.maxGridY = maxGridY; + this.minGridX = minGridX; + this.maxGridX = maxGridX; + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionLocation.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionLocation.java new file mode 100644 index 00000000..421d38e0 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionLocation.java @@ -0,0 +1,76 @@ +package com.msg.fillmap.event.submission.entity; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import org.hibernate.annotations.BatchSize; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신청 위치 하나 (MSG-498 FR-9). 이름 필드가 없다 — 화면 식별은 순번(displayOrder, 1부터)과 + * 대표 격자에서 계산한 지역 라벨로 한다 (피그마 #102). + *

+ * 사각형은 {@link ElementCollection} 이다. 위치당 최대 81개로 상한이 분명하고 위치 없이 홀로 의미가 없어 + * 별도 엔티티일 이유가 없다. {@code @BatchSize} 는 상세 응답이 위치 수만큼 사각형 조회를 내는 N+1 을 막는다. + *

+ * 신청으로 가는 {@code @ManyToOne} 이 연관의 주인이다 (스펙 D-12) — FK 를 가진 쪽이 주인이라야 + * Hibernate 가 자식 INSERT 에 FK 를 싣고 끝낸다. 반대편 컬렉션은 {@code mappedBy} 로 읽기 쪽이 되고, + * 양쪽 참조는 {@link EventSubmission#replaceLocations} 가 캡슐화해 맞춘다. 이 필드는 + * equals·hashCode·toString 어디에도 넣지 않는다(Lombok 생성 대상에서 제외 — 프록시 초기화·순환 참조 방지). + */ +@Entity +@Table(name = "event_submission_locations") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventSubmissionLocation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "event_submission_id", nullable = false) + private EventSubmission submission; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(name = "representative_grid_id", length = 20, nullable = false) + private String representativeGridId; + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "event_submission_location_rects", + joinColumns = @JoinColumn(name = "event_submission_location_id")) + @BatchSize(size = 20) + private List rects = new ArrayList<>(); + + public EventSubmissionLocation(String representativeGridId, List rects) { + this.representativeGridId = representativeGridId; + this.rects = new ArrayList<>(rects); + } + + /** + * 애그리거트에 붙으며 부모와 순번을 받는다. 패키지 전용이고 호출자는 {@link EventSubmission#replaceLocations} + * 하나다 — 양쪽 참조를 맞추는 책임을 서비스에 흘리지 않으려고 편의 메서드로 가둔다. + */ + void attachTo(EventSubmission submission, int displayOrder) { + this.submission = submission; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionReasonCode.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionReasonCode.java new file mode 100644 index 00000000..16f9ab33 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionReasonCode.java @@ -0,0 +1,20 @@ +package com.msg.fillmap.event.submission.entity; + +/** + * 반려 항목 코드 (MSG-498 FR-19). 관리자가 무엇 때문에 반려했는지를 화면이 항목 카드로 그린다. + * 이 티켓은 저장 형태 정의와 읽기만 담당하고, 쓰기(1개 이상 강제 검증 포함)는 MSG-500 이다. + */ +public enum EventSubmissionReasonCode { + + /** 행사 기간. */ + PERIOD, + + /** 위치 영역. */ + AREA, + + /** 대표 이미지. */ + IMAGE, + + /** 기본 정보. */ + INFO +} diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionReasonCodesConverter.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionReasonCodesConverter.java new file mode 100644 index 00000000..cdf09750 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionReasonCodesConverter.java @@ -0,0 +1,38 @@ +package com.msg.fillmap.event.submission.entity; + +import java.util.Arrays; +import java.util.List; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * 반려 항목 코드 목록 ↔ 쉼표 연결 문자열 ("AREA,INFO") 매핑 (MSG-498). 값이 4종 고정이고 순서에 의미가 + * 없어 JSONB 같은 방언 의존 타입으로 얻을 것이 없다 — JPA 표준 매핑 하나로 끝난다. + * 비어 있거나 없는 사유는 NULL 이다 (반려 아닌 상태 행의 저장 계약, DDL CHECK 과 같은 규칙). + */ +@Converter +public class EventSubmissionReasonCodesConverter + implements AttributeConverter, String> { + + private static final String DELIMITER = ","; + + @Override + public String convertToDatabaseColumn(List codes) { + if (codes == null || codes.isEmpty()) { + return null; + } + return codes.stream().map(Enum::name).reduce((left, right) -> left + DELIMITER + right).orElseThrow(); + } + + @Override + public List convertToEntityAttribute(String column) { + if (column == null || column.isBlank()) { + return null; + } + return Arrays.stream(column.split(DELIMITER)) + .map(String::trim) + .map(EventSubmissionReasonCode::valueOf) + .toList(); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatus.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatus.java new file mode 100644 index 00000000..69e856e7 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatus.java @@ -0,0 +1,17 @@ +package com.msg.fillmap.event.submission.entity; + +/** + * 행사 등재 신청 상태 (MSG-498 FR-10). 이 티켓이 만드는 전이는 제출 시 IN_REVIEW 와 반려본 재제출 시 + * REJECTED → IN_REVIEW 둘뿐이고, APPROVED · REJECTED 로 보내는 쪽은 관리자 심사(MSG-500)다. + */ +public enum EventSubmissionStatus { + + /** 심사 중. 제출·재제출 직후의 상태다. */ + IN_REVIEW, + + /** 승인. 쓰는 쪽은 MSG-500. */ + APPROVED, + + /** 반려. 이 상태에서만 수정 재제출이 열린다 (FR-13). */ + REJECTED +} diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatusHistory.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatusHistory.java new file mode 100644 index 00000000..8686360e --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatusHistory.java @@ -0,0 +1,62 @@ +package com.msg.fillmap.event.submission.entity; + +import java.time.LocalDateTime; +import java.util.List; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신청 상태 이력 (MSG-498 FR-12). 전이마다 한 행이 쌓이는 append 로그라 갱신·삭제가 없고, 신청과 연관을 + * 걸지 않는다 — 쓰기는 신청 id 하나로 끝나고 읽기는 신청당 한 번의 파생 쿼리다. + *

+ * 반려 사유(코드·본문)의 저장 원천이 이 테이블 하나다 (D-3). 이 티켓은 반려 아닌 행만 쓰고 반려 행은 + * 읽기만 하며, 반려 행을 쓰는 쪽은 MSG-500 이다. "반려 행에만 사유가 있다"는 DDL CHECK 두 개가 강제한다. + */ +@Entity +@Table(name = "event_submission_status_history") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventSubmissionStatusHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_submission_id", nullable = false) + private Long eventSubmissionId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private EventSubmissionStatus status; + + @Convert(converter = EventSubmissionReasonCodesConverter.class) + @Column(name = "reason_codes", length = 30) + private List reasonCodes; + + @Column(name = "reason_text") + private String reasonText; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** 심사 중 전이 기록 (제출·재제출). 사유가 없는 상태 행이라 인자에도 사유가 없다. */ + public static EventSubmissionStatusHistory inReview(Long eventSubmissionId, LocalDateTime now) { + EventSubmissionStatusHistory history = new EventSubmissionStatusHistory(); + history.eventSubmissionId = eventSubmissionId; + history.status = EventSubmissionStatus.IN_REVIEW; + history.createdAt = now; + return history; + } +} 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 new file mode 100644 index 00000000..62e09ce7 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionType.java @@ -0,0 +1,15 @@ +package com.msg.fillmap.event.submission.entity; + +/** + * 행사 등재 신청의 등록 유형 (MSG-498, PRD 유형 표). 유형이 유형별 필수 항목을 결정한다 — + * FESTIVAL 은 주요 프로그램(programDescription), POPUP 은 운영 시간(operatingHours)이다. + * 세 번째 유형(이벤트 참여형)은 v2.1 재편으로 MSG-501·502 로 분리돼 여기 없다 — 그쪽이 값을 추가한다. + */ +public enum EventSubmissionType { + + /** 지역축제. */ + FESTIVAL, + + /** 팝업스토어. */ + POPUP +} diff --git a/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java new file mode 100644 index 00000000..da3ec3aa --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java @@ -0,0 +1,56 @@ +package com.msg.fillmap.event.submission.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.msg.fillmap.event.submission.entity.EventSubmission; + +public interface EventSubmissionRepository extends JpaRepository { + + /** + * 소유 조회 (FR-14). 조회를 항상 id + userId 쌍으로 하는 것이 존재 은닉의 구현이다 — 없는 신청과 남의 + * 신청이 같은 빈 결과가 되어 응답이 갈릴 코드 경로 자체가 없다. + */ + @EntityGraph(attributePaths = "locations") + Optional findByIdAndUserId(Long id, Long userId); + + /** + * 내 신청 목록 (FR-11). 페이지네이션 없음 — 내부 소수 사용자다. id 는 같은 시각 제출의 결정성 보험이다. + * 상태별 건수도 이 결과에서 센다 — 목록이 곧 전량이라 GROUP BY 를 따로 날리면 스냅숏만 갈라진다. + */ + List findByUserIdOrderByCreatedAtDescIdDesc(Long userId); + + /** + * 반려본 재제출의 상태 복귀 (FR-13) — 검사와 갱신이 한 문장이라 동시 PATCH 두 건이 둘 다 REJECTED 를 + * 관찰하고 둘 다 성공하는 경합이 성립하지 않는다. 술어에 userId 가 들어 있어 남의 행은 어떤 경로로도 + * 수정되지 않는다(뒤의 재로드나 롤백에 기대지 않는다). + *

+ * 영향 행이 0이면 호출자가 소유 조회로 분기한다 — 행이 없으면 13430, 있는데 REJECTED 가 아니면 13434 다. + * id + status 로만 걸면 남의 REJECTED 행이 13434 로 새어 존재가 드러나므로 분기 기준은 반드시 소유 조회다. + * {@code clearAutomatically} 는 벌크 UPDATE 가 우회한 영속성 컨텍스트의 스테일 스냅숏을 비운다 — + * 호출자의 재로드가 DB 의 새 상태를 읽어야 하기 때문이다. + */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE EventSubmission s + SET s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.IN_REVIEW, + s.updatedAt = :now + WHERE s.id = :id AND s.userId = :userId + AND s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.REJECTED + """) + int reopenRejected(@Param("id") Long id, @Param("userId") Long userId, @Param("now") LocalDateTime now); + + /** + * 신청 번호의 순번 (D-4). 연도별 리셋이 없는 전역 증가값이라 리셋 기계 없이 UNIQUE 가 보장된다. + * PostgreSQL 시퀀스 함수라 native 다 (JPA 표준에 동등 표현이 없다). + */ + @Query(value = "SELECT nextval('event_submission_no_seq')", nativeQuery = true) + long nextSubmissionSequence(); +} diff --git a/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionStatusHistoryRepository.java b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionStatusHistoryRepository.java new file mode 100644 index 00000000..75813ac3 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionStatusHistoryRepository.java @@ -0,0 +1,13 @@ +package com.msg.fillmap.event.submission.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.msg.fillmap.event.submission.entity.EventSubmissionStatusHistory; + +public interface EventSubmissionStatusHistoryRepository extends JpaRepository { + + /** 신청 하나의 전체 이력을 발생 순으로 (FR-12). id 오름차순이 곧 발생 순이다 — append 로그라 갱신이 없다. */ + List findByEventSubmissionIdOrderByIdAsc(Long eventSubmissionId); +} diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java new file mode 100644 index 00000000..762d3320 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java @@ -0,0 +1,222 @@ +package com.msg.fillmap.event.submission.service; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignResponseDto; +import com.msg.fillmap.global.config.AwsProperties; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; + +/** + * 신청 대표 이미지의 2단 업로드 (MSG-498 §대표 이미지 확정). 프로필 이미지(MSG-373) 흐름을 미러링하되 + * 두 가지가 다르다 — webp 를 받지 않고(시안 문구가 "JPG 또는 PNG"다), 확정본 프리픽스에 공개 읽기를 열지 + * 않는다(열람자가 신청 소유자와 관리자뿐이라 presigned GET 으로 충분하다). + *

+ * 확정 키의 uuid 는 복사 시점에 새로 발급한다 — 신청마다 독립 객체를 소유하므로 두 신청이 같은 객체를 + * 공유하는 상태 자체가 성립하지 않고, 재제출의 이전 이미지 삭제가 다른 신청의 이미지에 닿을 경로가 없다. + * 복사에 성공한 pending 은 커밋 후 지운다 — 같은 pending 키로 두 번 신청하는 경로를 막기 위해서다 + * (재확정 시도는 HEAD 실패라 13436 이 된다). 이 삭제는 정리 목적 베스트 에포트라 실패해도 신청은 유효하다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EventSubmissionImageStore { + + /** + * 허용 확장자 → 정규 Content-Type. 쌍으로 검증해 엇갈린 조합을 막는다 (프로필 이미지 선례). + * 이 맵 하나가 발급 검증과 확정 최종 관문 양쪽의 화이트리스트다. + */ + private static final Map ALLOWED_IMAGE_TYPES = Map.of( + "jpg", "image/jpeg", + "jpeg", "image/jpeg", + "png", "image/png"); + + private static final Duration PRESIGN_TTL = Duration.ofMinutes(10); + + /** 시안이 고정한 값이라 설정화하지 않는다 — 바꾸는 일 자체가 PRD 개정이다 (프로필 이미지 §D-6 과 같은 결). */ + private static final long MAX_IMAGE_BYTES = 10L * 1024 * 1024; + + private static final String PENDING_PREFIX = "event-submissions/pending/"; + private static final String ORIGINAL_PREFIX = "event-submissions/original/"; + + private final S3Presigner s3Presigner; + private final S3Client s3Client; + private final AwsProperties awsProperties; + private final ThumbnailUrlPresigner thumbnailUrlPresigner; + + public EventSubmissionImagePresignResponseDto presign(Long userId, + EventSubmissionImagePresignRequestDto request) { + String extension = request.extension().toLowerCase(); + String allowedType = ALLOWED_IMAGE_TYPES.get(extension); + if (allowedType == null || !allowedType.equals(request.contentType())) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_UNSUPPORTED); + } + if (request.contentLength() > MAX_IMAGE_BYTES) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_TOO_LARGE); + } + + String s3Key = "%s%d/%s.%s".formatted(PENDING_PREFIX, userId, UUID.randomUUID(), extension); + + // contentLength·contentType 을 서명에 포함시켜 클라이언트가 선언과 다른 크기·타입으로 올리면 S3 가 + // 403 을 낸다 (PUT presign 에는 POST policy 의 content-length-range 같은 범위 조건이 없다). + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(awsProperties.s3().bucket()) + .key(s3Key) + .contentType(request.contentType()) + .contentLength(request.contentLength()) + .build(); + + String uploadUrl = s3Presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(PRESIGN_TTL) + .putObjectRequest(objectRequest) + .build()).url().toString(); + + return new EventSubmissionImagePresignResponseDto(uploadUrl, s3Key, PRESIGN_TTL.toSeconds()); + } + + /** + * pending 키를 확정본으로 복사하고 저장할 키를 돌려준다. 검증은 S3 를 건드리지 않는 것부터 — + * 키 형식 → 실존(HeadObject) → 실측 크기 순이라 잘못된 요청이 S3 부수효과 없이 거부된다. + *

+ * 소유(prefix) 검사만으로는 부족하다 — 신청자는 자기 userId 를 아니까 지어낸 키를 보낼 수 있고 실존 + * 확인까지 있어야 막힌다. 실측 0바이트를 없는 것과 같이 취급하는 이유는 빈 객체가 대표 이미지로 저장되면 + * 심사 화면이 깨진 이미지를 그리게 되기 때문이다. + */ + public String confirm(Long userId, String pendingKey) { + String extension = validatePendingKey(userId, pendingKey); + HeadObjectResponse head = requireObjectExists(pendingKey); + Long contentLength = head.contentLength(); + if (contentLength == null || contentLength == 0L) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED); + } + if (contentLength > MAX_IMAGE_BYTES) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_TOO_LARGE); + } + + String originalKey = "%s%d/%s.%s".formatted(ORIGINAL_PREFIX, userId, UUID.randomUUID(), extension); + // 보상 등록이 복사 호출보다 앞이다 (Codex 구현 리뷰 1R). 목적지 키는 복사 전에 이미 정해져 있고, + // 복사가 S3 에서는 끝났는데 응답이 유실·타임아웃되면 등록 전에 예외로 빠져나가 고아가 영구히 남는다. + // 복사가 진짜 실패한 경우의 보상은 없는 키 삭제라 no-op 다(S3 DeleteObject 는 404 에도 성공한다). + deleteOnRollback(originalKey); + s3Client.copyObject(CopyObjectRequest.builder() + .sourceBucket(awsProperties.s3().bucket()) + .sourceKey(pendingKey) + .destinationBucket(awsProperties.s3().bucket()) + .destinationKey(originalKey) + .build()); + afterCommit(() -> deleteQuietly(pendingKey)); + return originalKey; + } + + /** 열람용 presigned GET (§API 4). 발급 방식·TTL 은 썸네일 선례 상수를 그대로 쓴다. */ + public String presignGet(String imageKey) { + return thumbnailUrlPresigner.presign(imageKey); + } + + /** 재제출로 밀려난 이전 확정 이미지 정리 — 커밋 후 베스트 에포트다 (프로필 이미지 선례). */ + public void deleteAfterCommit(String imageKey) { + if (imageKey != null) { + afterCommit(() -> deleteQuietly(imageKey)); + } + } + + private String validatePendingKey(Long userId, String pendingKey) { + if (!pendingKey.startsWith("%s%d/".formatted(PENDING_PREFIX, userId))) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_KEY_INVALID); + } + int extensionAt = pendingKey.lastIndexOf('.'); + if (extensionAt < 0) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_KEY_INVALID); + } + String extension = pendingKey.substring(extensionAt + 1).toLowerCase(); + if (!ALLOWED_IMAGE_TYPES.containsKey(extension)) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_KEY_INVALID); + } + return extension; + } + + private HeadObjectResponse requireObjectExists(String s3Key) { + try { + return s3Client.headObject(HeadObjectRequest.builder() + .bucket(awsProperties.s3().bucket()) + .key(s3Key) + .build()); + } catch (NoSuchKeyException e) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED, e); + } catch (S3Exception e) { + // HeadObject 는 본문 없는 404 를 주므로 SDK 가 NoSuchKeyException 으로 못 좁히는 경우가 있다. + if (e.statusCode() == 404) { + throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED, e); + } + throw e; + } + } + + /** + * 복사 이후 트랜잭션이 롤백되면 방금 만든 확정본이 아무도 참조하지 않는 고아로 남는다 — + * {@code VideoServiceImpl.deleteOnRollback}(MSG-247) 선례 그대로 보상 삭제한다. + * STATUS_UNKNOWN(커밋 결과 불명)은 남긴다 — 커밋됐을 수 있는 이미지를 지우면 유실이고 고아는 비용 문제뿐이다. + * 확정 키가 시도마다 새 uuid 라 목적지 공유가 없어, 이 보상이 다른 시도의 객체를 지울 수 없다. + */ + private void deleteOnRollback(String originalKey) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + deleteQuietly(originalKey); + } + } + }); + } + + /** 트랜잭션이 없으면(단위 테스트 등) 그냥 지금 실행한다 — VideoServiceImpl.afterCommit 패턴. */ + private void afterCommit(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } + + private void deleteQuietly(String s3Key) { + try { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(awsProperties.s3().bucket()) + .key(s3Key) + .build()); + } catch (SdkException e) { + log.error("신청 이미지 정리 실패 — 고아로 남는다: key={}", s3Key, e); + } + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionService.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionService.java new file mode 100644 index 00000000..829c1384 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionService.java @@ -0,0 +1,29 @@ +package com.msg.fillmap.event.submission.service; + +import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionDetailResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionMyListResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionSubmitResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionUpdateRequestDto; + +/** + * 행사 등재 신청 (MSG-498). 모든 메서드가 userId 를 첫 인자로 받는다 — 소유권 판정이 서비스 계층 몫이고 + * (경로 인가는 {@code /api/org/**} matcher 가 ORG 까지만 좁힌다), 조회를 항상 id + userId 쌍으로 해야 + * 없는 신청과 남의 신청이 같은 실패가 되기 때문이다 (FR-14). + */ +public interface EventSubmissionService { + + EventSubmissionImagePresignResponseDto issueImagePresignedUrl(Long userId, + EventSubmissionImagePresignRequestDto request); + + EventSubmissionSubmitResponseDto submit(Long userId, EventSubmissionCreateRequestDto request); + + EventSubmissionMyListResponseDto getMySubmissions(Long userId); + + EventSubmissionDetailResponseDto getSubmission(Long userId, Long submissionId); + + EventSubmissionSubmitResponseDto resubmit(Long userId, Long submissionId, + EventSubmissionUpdateRequestDto request); +} 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 new file mode 100644 index 00000000..dccd5bbd --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java @@ -0,0 +1,367 @@ +package com.msg.fillmap.event.submission.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionDetailResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionForm; +import com.msg.fillmap.event.submission.dto.EventSubmissionHistoryResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignResponseDto; +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.EventSubmissionRejectionResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionStatusCountsResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionSubmitResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionSummaryResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionUpdateRequestDto; +import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatusHistory; +import com.msg.fillmap.event.submission.entity.EventSubmissionType; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.event.submission.repository.EventSubmissionStatusHistoryRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.global.geo.AreaCell; +import com.msg.fillmap.global.geo.RepresentativeGridResolver; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; +import com.msg.fillmap.grid.service.GridQueryService; +import com.msg.fillmap.zone.service.ZoneCellName; +import com.msg.fillmap.zone.service.ZoneNameQueryService; +import com.msg.fillmap.zone.service.ZoneNameResolver; + +/** + * 행사 등재 신청 접수 (MSG-498). 검증 3규칙(영역·기간·유형별 항목)과 대표 격자 계산이 제출과 재제출에서 + * 똑같이 돌아간다 — 재제출은 부분 수정이 아니라 폼 전체 교체라 같은 입력을 같은 규칙으로 다시 받는 것과 + * 다르지 않기 때문이다. + *

+ * 81칸 상한은 사각형 합산이 아니라 전개한 격자 집합의 크기로 판정한다 (D-7) — 겹침을 두 번 세면 + * 정당한 81칸 영역이 그리는 방식에 따라 거부되는 비결정성이 생긴다. 대표 격자도 같은 집합을 입력으로 받아 + * 표현이 아니라 기하로 결정된다. + */ +@Service +public class EventSubmissionServiceImpl implements EventSubmissionService { + + /** 신청 번호의 연도 라벨은 사용자 대면 값이라 KST 다 (D-4, uploadDate 선례와 같은 성격). */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + /** 위치 수 상한 (D-10). 실사용을 막지 않으면서 무제한 엔티티 생성을 차단하는 값이다. */ + private static final int MAX_LOCATIONS = 20; + + /** 위치 하나의 영역 상한 (FR-24). 사각형 수 상한이기도 하다 — 사각형마다 최소 1칸이라 초과는 전부 중복 입력이다. */ + private static final int MAX_CELLS_PER_LOCATION = 81; + + private final EventSubmissionRepository submissionRepository; + private final EventSubmissionStatusHistoryRepository historyRepository; + private final EventSubmissionImageStore imageStore; + private final ZoneNameQueryService zoneNameQueryService; + private final GridQueryService gridQueryService; + private final Clock clock; + + /** 프로덕션 생성자 — clock 을 systemUTC 로 고정해 전체 생성자로 위임한다 (EventVideoServiceImpl 선례). */ + @Autowired + public EventSubmissionServiceImpl(EventSubmissionRepository submissionRepository, + EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, + ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService) { + this(submissionRepository, historyRepository, imageStore, zoneNameQueryService, gridQueryService, + Clock.systemUTC()); + } + + public EventSubmissionServiceImpl(EventSubmissionRepository submissionRepository, + EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, + ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService, Clock clock) { + this.submissionRepository = submissionRepository; + this.historyRepository = historyRepository; + this.imageStore = imageStore; + this.zoneNameQueryService = zoneNameQueryService; + this.gridQueryService = gridQueryService; + this.clock = clock; + } + + @Override + public EventSubmissionImagePresignResponseDto issueImagePresignedUrl(Long userId, + EventSubmissionImagePresignRequestDto request) { + return imageStore.presign(userId, request); + } + + @Override + @Transactional + public EventSubmissionSubmitResponseDto submit(Long userId, EventSubmissionCreateRequestDto request) { + validateForm(request.type(), request); + 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); + applyForm(submission, request, imageKey, locations, now); + + submissionRepository.save(submission); + historyRepository.save(EventSubmissionStatusHistory.inReview(submission.getId(), now)); + return EventSubmissionSubmitResponseDto.from(submission); + } + + @Override + @Transactional(readOnly = true) + public EventSubmissionMyListResponseDto getMySubmissions(Long userId) { + List submissions = submissionRepository.findByUserIdOrderByCreatedAtDescIdDesc(userId); + // 건수를 GROUP BY 로 따로 세지 않는다 (Codex 구현 리뷰 1R). 페이지네이션이 없어 목록이 곧 전량이라 + // 세는 재료가 이미 손에 있고, 쿼리를 둘로 나누면 READ COMMITTED 에서 서로 다른 스냅숏을 볼 뿐이다 — + // 그 사이 상태가 바뀌면 현황 카드의 합과 목록이 어긋나 보인다. 목록에서 세면 둘이 같은 한 장면이다. + Map counts = submissions.stream() + .collect(Collectors.groupingBy(EventSubmission::getStatus, Collectors.counting())); + return new EventSubmissionMyListResponseDto( + new EventSubmissionStatusCountsResponseDto( + counts.getOrDefault(EventSubmissionStatus.IN_REVIEW, 0L), + counts.getOrDefault(EventSubmissionStatus.APPROVED, 0L), + counts.getOrDefault(EventSubmissionStatus.REJECTED, 0L)), + submissions.stream().map(EventSubmissionSummaryResponseDto::from).toList()); + } + + @Override + @Transactional(readOnly = true) + public EventSubmissionDetailResponseDto getSubmission(Long userId, Long submissionId) { + EventSubmission submission = findOwned(userId, submissionId); + List history = + historyRepository.findByEventSubmissionIdOrderByIdAsc(submissionId); + return new EventSubmissionDetailResponseDto( + submission.getId(), + submission.getSubmissionNo(), + submission.getType().name(), + submission.getStatus().name(), + submission.getTitle(), + submission.getOrganizerName(), + submission.getStartsOn(), + submission.getEndsOn(), + submission.getOperatingHours(), + submission.getProgramDescription(), + submission.getDescription(), + imageStore.presignGet(submission.getImageKey()), + toLocationDtos(submission), + toRejection(submission, history), + history.stream().map(EventSubmissionHistoryResponseDto::from).toList(), + submission.getUpdatedAt()); + } + + /** + * 반려본 수정 재제출 (FR-13). 상태 복귀를 소유권 술어를 포함한 조건부 UPDATE 하나로 원자화하고 + * 실행 순서가 계약이다. + *

+ * ① 유형을 읽고 폼을 검증한다. 유형은 바꿀 수 없으므로(D-8) 저장값이 검증 기준이고, 읽기는 소유 조회라 + * 남의 id·없는 id 가 여기서 같은 13430 이 된다. 이 선행 조회는 읽기 전용이라 뒤의 벌크 UPDATE 가 + * 덮어쓸 변경을 만들지 않는다(수정 대상 엔티티에 손대는 것은 ④ 뒤다). ② 조건부 UPDATE 를 실행한다. + * 술어에 userId 가 있어 남의 행은 어떤 경로로도 수정되지 않는다. ③ 영향 행이 0이면 소유 조회로 + * 분기한다 — 행이 없으면 13430, 있는데 REJECTED 가 아니면 13434(동시 재제출의 패자 포함)다. id + status + * 로만 분기하면 남의 REJECTED 행이 13434 로 새어 존재가 드러난다. ④ 이겼으면 애그리거트를 재로드해 + * 교체한다 — 순서를 뒤집어 UPDATE 전에 로드한 엔티티를 고쳐 flush 하면, 벌크 UPDATE 가 영속성 컨텍스트를 + * 우회한 탓에 스테일한 REJECTED 가 도로 덮어써진다. + */ + @Override + @Transactional + public EventSubmissionSubmitResponseDto resubmit(Long userId, Long submissionId, + EventSubmissionUpdateRequestDto request) { + EventSubmissionType type = findOwned(userId, submissionId).getType(); + validateForm(type, request); + List locations = buildLocations(request.locations()); + + LocalDateTime now = LocalDateTime.now(clock); + if (submissionRepository.reopenRejected(submissionId, userId, now) == 0) { + // 진 이유를 소유 조회로 가른다 — 그 사이 사라졌으면 13430, 남아 있으면 상태 위반이다. + findOwned(userId, submissionId); + throw new ApiException(EventErrorCode.SUBMISSION_NOT_EDITABLE); + } + + EventSubmission submission = findOwned(userId, submissionId); + String previousImageKey = submission.getImageKey(); + // null·생략은 "기존 이미지 유지"다 — 상세가 저장 키를 노출하지 않으므로 확정 키를 직접 보내는 경로가 없다. + String imageKey = request.imageS3Key() == null + ? previousImageKey + : imageStore.confirm(userId, request.imageS3Key()); + applyForm(submission, request, imageKey, locations, now); + + historyRepository.save(EventSubmissionStatusHistory.inReview(submissionId, now)); + if (!imageKey.equals(previousImageKey)) { + imageStore.deleteAfterCommit(previousImageKey); + } + return EventSubmissionSubmitResponseDto.from(submission); + } + + /** 존재 은닉의 단일 진입점 (FR-14) — 없는 신청과 남의 신청이 여기서 같은 13430 이 된다. */ + private EventSubmission findOwned(Long userId, Long submissionId) { + return submissionRepository.findByIdAndUserId(submissionId, userId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + } + + 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); + submission.replaceLocations(locations); + } + + /** + * 유형별 필수 항목과 기간 (§도메인 로직). 자기 유형이 아닌 필드는 무시하지 않고 거부한다 — 폼에 없는 + * 값이 저장되면 관리자 화면이 출처 불명 데이터를 그린다. + */ + 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)) { + throw new ApiException(EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + validatePeriod(form.startsOn(), form.endsOn()); + } + + /** + * 기간 검증 (D-6). 이미 끝난 행사는 심사할 의미가 없고 관리자 큐만 오염시키므로 종료일이 오늘 이전이면 + * 거부한다. 진행 중 행사의 신청(시작일 과거)은 허용한다. 오늘은 사용자가 생각하는 날짜라 KST 다. + */ + private void validatePeriod(LocalDate startsOn, LocalDate endsOn) { + if (startsOn.isAfter(endsOn) || endsOn.isBefore(LocalDate.now(clock.withZone(KST)))) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_PERIOD); + } + } + + private List buildLocations(List requests) { + if (requests == null || requests.isEmpty() || requests.size() > MAX_LOCATIONS) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_AREA); + } + List locations = new ArrayList<>(); + for (EventSubmissionLocationRequestDto request : requests) { + Set cells = expand(request.areaRects()); + locations.add(new EventSubmissionLocation( + RepresentativeGridResolver.resolve(cells, null), + request.areaRects().stream().map(EventSubmissionAreaRectDto::toEntity).toList())); + } + return locations; + } + + /** + * 사각형들을 격자 집합으로 전개한다 (FR-8, FR-24). 사각형 하나의 칸 수를 long 산술로 먼저 구해 + * 상한을 넘으면 전개 없이 거부하는 것은 거대 사각형을 펼치다 메모리를 태우지 않기 위해서다. + * 인덱스 범위 검사가 극단값을 대표 격자 산술에 닿기 전에 걸러낸다 — 그쪽의 long 오버플로 안전 논증이 + * 이 상한 위에 서 있다. + */ + private Set expand(List rects) { + if (rects == null || rects.isEmpty() || rects.size() > MAX_CELLS_PER_LOCATION) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_AREA); + } + Set cells = new LinkedHashSet<>(); + for (EventSubmissionAreaRectDto rect : rects) { + validateRect(rect); + long rows = (long) rect.maxGridY() - rect.minGridY() + 1; + long columns = (long) rect.maxGridX() - rect.minGridX() + 1; + if (rows * columns > MAX_CELLS_PER_LOCATION) { + throw new ApiException(EventErrorCode.SUBMISSION_AREA_LIMIT_EXCEEDED); + } + for (int gridY = rect.minGridY(); gridY <= rect.maxGridY(); gridY++) { + for (int gridX = rect.minGridX(); gridX <= rect.maxGridX(); gridX++) { + cells.add(new AreaCell(gridY, gridX)); + } + } + // 합집합 판정 (D-7) — 겹치는 사각형은 Set 이 자연히 한 번만 센다. + if (cells.size() > MAX_CELLS_PER_LOCATION) { + throw new ApiException(EventErrorCode.SUBMISSION_AREA_LIMIT_EXCEEDED); + } + } + return cells; + } + + private void validateRect(EventSubmissionAreaRectDto rect) { + if (rect == null) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_AREA); + } + requireIndex(rect.minGridY()); + requireIndex(rect.maxGridY()); + requireIndex(rect.minGridX()); + requireIndex(rect.maxGridX()); + if (rect.minGridY() > rect.maxGridY() || rect.minGridX() > rect.maxGridX()) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_AREA); + } + } + + private void requireIndex(Integer index) { + if (index == null || index <= 0 || index >= RepresentativeGridResolver.GRID_INDEX_UPPER_EXCLUSIVE) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_AREA); + } + } + + /** {@code FM-{KST 연도}-{4자리 0패딩 순번}} (D-4). 순번은 연도별 리셋 없는 전역 시퀀스라 9999 를 넘으면 자릿수가 늘어난다. */ + private String nextSubmissionNo() { + return "FM-%d-%04d".formatted( + LocalDate.now(clock.withZone(KST)).getYear(), submissionRepository.nextSubmissionSequence()); + } + + /** + * 위치 응답 (§API 4). 표시명 재료는 대표 격자 기준으로 서버가 계산해 동봉하고 FE 는 조립만 한다 — + * 구역은 요청당 리졸버 1회로 순수 계산하고(MSG-341 계약), 행정동은 대표 격자를 한 번에 넘겨 받는다. + */ + private List toLocationDtos(EventSubmission submission) { + List locations = submission.getLocations(); + List gridIds = locations.stream() + .map(EventSubmissionLocation::getRepresentativeGridId) + .distinct() + .toList(); + ZoneNameResolver resolver = zoneNameQueryService.resolver(); + Map regionNames = gridQueryService.resolveRegionNames(gridIds); + + List dtos = new ArrayList<>(); + for (EventSubmissionLocation location : locations) { + String gridId = location.getRepresentativeGridId(); + GridIndex index = GridEncoder.decode(gridId); + ZoneCellName zone = resolver.name(index.gridY(), index.gridX()); + List rects = location.getRects().stream() + .map(EventSubmissionAreaRectDto::from) + .toList(); + dtos.add(new EventSubmissionLocationResponseDto( + location.getDisplayOrder(), + gridId, + zone.zoneName(), + zone.zoneCell(), + regionNames.get(gridId), + expand(rects).size(), + rects)); + } + return dtos; + } + + /** + * 현재 반려 사유는 이력 최신 행에서 읽는다 (D-3) — 신청 행에 중복 저장하지 않는다. + *

+ * 엔티티 상태와 이력이 다른 문장에서 읽히므로 READ COMMITTED 에서 둘이 한 찰나만큼 어긋날 수 있다 + * (반려 상태인데 반려 행이 아직 안 보이거나, 재제출 직후 상태는 심사 중인데 이력이 반려로 읽히는 경우). + * 수용한다 (Codex 구현 리뷰 1R) — 본인이 자기 신청을 동시에 재제출하는 찰나에만 성립하고, 결과는 카드 + * 하나가 잠깐 비거나 남는 표시 문제이며, 다음 조회에서 사라진다. 이 하나를 없애려고 격리 수준을 올리거나 + * 이력을 신청 행에 중복 저장(D-3 번복)하는 것은 대가가 훨씬 크다. + */ + private EventSubmissionRejectionResponseDto toRejection(EventSubmission submission, + List history) { + if (submission.getStatus() != EventSubmissionStatus.REJECTED || history.isEmpty()) { + return null; + } + return EventSubmissionRejectionResponseDto.from(history.get(history.size() - 1)); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/msg/fillmap/global/geo/RepresentativeGridResolver.java b/src/main/java/com/msg/fillmap/global/geo/RepresentativeGridResolver.java index b17113b5..876f172e 100644 --- a/src/main/java/com/msg/fillmap/global/geo/RepresentativeGridResolver.java +++ b/src/main/java/com/msg/fillmap/global/geo/RepresentativeGridResolver.java @@ -16,6 +16,13 @@ */ public final class RepresentativeGridResolver { + /** + * EPSG:5179 국내 정의역을 넉넉히 포함하는 격자 인덱스 방어 상한 (0 초과 이 값 미만). 이 산출기의 long + * 정수 산술이 오버플로하지 않는다는 논증이 이 상한 위에 서 있으므로, 셀 집합을 만드는 쪽(시딩·신청 접수)이 + * 전부 같은 값으로 걸러야 한다 — 그래서 값의 자리가 산출기 옆 한 곳이다 (MSG-498 에서 EventSeeder 승격). + */ + public static final int GRID_INDEX_UPPER_EXCLUSIVE = 100_000; + private RepresentativeGridResolver() { } diff --git a/src/main/resources/db/migration/V49__event_submissions.sql b/src/main/resources/db/migration/V49__event_submissions.sql new file mode 100644 index 00000000..1de3685b --- /dev/null +++ b/src/main/resources/db/migration/V49__event_submissions.sql @@ -0,0 +1,69 @@ +-- 행사 등재 v2 (MSG-498): 행사 운영자 등록 신청. 심사·승인 반영은 MSG-500. +-- 격자 컬럼은 grids 를 FK 하지 않는다 (lazy insert 전략상 행 존재 비보장, V39 선례). + +CREATE SEQUENCE event_submission_no_seq; + +CREATE TABLE event_submissions ( + id BIGSERIAL PRIMARY KEY, + submission_no VARCHAR(20) NOT NULL UNIQUE, -- "FM-2026-0007" + user_id BIGINT NOT NULL REFERENCES users(id), -- 신청자 (role=ORG) + type VARCHAR(20) NOT NULL, -- EventSubmissionType + status VARCHAR(20) NOT NULL, -- EventSubmissionStatus + title VARCHAR(100) NOT NULL, -- 축제명 / 팝업명 + organizer_name VARCHAR(100) NOT NULL, -- 주최 기관 / 브랜드·운영사 + starts_on DATE NOT NULL, -- KST 날짜 라벨 (기간) + ends_on DATE NOT NULL, + operating_hours VARCHAR(100), -- POPUP 전용 (event_locations 선례 타입) + program_description TEXT, -- FESTIVAL 전용, 최소 10자 (#100) + description TEXT NOT NULL, -- 소개, 최소 10자 (#100) + image_key VARCHAR(255) NOT NULL, -- 확정 프리픽스 S3 키 + created_at TIMESTAMP NOT NULL, -- UTC (MSG-376 체계, 앱이 Clock 으로 기록) + updated_at TIMESTAMP NOT NULL, + + -- 이벤트 참여형(MSG-502)은 값 추가 + 부모 이벤트 컬럼 추가로 확장한다. 선반영하지 않는다. + CONSTRAINT chk_event_sub_type CHECK (type IN ('FESTIVAL', 'POPUP')), + CONSTRAINT chk_event_sub_status CHECK (status IN ('IN_REVIEW', 'APPROVED', 'REJECTED')), + CONSTRAINT chk_event_sub_period CHECK (starts_on <= ends_on) +); +CREATE INDEX idx_event_submissions_user ON event_submissions(user_id); + +CREATE TABLE event_submission_locations ( + id BIGSERIAL PRIMARY KEY, + event_submission_id BIGINT NOT NULL REFERENCES event_submissions(id) ON DELETE CASCADE, + display_order INTEGER NOT NULL, -- 요청 배열 순서, 1부터. 위치 이름 없음 (#102) + representative_grid_id VARCHAR(20) NOT NULL -- 서버 계산 (FR-9) +); +CREATE INDEX idx_event_sub_locations_submission ON event_submission_locations(event_submission_id); + +-- 제출 원본 사각형. 승인 시 event_locations·event_location_grids 로 전개하는 쪽은 MSG-500. +CREATE TABLE event_submission_location_rects ( + event_submission_location_id BIGINT NOT NULL + REFERENCES event_submission_locations(id) ON DELETE CASCADE, + min_grid_y INTEGER NOT NULL, + max_grid_y INTEGER NOT NULL, + min_grid_x INTEGER NOT NULL, + max_grid_x INTEGER NOT NULL, + + CONSTRAINT chk_event_sub_rect_y CHECK (min_grid_y <= max_grid_y), + CONSTRAINT chk_event_sub_rect_x CHECK (min_grid_x <= max_grid_x) +); +CREATE INDEX idx_event_sub_rects_location + ON event_submission_location_rects(event_submission_location_id); + +CREATE TABLE event_submission_status_history ( + id BIGSERIAL PRIMARY KEY, + event_submission_id BIGINT NOT NULL REFERENCES event_submissions(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL, + reason_codes VARCHAR(30), -- 쉼표 연결 "AREA,INFO" (쓰기는 MSG-500) + reason_text TEXT, + created_at TIMESTAMP NOT NULL, + + CONSTRAINT chk_event_sub_hist_status CHECK (status IN ('IN_REVIEW', 'APPROVED', 'REJECTED')), + -- 반려 행에는 사유 두 벌이 반드시 있고, 그 외 상태 행에는 없다 (FR-19 저장 계약) + CONSTRAINT chk_event_sub_hist_reason_required + CHECK (status <> 'REJECTED' OR (reason_codes IS NOT NULL AND reason_text IS NOT NULL)), + CONSTRAINT chk_event_sub_hist_reason_absent + CHECK (status = 'REJECTED' OR (reason_codes IS NULL AND reason_text IS NULL)) +); +CREATE INDEX idx_event_sub_history_submission + ON event_submission_status_history(event_submission_id); diff --git a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java new file mode 100644 index 00000000..08ec7f7b --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java @@ -0,0 +1,91 @@ +package com.msg.fillmap.event.submission; + +import java.util.UUID; + +/** + * 행사 등재 신청 요청 본문 조립기 (MSG-498 테스트 공용). 본문을 문자열로 만드는 이유는 "필드를 아예 빼면 + * 어떻게 되나"(재제출의 이미지 키 생략)를 검증해야 하는데 DTO 로 만들면 그 상태를 표현할 수 없기 때문이다. + */ +public final class EventSubmissionFixtures { + + /** 부산 광안리 일대 — 3행 7열(21칸) 홀수 직사각형이라 대표 격자가 정중앙으로 결정된다. */ + public static final String GWANGALLI_RECT = rect(16859, 16861, 11509, 11515); + public static final String GWANGALLI_CENTER = "16860_11512"; + + private EventSubmissionFixtures() { + } + + public static String rect(int minGridY, int maxGridY, int minGridX, int maxGridX) { + return """ + {"minGridY": %d, "maxGridY": %d, "minGridX": %d, "maxGridX": %d}""" + .formatted(minGridY, maxGridY, minGridX, maxGridX); + } + + /** 사각형들을 위치 하나로 묶는다 — 위치에는 이름 필드가 없다 (피그마 #102). */ + public static String location(String... rects) { + return """ + {"areaRects": [%s]}""".formatted(String.join(", ", rects)); + } + + public static String pendingKey(long userId) { + return "event-submissions/pending/%d/%s.jpg".formatted(userId, UUID.randomUUID()); + } + + public static String festivalBody(long userId, String... locations) { + return festivalBody(pendingKey(userId), locations); + } + + public static String festivalBody(String imageS3Key, String... locations) { + return body("부산불꽃축제", "2026-11-07", "2026-11-07", + "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", imageS3Key, locations); + } + + /** 이미 끝난 행사 — 종료일이 KST 오늘 이전이라 거부 대상이다 (D-6). */ + public static String pastFestivalBody(long userId, String... locations) { + return body("2020 부산불꽃축제", "2020-11-06", "2020-11-07", + "광안리해수욕장 일원에서 열렸던 부산 대표 불꽃 축제", pendingKey(userId), locations); + } + + public static String festivalBodyWithTitle(long userId, String title, String... locations) { + return body(title, "2026-11-07", "2026-11-07", + "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", 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); + } + + /** 재제출 본문 — 제출에서 type 을 뺀 전체다. imageS3Key 가 null 이면 필드 자체가 빠져 기존 이미지 유지 계약을 탄다. */ + public static String updateBody(String title, String imageS3Key, String... locations) { + return """ + { + "title": "%s", + "organizerName": "부산문화관광축제조직위원회", + "startsOn": "2026-11-07", + "endsOn": "2026-11-07", + "programDescription": "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영", + "description": "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", + %s"locations": [%s] + }""".formatted(title, field("imageS3Key", imageS3Key), String.join(", ", locations)); + } + + private static String body(String title, String startsOn, String endsOn, String description, + String imageS3Key, String... locations) { + return """ + { + "type": "FESTIVAL", + "title": "%s", + "organizerName": "부산문화관광축제조직위원회", + "startsOn": "%s", + "endsOn": "%s", + "programDescription": "멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영", + "description": "%s", + "imageS3Key": "%s", + "locations": [%s] + }""".formatted(title, startsOn, endsOn, description, imageS3Key, String.join(", ", locations)); + } + + private static String field(String name, String value) { + return value == null ? "" : "\"%s\": \"%s\",\n\t\t\t".formatted(name, value); + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionAuthorizationTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionAuthorizationTest.java new file mode 100644 index 00000000..bc7e80fd --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionAuthorizationTest.java @@ -0,0 +1,135 @@ +package com.msg.fillmap.event.submission.controller; + +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.GWANGALLI_RECT; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.festivalBody; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.location; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 신청 API 의 인가 회귀 (MSG-498). 이 티켓은 인가 코드를 한 줄도 만들지 않는다 — SecurityConfig 의 + * {@code /api/org/**} matcher(MSG-496)와 비밀번호 게이트 인터셉터(MSG-497)가 프리픽스로 새 경로를 자동으로 + * 덮는다는 것이 스펙의 전제이고, 그 전제가 실경로에서 참인지를 여기서 확인한다. + *

+ * 게이트 검증에 실 DB 가 필요한 이유는 인터셉터가 매 요청 users 를 조회해 판정하기 때문이다(토큰 클레임이 + * 아니다 — 그래야 변경 즉시 풀린다). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("행사 등재 신청 인가 (MSG-498, 실 DB)") +class EventSubmissionAuthorizationTest { + + private static final String URL = "/api/org/event-submissions"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TokenProvider tokenProvider; + + private User organizer; + + @BeforeEach + void setUp() { + organizer = saveUser(UserRole.ORG); + } + + private User saveUser(UserRole role) { + User user = User.createLocalUser("m498-auth-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "담당자"); + ReflectionTestUtils.setField(user, "role", role); + return userRepository.saveAndFlush(user); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + + private String body() { + return festivalBody(organizer.getId(), location(GWANGALLI_RECT)); + } + + // 검증: FR-AUTH-14 + @Test + @DisplayName("비로그인 신청 제출은 401 이다") + void 비로그인으로_신청_API_접근은_401이다() throws Exception { + mockMvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(body())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.developCode").value(2403)); + + mockMvc.perform(get(URL + "/my")) + .andExpect(status().isUnauthorized()); + } + + // 검증: FR-AUTH-14 + @Test + @DisplayName("USER 토큰 신청 제출은 403 이다 — 콘솔은 행사 운영자 전용이다") + void USER_토큰으로_신청_API_접근은_403이다() throws Exception { + mockMvc.perform(post(URL) + .header(HttpHeaders.AUTHORIZATION, bearer(saveUser(UserRole.USER))) + .contentType(MediaType.APPLICATION_JSON) + .content(body())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.developCode").value(403)); + } + + // 검증: FR-AUTH-14 + @Test + @DisplayName("ADMIN 토큰 신청 제출도 403 이다 — 관리자에게도 열지 않는다") + void ADMIN_토큰으로_신청_API_접근은_403이다() throws Exception { + mockMvc.perform(post(URL) + .header(HttpHeaders.AUTHORIZATION, bearer(saveUser(UserRole.ADMIN))) + .contentType(MediaType.APPLICATION_JSON) + .content(body())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.developCode").value(403)); + } + + // 검증: FR-AUTH-15 + @Test + @DisplayName("초기 비밀번호 상태의 ORG 토큰은 신청 제출도 차단된다 — 프리픽스 게이트가 새 경로를 덮는다") + void mustChange_상태의_ORG_토큰으로_신청_제출은_차단된다() throws Exception { + ReflectionTestUtils.setField(organizer, "passwordMustChange", true); + userRepository.flush(); + + mockMvc.perform(post(URL) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(body())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.developCode").value(2441)); + + mockMvc.perform(get(URL + "/my").header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.developCode").value(2441)); + } +} 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 new file mode 100644 index 00000000..e04b8014 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java @@ -0,0 +1,515 @@ +package com.msg.fillmap.event.submission.controller; + +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.festivalBody; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.festivalBodyWithDescription; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.festivalBodyWithTitle; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.location; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.pastFestivalBody; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.pendingKey; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.rect; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.updateBody; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +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.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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.jayway.jsonpath.JsonPath; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; + +import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.UserRepository; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; + +/** + * 행사 등재 신청 제출·조회·재제출 (MSG-498, 실 DB). 신청 번호 시퀀스, 애그리거트 전체 교체, 조건부 UPDATE + * 전이, 존재 은닉이 전부 DB 동작이라 목으로는 검증이 성립하지 않는다. {@code @Transactional} 롤백 격리로 + * 공유 로컬 DB 에 계정·신청을 남기지 않는다 (OrgAccountControllerTest 선례). + *

+ * 반려 상태는 관리자 심사(MSG-500)가 만드는데 아직 없으므로 테스트가 그 행위를 SQL 로 대역한다 — + * 이 티켓은 반려 행을 읽기만 하고 쓰지 않는다는 스펙 계약을 테스트 코드가 그대로 반영한 것이다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("행사 등재 신청 (MSG-498, 실 DB)") +class EventSubmissionControllerTest { + + private static final String URL = "/api/org/event-submissions"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private EntityManager entityManager; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private ThumbnailUrlPresigner thumbnailUrlPresigner; + + private User organizer; + private User otherOrganizer; + + @BeforeEach + void setUp() { + organizer = saveOrganizer(); + otherOrganizer = saveOrganizer(); + given(s3Client.headObject(any(HeadObjectRequest.class))) + .willReturn(HeadObjectResponse.builder().contentLength(2048L).build()); + given(s3Client.copyObject(any(CopyObjectRequest.class))).willReturn(CopyObjectResponse.builder().build()); + given(thumbnailUrlPresigner.presign(anyString())).willReturn("https://signed.example/image.jpg"); + } + + private User saveOrganizer() { + User user = User.createLocalUser("organizer-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "담당자"); + ReflectionTestUtils.setField(user, "role", UserRole.ORG); + return userRepository.saveAndFlush(user); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + + /** 신청 하나를 제출하고 id 를 돌려준다 — 조회·재제출 테스트의 준비물이다. */ + private long 신청한다(User user, String body) throws Exception { + String response = mockMvc.perform(post(URL) + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return ((Number) JsonPath.read(response, "$.data.id")).longValue(); + } + + private long 축제를_신청한다(User user) throws Exception { + return 신청한다(user, festivalBody(user.getId(), location(GWANGALLI_RECT))); + } + + /** + * 관리자 심사(MSG-500) 대역 — 이 티켓은 반려 행을 쓰지 않으므로 SQL 로 만든다. + * JDBC 는 영속성 컨텍스트를 우회하므로 flush 로 앞선 쓰기를 DB 에 내린 뒤에 실행한다. + */ + private void 반려한다(long submissionId, String reasonCodes, String reasonText) { + entityManager.flush(); + jdbcTemplate.update("UPDATE event_submissions SET status = 'REJECTED' WHERE id = ?", submissionId); + jdbcTemplate.update(""" + INSERT INTO event_submission_status_history + (event_submission_id, status, reason_codes, reason_text, created_at) + VALUES (?, 'REJECTED', ?, ?, ?) + """, submissionId, reasonCodes, reasonText, LocalDateTime.now(ZoneOffset.UTC)); + entityManager.clear(); + } + + private void 승인한다(long submissionId) { + entityManager.flush(); + jdbcTemplate.update("UPDATE event_submissions SET status = 'APPROVED' WHERE id = ?", submissionId); + entityManager.clear(); + } + + private EventSubmission 저장된_신청(long submissionId, User owner) { + entityManager.flush(); + entityManager.clear(); + return submissionRepository.findByIdAndUserId(submissionId, owner.getId()).orElseThrow(); + } + + @Nested + @DisplayName("제출") + class Submit { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("신청하면 심사 중 상태와 FM 꼴 신청 번호가 부여되고 대표 격자가 저장된다") + void 신청하면_심사_중_상태와_FM꼴_신청_번호가_부여된다() throws Exception { + long id = 축제를_신청한다(organizer); + + EventSubmission saved = 저장된_신청(id, organizer); + assertThat(saved.getSubmissionNo()).matches("FM-\\d{4}-\\d{4,}"); + assertThat(saved.getStatus().name()).isEqualTo("IN_REVIEW"); + assertThat(saved.getImageKey()).startsWith("event-submissions/original/" + organizer.getId() + "/"); + assertThat(saved.getLocations()).singleElement() + .extracting(EventSubmissionLocation::getRepresentativeGridId) + .isEqualTo(GWANGALLI_CENTER); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("신청 번호는 제출마다 증가하고 겹치지 않는다") + void 신청_번호는_제출마다_증가하고_겹치지_않는다() throws Exception { + String first = 저장된_신청(축제를_신청한다(organizer), organizer).getSubmissionNo(); + String second = 저장된_신청(축제를_신청한다(organizer), organizer).getSubmissionNo(); + + assertThat(first).isNotEqualTo(second); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("신청하면 이력에 심사 중 행이 남는다") + void 신청하면_이력에_심사_중_행이_남는다() throws Exception { + long id = 축제를_신청한다(organizer); + + mockMvc.perform(get(URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.history.length()").value(1)) + .andExpect(jsonPath("$.data.history[0].status").value("IN_REVIEW")) + .andExpect(jsonPath("$.data.history[0].reasonCodes").isEmpty()) + .andExpect(jsonPath("$.data.history[0].changedAt").exists()); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("합집합 82칸 위치는 13432 로 거부한다") + void 위치_사각형_합산이_82칸이면_거부한다() throws Exception { + 신청_실패(organizer, festivalBody(organizer.getId(), + location(rect(100, 108, 200, 208), rect(300, 300, 400, 400))), 400, 13432); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("사각형이 뒤집힌 위치는 13431 로 거부한다") + void 사각형의_min이_max보다_크면_거부한다() throws Exception { + 신청_실패(organizer, festivalBody(organizer.getId(), location(rect(108, 100, 200, 208))), 400, 13431); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("이미 끝난 행사 신청은 13433 으로 거부한다") + void 종료일이_오늘_이전이면_거부한다() throws Exception { + 신청_실패(organizer, pastFestivalBody(organizer.getId(), location(GWANGALLI_RECT)), 400, 13433); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("남의 pending 키로 신청하면 13435 로 거부한다") + void 남의_pending_키로_신청하면_거부한다() throws Exception { + 신청_실패(organizer, festivalBody(pendingKey(otherOrganizer.getId()), location(GWANGALLI_RECT)), + 400, 13435); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("소개가 10자 미만이거나 2000자를 넘거나 제목이 100자를 넘으면 공통 400 이다") + void 서술_항목_길이_위반은_400이다() throws Exception { + for (String body : List.of( + festivalBodyWithDescription(organizer.getId(), "짧은소개", location(GWANGALLI_RECT)), + festivalBodyWithDescription(organizer.getId(), "가".repeat(2001), location(GWANGALLI_RECT)), + festivalBodyWithTitle(organizer.getId(), "가".repeat(101), location(GWANGALLI_RECT)))) { + 신청_실패(organizer, body, 400, 400); + } + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("위치나 사각형 자리에 null 이 들어오면 400 이다 — 도메인까지 내려가 500 이 되지 않는다") + void 목록_원소가_null이면_400이다() throws Exception { + 신청_실패(organizer, festivalBody(organizer.getId(), "null"), 400, 400); + 신청_실패(organizer, festivalBody(organizer.getId(), location("null")), 400, 400); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("정의되지 않은 등록 유형은 400 이다") + void 알_수_없는_유형은_400이다() throws Exception { + 신청_실패(organizer, + festivalBody(organizer.getId(), location(GWANGALLI_RECT)).replace("\"FESTIVAL\"", "\"CONCERT\""), + 400, 400); + } + + private void 신청_실패(User user, String body, int httpStatus, int developCode) throws Exception { + mockMvc.perform(post(URL) + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().is(httpStatus)) + .andExpect(jsonPath("$.developCode").value(developCode)); + } + } + + @Nested + @DisplayName("내 신청 목록") + class MyList { + + // 검증: FR-EVENT-14 + @Test + @DisplayName("상태별 건수가 실리고 목록은 최신 제출부터 온다") + void 내_신청_목록에_상태별_건수가_실린다() throws Exception { + long first = 축제를_신청한다(organizer); + long second = 축제를_신청한다(organizer); + 반려한다(second, "AREA", "영역이 너무 넓습니다"); + + mockMvc.perform(get(URL + "/my").header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.counts.inReview").value(1)) + .andExpect(jsonPath("$.data.counts.approved").value(0)) + .andExpect(jsonPath("$.data.counts.rejected").value(1)) + .andExpect(jsonPath("$.data.submissions.length()").value(2)) + .andExpect(jsonPath("$.data.submissions[0].id").value(second)) + .andExpect(jsonPath("$.data.submissions[0].status").value("REJECTED")) + .andExpect(jsonPath("$.data.submissions[1].id").value(first)) + .andExpect(jsonPath("$.data.submissions[0].updatedAt").exists()); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("남의 신청은 목록과 건수에 잡히지 않는다") + void 남의_신청은_목록과_건수에_잡히지_않는다() throws Exception { + 축제를_신청한다(otherOrganizer); + + mockMvc.perform(get(URL + "/my").header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.counts.inReview").value(0)) + .andExpect(jsonPath("$.data.submissions.length()").value(0)); + } + } + + @Nested + @DisplayName("신청 상세") + class Detail { + + // 검증: FR-EVENT-14 + @Test + @DisplayName("위치 순번과 표시명 재료와 칸 수와 제출 원본 사각형이 실린다") + void 상세에_위치_순번과_표시명_재료와_칸수가_실린다() throws Exception { + long id = 축제를_신청한다(organizer); + + mockMvc.perform(get(URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("FESTIVAL")) + .andExpect(jsonPath("$.data.operatingHours").isEmpty()) + .andExpect(jsonPath("$.data.imageUrl").value("https://signed.example/image.jpg")) + .andExpect(jsonPath("$.data.locations[0].order").value(1)) + .andExpect(jsonPath("$.data.locations[0].representativeGridId").value(GWANGALLI_CENTER)) + .andExpect(jsonPath("$.data.locations[0].cellCount").value(21)) + .andExpect(jsonPath("$.data.locations[0].areaRects[0].minGridY").value(16859)) + .andExpect(jsonPath("$.data.locations[0].areaRects[0].maxGridX").value(11515)) + .andExpect(jsonPath("$.data.rejection").isEmpty()); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("반려된 신청 상세에 반려 코드 배열과 사유가 실린다") + void 반려된_신청_상세에_반려_코드_배열과_사유가_실린다() throws Exception { + long id = 축제를_신청한다(organizer); + 반려한다(id, "AREA,INFO", "영역과 기본 정보를 다시 확인해 주세요"); + + mockMvc.perform(get(URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REJECTED")) + .andExpect(jsonPath("$.data.rejection.reasonCodes[0]").value("AREA")) + .andExpect(jsonPath("$.data.rejection.reasonCodes[1]").value("INFO")) + .andExpect(jsonPath("$.data.rejection.reasonText").value("영역과 기본 정보를 다시 확인해 주세요")) + .andExpect(jsonPath("$.data.history.length()").value(2)); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("없는 신청과 남의 신청의 실패 응답이 완전히 같다") + void 없는_신청과_남의_신청의_실패_응답이_같다() throws Exception { + long othersId = 축제를_신청한다(otherOrganizer); + + String missing = 상세_실패(999_999_999L); + String others = 상세_실패(othersId); + + assertThat(others).isEqualTo(missing); + assertThat(missing).contains("13430"); + } + + private String 상세_실패(long submissionId) throws Exception { + return mockMvc.perform(get(URL + "/" + submissionId) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + } + } + + @Nested + @DisplayName("수정 재제출") + class Resubmit { + + // 검증: FR-EVENT-14 + @Test + @DisplayName("반려된 신청을 수정하면 심사 중으로 돌아가고 신청 번호는 그대로다") + void 반려된_신청을_수정하면_심사_중으로_돌아간다() throws Exception { + long id = 축제를_신청한다(organizer); + String submissionNo = 저장된_신청(id, organizer).getSubmissionNo(); + 반려한다(id, "INFO", "기본 정보를 확인해 주세요"); + + 수정한다(organizer, id, updateBody("부산불꽃축제 2026", null, location(GWANGALLI_RECT))) + .andExpect(jsonPath("$.data.status").value("IN_REVIEW")) + .andExpect(jsonPath("$.data.submissionNo").value(submissionNo)); + + EventSubmission saved = 저장된_신청(id, organizer); + assertThat(saved.getTitle()).isEqualTo("부산불꽃축제 2026"); + assertThat(saved.getStatus().name()).isEqualTo("IN_REVIEW"); + mockMvc.perform(get(URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(jsonPath("$.data.history.length()").value(3)) + .andExpect(jsonPath("$.data.history[2].status").value("IN_REVIEW")) + // 재제출 뒤에도 과거 반려는 이력에 남아 사유를 계속 확인할 수 있다. + .andExpect(jsonPath("$.data.history[1].reasonText").value("기본 정보를 확인해 주세요")) + .andExpect(jsonPath("$.data.rejection").isEmpty()); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("수정하면 위치와 대표 격자가 전체 교체된다") + void 수정하면_위치와_대표_격자가_전체_교체된다() throws Exception { + long id = 축제를_신청한다(organizer); + 반려한다(id, "AREA", "위치를 다시 지정해 주세요"); + + 수정한다(organizer, id, updateBody("부산불꽃축제", null, + location(rect(100, 102, 200, 202)), location(rect(300, 300, 400, 400)))); + + assertThat(저장된_신청(id, organizer).getLocations()) + .extracting(EventSubmissionLocation::getDisplayOrder, + EventSubmissionLocation::getRepresentativeGridId) + .containsExactly(tuple(1, "101_201"), tuple(2, "300_400")); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("이미지 키를 생략하면 기존 이미지가 유지된다") + void 수정에서_이미지_키를_생략하면_기존_이미지가_유지된다() throws Exception { + long id = 축제를_신청한다(organizer); + String imageKey = 저장된_신청(id, organizer).getImageKey(); + 반려한다(id, "INFO", "기본 정보를 확인해 주세요"); + + 수정한다(organizer, id, updateBody("부산불꽃축제", null, location(GWANGALLI_RECT))); + + assertThat(저장된_신청(id, organizer).getImageKey()).isEqualTo(imageKey); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("이미지 키를 보내면 새 확정본으로 교체된다") + void 수정에서_이미지_키를_보내면_교체된다() throws Exception { + long id = 축제를_신청한다(organizer); + String imageKey = 저장된_신청(id, organizer).getImageKey(); + 반려한다(id, "IMAGE", "대표 이미지를 바꿔 주세요"); + + 수정한다(organizer, id, + updateBody("부산불꽃축제", pendingKey(organizer.getId()), location(GWANGALLI_RECT))); + + assertThat(저장된_신청(id, organizer).getImageKey()) + .isNotEqualTo(imageKey) + .startsWith("event-submissions/original/" + organizer.getId() + "/"); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("재제출도 위치 자리의 null 을 400 으로 막는다 — 상태 전이 앞에서 걸린다") + void 재제출_목록_원소가_null이면_400이다() throws Exception { + long id = 축제를_신청한다(organizer); + 반려한다(id, "AREA", "위치를 다시 지정해 주세요"); + + mockMvc.perform(patch(URL + "/" + id) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(updateBody("부산불꽃축제", null, "null"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(400)); + + // 검증 실패가 전이 앞이라 상태는 그대로 반려다. + assertThat(저장된_신청(id, organizer).getStatus().name()).isEqualTo("REJECTED"); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("심사 중이거나 승인된 신청의 수정은 13434 로 거부한다") + void 심사_중과_승인된_신청의_수정은_거부한다() throws Exception { + long inReview = 축제를_신청한다(organizer); + 수정_실패(organizer, inReview, 409, 13434); + + long approved = 축제를_신청한다(organizer); + 승인한다(approved); + 수정_실패(organizer, approved, 409, 13434); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("남의 신청 수정 요청도 같은 13430 이다 — 남의 반려 신청이 13434 로 새지 않는다") + void 남의_신청_수정_요청도_같은_실패_응답이다() throws Exception { + long othersRejected = 축제를_신청한다(otherOrganizer); + 반려한다(othersRejected, "INFO", "기본 정보를 확인해 주세요"); + + 수정_실패(organizer, othersRejected, 404, 13430); + 수정_실패(organizer, 999_999_999L, 404, 13430); + // 남의 신청은 요청으로 바뀌지 않는다 — UPDATE 술어의 userId 가 막는다. + assertThat(저장된_신청(othersRejected, otherOrganizer).getStatus().name()).isEqualTo("REJECTED"); + } + + private ResultActions 수정한다(User user, long id, String body) throws Exception { + return mockMvc.perform(patch(URL + "/" + id) + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + } + + private void 수정_실패(User user, long id, int httpStatus, int developCode) throws Exception { + mockMvc.perform(patch(URL + "/" + id) + .header(HttpHeaders.AUTHORIZATION, bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content(updateBody("부산불꽃축제", null, location(GWANGALLI_RECT)))) + .andExpect(status().is(httpStatus)) + .andExpect(jsonPath("$.developCode").value(developCode)); + } + } +} 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 new file mode 100644 index 00000000..7daec805 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionCommitBoundaryTest.java @@ -0,0 +1,222 @@ +package com.msg.fillmap.event.submission.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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +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.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectResponse; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionLocationRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionUpdateRequestDto; +import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionAreaRect; +import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; +import com.msg.fillmap.event.submission.entity.EventSubmissionType; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 커밋 경계에 걸린 두 동작 (MSG-498). 하나는 재제출의 조건부 UPDATE 원자 전이고, 다른 하나는 이미지 복사 + * 이후 롤백의 보상 삭제다. 둘 다 실제 커밋·롤백이 일어나야 관찰되므로 {@code @Transactional} 롤백 격리를 + * 쓸 수 없다 — 합성 행은 커밋해 두고 {@code @AfterEach} 에서 대상 지정 삭제한다 (VideoDeleteConcurrencyTest 선례). + */ +@SpringBootTest +@DisplayName("행사 등재 신청 커밋 경계 (MSG-498, 실 DB)") +class EventSubmissionCommitBoundaryTest { + + private static final long JOIN_TIMEOUT_SEC = 30L; + + @Autowired + private EventSubmissionService eventSubmissionService; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager txManager; + + @MockitoBean + private S3Client s3Client; + + private TransactionTemplate tx; + private long userId; + + @BeforeEach + void setUp() { + given(s3Client.headObject(any(HeadObjectRequest.class))) + .willReturn(HeadObjectResponse.builder().contentLength(2048L).build()); + given(s3Client.copyObject(any(CopyObjectRequest.class))).willReturn(CopyObjectResponse.builder().build()); + given(s3Client.deleteObject(any(DeleteObjectRequest.class))).willReturn(DeleteObjectResponse.builder().build()); + tx = new TransactionTemplate(txManager); + tx.executeWithoutResult(status -> { + User user = User.createLocalUser("m498-boundary-" + UUID.randomUUID() + "@fillmap.dev", "hash", "담당자"); + ReflectionTestUtils.setField(user, "role", UserRole.ORG); + userId = userRepository.save(user).getId(); + }); + } + + @AfterEach + void tearDown() { + // 커밋해 둔 합성 행만 지운다(실데이터 불가침). 위치·사각형·이력은 FK ON DELETE CASCADE 가 따라 지운다. + tx.executeWithoutResult(status -> { + jdbcTemplate.update("DELETE FROM event_submissions WHERE user_id = ?", userId); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", userId); + }); + } + + private EventSubmissionLocationRequestDto location() { + return new EventSubmissionLocationRequestDto( + List.of(new EventSubmissionAreaRectDto(16859, 16861, 11509, 11515))); + } + + private EventSubmissionCreateRequestDto createRequest() { + return new EventSubmissionCreateRequestDto(EventSubmissionType.FESTIVAL, "부산불꽃축제", + "부산문화관광축제조직위원회", LocalDate.now(ZoneOffset.UTC).plusDays(30), + LocalDate.now(ZoneOffset.UTC).plusDays(31), null, "멀티불꽃쇼, 드론 라이트쇼 운영", + "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", + "event-submissions/pending/%d/%s.jpg".formatted(userId, UUID.randomUUID()), List.of(location())); + } + + /** 이미지 키를 생략한 재제출 — 기존 이미지 유지라 S3 를 건드리지 않는다. */ + private EventSubmissionUpdateRequestDto updateRequest() { + return new EventSubmissionUpdateRequestDto("부산불꽃축제 2026", "부산문화관광축제조직위원회", + LocalDate.now(ZoneOffset.UTC).plusDays(30), LocalDate.now(ZoneOffset.UTC).plusDays(31), null, + "멀티불꽃쇼, 드론 라이트쇼 운영", "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", + null, List.of(location())); + } + + /** 반려 상태의 신청 하나를 커밋해 둔다 — 관리자 심사(MSG-500) 대역이라 상태는 SQL 로 만든다. */ + private long 반려된_신청을_커밋한다() { + return tx.execute(status -> { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + EventSubmission submission = EventSubmission.submit( + "FM-2026-%04d".formatted(submissionRepository.nextSubmissionSequence()), + userId, EventSubmissionType.FESTIVAL, now); + submission.updateForm("부산불꽃축제", "부산문화관광축제조직위원회", LocalDate.of(2026, 11, 7), + LocalDate.of(2026, 11, 7), 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))))); + long id = submissionRepository.save(submission).getId(); + submissionRepository.flush(); + jdbcTemplate.update("UPDATE event_submissions SET status = 'REJECTED' WHERE id = ?", id); + return id; + }); + } + + // 검증: FR-EVENT-14 + @Test + @DisplayName("동시 재제출은 한 건만 성공하고 진 쪽은 13434 다 — 조건부 UPDATE 가 전이를 원자화한다") + void 동시_재제출은_한_건만_성공한다() throws Exception { + long submissionId = 반려된_신청을_커밋한다(); + ExecutorService executor = Executors.newFixedThreadPool(2); + Callable attempt = () -> { + try { + eventSubmissionService.resubmit(userId, submissionId, updateRequest()); + return null; + } catch (ApiException e) { + return e; + } + }; + + Future first = executor.submit(attempt); + Future second = executor.submit(attempt); + executor.shutdown(); + assertThat(executor.awaitTermination(JOIN_TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue(); + + List failures = Stream.of(first.get(), second.get()).filter(Objects::nonNull).toList(); + assertThat(failures).singleElement() + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_NOT_EDITABLE); + // 이력은 심사 중 한 줄만 늘어난다 — 둘 다 성공했다면 두 줄이 남는다. + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM event_submission_status_history WHERE event_submission_id = ?", + Long.class, submissionId)).isEqualTo(1L); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("복사 성공 후 커밋이 실패하면 확정본이 정리된다 — 아무도 참조하지 않는 고아를 남기지 않는다") + void 복사_성공_후_커밋이_실패하면_원본이_정리된다() { + tx.executeWithoutResult(status -> { + eventSubmissionService.submit(userId, createRequest()); + status.setRollbackOnly(); + }); + + ArgumentCaptor copied = ArgumentCaptor.forClass(CopyObjectRequest.class); + then(s3Client).should().copyObject(copied.capture()); + ArgumentCaptor deleted = ArgumentCaptor.forClass(DeleteObjectRequest.class); + // 롤백이라 pending 정리(커밋 후 실행)는 돌지 않는다 — 지워지는 것은 방금 복사한 확정본 하나뿐이다. + then(s3Client).should(times(1)).deleteObject(deleted.capture()); + assertThat(deleted.getValue().key()).isEqualTo(copied.getValue().destinationKey()); + assertThat(submissionRepository.findByUserIdOrderByCreatedAtDescIdDesc(userId)).isEmpty(); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("복사 응답이 유실돼도 확정본이 정리된다 — 보상 등록이 복사 호출보다 앞이라서다") + void 복사_응답이_유실돼도_확정본이_정리된다() { + // S3 쪽에서는 복사가 끝났는데 응답만 유실·타임아웃된 상황(클라이언트에는 예외로 보인다). + // 보상 등록이 복사 뒤에 있었다면 등록 전에 빠져나가 아무도 참조하지 않는 original 이 영구히 남는다. + given(s3Client.copyObject(any(CopyObjectRequest.class))) + .willThrow(SdkClientException.create("복사 응답 유실")); + + assertThatThrownBy(() -> tx.executeWithoutResult(status -> + eventSubmissionService.submit(userId, createRequest()))) + .isInstanceOf(SdkException.class); + + ArgumentCaptor copied = ArgumentCaptor.forClass(CopyObjectRequest.class); + then(s3Client).should().copyObject(copied.capture()); + ArgumentCaptor deleted = ArgumentCaptor.forClass(DeleteObjectRequest.class); + then(s3Client).should(times(1)).deleteObject(deleted.capture()); + assertThat(deleted.getValue().key()).isEqualTo(copied.getValue().destinationKey()); + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStoreTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStoreTest.java new file mode 100644 index 00000000..b271368e --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStoreTest.java @@ -0,0 +1,193 @@ +package com.msg.fillmap.event.submission.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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +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.mockito.ArgumentCaptor; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectResponse; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionImagePresignResponseDto; +import com.msg.fillmap.global.config.AwsProperties; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.video.support.ThumbnailUrlPresigner; + +/** + * 신청 대표 이미지의 발급·확정 규칙 (MSG-498 §대표 이미지 확정). presign 은 네트워크 없는 로컬 서명이라 + * 더미 자격증명의 실제 presigner 를 쓰고(VideoPresignTest 선례), 실존·복사만 목 S3Client 다. + */ +@DisplayName("행사 신청 대표 이미지 (MSG-498)") +class EventSubmissionImageStoreTest { + + private static final long USER_ID = 42L; + private static final long TEN_MB = 10L * 1024 * 1024; + private static final String PENDING_KEY = "event-submissions/pending/42/8b1c.jpg"; + + private S3Client s3Client; + private EventSubmissionImageStore imageStore; + + @BeforeEach + void setUp() { + s3Client = mock(S3Client.class); + S3Presigner presigner = S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("ak", "sk"))) + .build(); + AwsProperties properties = new AwsProperties("ap-northeast-2", + new AwsProperties.S3("fillmap-video-dev", 104857600L, 2147483648L)); + imageStore = new EventSubmissionImageStore(presigner, s3Client, properties, + mock(ThumbnailUrlPresigner.class)); + } + + private void 업로드된_객체가_있다(long contentLength) { + given(s3Client.headObject(any(HeadObjectRequest.class))) + .willReturn(HeadObjectResponse.builder().contentLength(contentLength).build()); + given(s3Client.copyObject(any(CopyObjectRequest.class))) + .willReturn(CopyObjectResponse.builder().build()); + } + + @Nested + @DisplayName("presigned URL 발급") + class Presign { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("jpg 요청이면 내 pending 경로의 키가 발급된다") + void jpg_요청이면_내_pending_경로의_키가_발급된다() { + EventSubmissionImagePresignResponseDto response = imageStore.presign(USER_ID, + new EventSubmissionImagePresignRequestDto("jpg", "image/jpeg", 1048576L)); + + assertThat(response.s3Key()).matches("event-submissions/pending/42/[0-9a-f-]{36}\\.jpg"); + assertThat(response.uploadUrl()).contains("fillmap-video-dev"); + assertThat(response.expiresInSec()).isEqualTo(600L); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("webp 확장자 요청은 거부한다 — 시안 문구가 JPG 또는 PNG 라 프로필 이미지와 다르다") + void webp_확장자_presign_요청은_거부한다() { + assertThatThrownBy(() -> imageStore.presign(USER_ID, + new EventSubmissionImagePresignRequestDto("webp", "image/webp", 1048576L))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_UNSUPPORTED); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("확장자와 contentType 이 어긋나면 거부한다") + void 확장자와_contentType이_어긋나면_거부한다() { + assertThatThrownBy(() -> imageStore.presign(USER_ID, + new EventSubmissionImagePresignRequestDto("png", "image/jpeg", 1048576L))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_UNSUPPORTED); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("10MB 초과 선언은 거부한다") + void 초과_선언은_거부한다() { + assertThatThrownBy(() -> imageStore.presign(USER_ID, + new EventSubmissionImagePresignRequestDto("jpg", "image/jpeg", TEN_MB + 1))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_TOO_LARGE); + } + } + + @Nested + @DisplayName("확정") + class Confirm { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("확정하면 original 로 복사되고 키의 uuid 는 새로 발급된다 — 신청마다 객체를 독점 소유한다") + void 확정하면_original_로_복사되고_uuid가_새로_발급된다() { + 업로드된_객체가_있다(1024L); + + String originalKey = imageStore.confirm(USER_ID, PENDING_KEY); + + assertThat(originalKey).matches("event-submissions/original/42/[0-9a-f-]{36}\\.jpg"); + ArgumentCaptor captor = ArgumentCaptor.forClass(CopyObjectRequest.class); + then(s3Client).should().copyObject(captor.capture()); + assertThat(captor.getValue().sourceKey()).isEqualTo(PENDING_KEY); + assertThat(captor.getValue().destinationKey()).isEqualTo(originalKey); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("복사에 성공한 pending 은 지운다 — 같은 키의 재확정이 HEAD 실패로 막힌다") + void 같은_pending_키의_재확정은_실패한다() { + 업로드된_객체가_있다(1024L); + // 트랜잭션 밖이라 커밋 후 정리가 즉시 실행된다 (afterCommit 폴백). + imageStore.confirm(USER_ID, PENDING_KEY); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + then(s3Client).should().deleteObject(captor.capture()); + assertThat(captor.getValue().key()).isEqualTo(PENDING_KEY); + + // 지워진 뒤의 재확정 시도는 실존 검사에서 걸린다. + given(s3Client.headObject(any(HeadObjectRequest.class))) + .willThrow(NoSuchKeyException.builder().build()); + assertThatThrownBy(() -> imageStore.confirm(USER_ID, PENDING_KEY)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("남의 pending 키와 허용 밖 확장자 키는 거부한다 — S3 를 건드리기 전에 걸린다") + void 남의_pending_키로_신청하면_거부한다() { + for (String invalid : new String[] { + "event-submissions/pending/99/8b1c.jpg", + "event-submissions/original/42/8b1c.jpg", + "event-submissions/pending/42/8b1c.svg", + "event-submissions/pending/42/8b1c"}) { + assertThatThrownBy(() -> imageStore.confirm(USER_ID, invalid)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_KEY_INVALID); + } + then(s3Client).shouldHaveNoInteractions(); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("0바이트 객체는 업로드되지 않은 것으로 본다 — 빈 이미지가 대표 이미지로 저장되지 않는다") + void 바이트_이미지_객체는_거부한다() { + 업로드된_객체가_있다(0L); + + assertThatThrownBy(() -> imageStore.confirm(USER_ID, PENDING_KEY)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("실측 크기가 10MB 를 넘으면 거부한다 — presign 을 우회해 올린 파일을 확정에서 잡는다") + void 실측_크기가_10MB를_넘으면_거부한다() { + 업로드된_객체가_있다(TEN_MB + 1); + + assertThatThrownBy(() -> imageStore.confirm(USER_ID, PENDING_KEY)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_TOO_LARGE); + } + } +} 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 new file mode 100644 index 00000000..3a31fd97 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java @@ -0,0 +1,373 @@ +package com.msg.fillmap.event.submission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +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.mock; +import static org.mockito.Mockito.never; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +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.mockito.ArgumentCaptor; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionCreateRequestDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionLocationRequestDto; +import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; +import com.msg.fillmap.event.submission.entity.EventSubmissionType; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.event.submission.repository.EventSubmissionStatusHistoryRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.service.GridQueryService; +import com.msg.fillmap.zone.service.ZoneNameQueryService; + +/** + * 신청 검증 3규칙과 대표 격자 계산 (MSG-498 §도메인 로직). DB·S3 가 필요 없는 순수 판정이라 목으로 짠다 — + * 기간 판정은 "오늘"이 입력이라 고정 Clock 이어야 검증이 성립하고, 그 Clock 을 실 컨텍스트에 꽂을 수 없다. + * 저장·조회·인가가 걸린 나머지는 통합 테스트(EventSubmissionSubmitTest 등)가 본다. + */ +@DisplayName("행사 등재 신청 검증 (MSG-498)") +class EventSubmissionValidationTest { + + private static final long USER_ID = 42L; + + /** UTC 2026-11-06T16:30Z = KST 2026-11-07T01:30 — 두 시간대의 날짜가 갈리는 순간이다. */ + private static final Clock KST_NEW_DAY = Clock.fixed(Instant.parse("2026-11-06T16:30:00Z"), ZoneOffset.UTC); + + private EventSubmissionRepository submissionRepository; + private EventSubmissionImageStore imageStore; + private EventSubmissionServiceImpl service; + + @BeforeEach + void setUp() { + submissionRepository = mock(EventSubmissionRepository.class); + imageStore = mock(EventSubmissionImageStore.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); + } + + private EventSubmissionCreateRequestDto festival() { + return request(EventSubmissionType.FESTIVAL, "멀티불꽃쇼, 드론 라이트쇼", null, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), List.of(location(rect(16859, 16861, 11509, 11515)))); + } + + 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); + } + + private EventSubmissionCreateRequestDto withLocations(List locations) { + return request(EventSubmissionType.FESTIVAL, "멀티불꽃쇼, 드론 라이트쇼", null, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), locations); + } + + private EventSubmissionAreaRectDto rect(int minGridY, int maxGridY, int minGridX, int maxGridX) { + return new EventSubmissionAreaRectDto(minGridY, maxGridY, minGridX, maxGridX); + } + + private EventSubmissionLocationRequestDto location(EventSubmissionAreaRectDto... rects) { + return new EventSubmissionLocationRequestDto(List.of(rects)); + } + + private EventSubmission submitted(EventSubmissionCreateRequestDto request) { + service.submit(USER_ID, request); + ArgumentCaptor captor = ArgumentCaptor.forClass(EventSubmission.class); + then(submissionRepository).should().save(captor.capture()); + return captor.getValue(); + } + + @Nested + @DisplayName("영역 검증과 81칸 상한") + class Area { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("위치 사각형 합산이 81칸이면 통과하고 82칸이면 거부한다") + void 위치_사각형_합산이_81칸이면_통과하고_82칸이면_거부한다() { + assertThatCode(() -> service.submit(USER_ID, + withLocations(List.of(location(rect(100, 108, 200, 208)))))) + .doesNotThrowAnyException(); + + assertThatThrownBy(() -> service.submit(USER_ID, withLocations(List.of( + location(rect(100, 108, 200, 208), rect(200, 200, 300, 300)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_AREA_LIMIT_EXCEEDED); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("겹치는 사각형은 한 번만 센다 — 합산이면 162칸이라 거부될 입력이다") + void 겹치는_사각형은_한_번만_센다() { + assertThatCode(() -> service.submit(USER_ID, withLocations(List.of( + location(rect(100, 108, 200, 208), rect(100, 108, 200, 208)))))) + .doesNotThrowAnyException(); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("사각형 하나가 81칸을 넘으면 전개 없이 거부한다") + void 사각형_하나가_81칸을_넘으면_전개_없이_거부한다() { + // 전개하면 4조 칸이라 선검사가 없으면 메모리를 태운다. + assertThatThrownBy(() -> service.submit(USER_ID, withLocations(List.of( + location(rect(1, 99_999, 1, 99_999)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_AREA_LIMIT_EXCEEDED); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("사각형의 min 이 max 보다 크면 거부한다") + void 사각형의_min이_max보다_크면_거부한다() { + assertThatThrownBy(() -> service.submit(USER_ID, withLocations(List.of( + location(rect(108, 100, 200, 208)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_AREA); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("위치가 없거나 위치에 사각형이 없으면 거부한다") + void 위치가_없거나_사각형이_없으면_거부한다() { + for (List locations : List.of( + List.of(), + List.of(new EventSubmissionLocationRequestDto(List.of())))) { + assertThatThrownBy(() -> service.submit(USER_ID, withLocations(locations))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_AREA); + } + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("격자 인덱스가 허용 범위 밖이면 거부한다 — 0 이하와 100000 이상") + void 격자_인덱스가_허용_범위_밖이면_거부한다() { + for (EventSubmissionAreaRectDto invalid : List.of( + rect(0, 1, 200, 208), rect(100, 108, 0, 208), + rect(100, 100_000, 200, 208), rect(100, 108, 200, 100_000))) { + assertThatThrownBy(() -> service.submit(USER_ID, withLocations(List.of(location(invalid))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_AREA); + } + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("위치가 20개면 통과하고 21개면 거부한다") + void 위치가_20개면_통과하고_21개면_거부한다() { + assertThatCode(() -> service.submit(USER_ID, withLocations(manyLocations(20)))) + .doesNotThrowAnyException(); + + assertThatThrownBy(() -> service.submit(USER_ID, withLocations(manyLocations(21)))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_AREA); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("위치당 사각형이 81개를 넘으면 거부한다 — 정상 드로잉으로는 나올 수 없는 형태다") + void 위치당_사각형이_81개를_넘으면_거부한다() { + List rects = IntStream.range(0, 82) + .mapToObj(index -> rect(100 + index, 100 + index, 200, 200)) + .toList(); + assertThatThrownBy(() -> service.submit(USER_ID, + withLocations(List.of(new EventSubmissionLocationRequestDto(rects))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_AREA); + } + + private List manyLocations(int count) { + List locations = new ArrayList<>(); + for (int index = 0; index < count; index++) { + locations.add(location(rect(100 + index, 100 + index, 200, 200))); + } + return locations; + } + } + + @Nested + @DisplayName("대표 격자 서버 계산") + class Representative { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("홀수 직사각형 위치는 정중앙이 대표 격자가 된다") + void 홀수_직사각형_위치는_정중앙이_대표_격자가_된다() { + EventSubmission submission = submitted(withLocations(List.of(location(rect(16859, 16861, 11509, 11515))))); + + assertThat(submission.getLocations()).singleElement() + .extracting(EventSubmissionLocation::getRepresentativeGridId) + .isEqualTo("16860_11512"); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("직사각형이 아닌 위치는 중심 최근접이 대표 격자가 된다") + void 직사각형이_아닌_위치는_중심_최근접이_대표_격자가_된다() { + // 2x2 사각형에 한 칸을 덧댄 L 자 — 경계 상자를 꽉 채우지 않아 정중앙 경로가 성립하지 않는다. + EventSubmission submission = submitted(withLocations(List.of( + location(rect(100, 101, 200, 201), rect(102, 102, 200, 200))))); + + assertThat(submission.getLocations()).singleElement() + .extracting(EventSubmissionLocation::getRepresentativeGridId) + .isEqualTo("101_200"); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("위치 순번은 요청 배열 순서대로 1부터 매겨진다") + void 위치_순번은_요청_배열_순서대로_1부터_매겨진다() { + EventSubmission submission = submitted(withLocations(List.of( + location(rect(100, 100, 200, 200)), location(rect(300, 300, 400, 400))))); + + assertThat(submission.getLocations()) + .extracting(EventSubmissionLocation::getDisplayOrder, + EventSubmissionLocation::getRepresentativeGridId) + .containsExactly(tuple(1, "100_200"), tuple(2, "300_400")); + } + } + + @Nested + @DisplayName("기간과 유형별 항목") + class Period { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("종료일이 시작일보다 빠르면 거부한다") + void 종료일이_시작일보다_빠르면_거부한다() { + assertThatThrownBy(() -> service.submit(USER_ID, request(EventSubmissionType.FESTIVAL, + "멀티불꽃쇼", null, LocalDate.of(2026, 11, 8), LocalDate.of(2026, 11, 7), + List.of(location(rect(100, 100, 200, 200)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_PERIOD); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("종료일이 KST 오늘이면 통과하고 그 전날이면 거부한다 — UTC 로 판정하면 통과해 버릴 시각이다") + void 종료일이_KST_기준_오늘_이전이면_거부한다() { + LocalDate kstToday = LocalDate.of(2026, 11, 7); + + assertThatCode(() -> service.submit(USER_ID, request(EventSubmissionType.FESTIVAL, "멀티불꽃쇼", null, + kstToday.minusDays(10), kstToday, List.of(location(rect(100, 100, 200, 200)))))) + .doesNotThrowAnyException(); + + assertThatThrownBy(() -> service.submit(USER_ID, request(EventSubmissionType.FESTIVAL, "멀티불꽃쇼", null, + kstToday.minusDays(10), kstToday.minusDays(1), List.of(location(rect(100, 100, 200, 200)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.INVALID_SUBMISSION_PERIOD); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("축제 신청에 주요 프로그램이 없으면 거부한다") + void 축제_신청에_주요_프로그램이_없으면_거부한다() { + assertThatThrownBy(() -> service.submit(USER_ID, request(EventSubmissionType.FESTIVAL, null, null, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), List.of(location(rect(100, 100, 200, 200)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("축제 신청에 운영 시간이 실려 오면 거부한다 — 폼에 없는 값을 조용히 저장하지 않는다") + void 축제_신청에_운영_시간이_실려_오면_거부한다() { + assertThatThrownBy(() -> service.submit(USER_ID, request(EventSubmissionType.FESTIVAL, "멀티불꽃쇼", + "11:00 ~ 20:00", LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), + List.of(location(rect(100, 100, 200, 200)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("팝업 신청에 운영 시간이 없으면 거부하고, 주요 프로그램이 실려 와도 거부한다") + void 팝업_신청에_운영_시간이_없으면_거부한다() { + assertThatThrownBy(() -> service.submit(USER_ID, request(EventSubmissionType.POPUP, null, null, + LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 20), List.of(location(rect(100, 100, 200, 200)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + + assertThatThrownBy(() -> service.submit(USER_ID, request(EventSubmissionType.POPUP, "멀티불꽃쇼", + "11:00 ~ 20:00", LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 20), + List.of(location(rect(100, 100, 200, 200)))))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_REQUIRED_FIELD_MISSING); + } + } + + @Nested + @DisplayName("신청 번호") + class SubmissionNo { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("신청 번호는 FM-{KST 연도}-{4자리 순번} 꼴이다") + void 신청하면_FM꼴_신청_번호가_부여된다() { + EventSubmission submission = submitted(festival()); + + assertThat(submission.getSubmissionNo()).isEqualTo("FM-2026-0007"); + assertThat(submission.getStatus().name()).isEqualTo("IN_REVIEW"); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("순번이 9999 를 넘으면 자릿수가 자연히 늘어난다 — 리셋 기계가 없어도 겹치지 않는다") + void 순번이_9999를_넘으면_자릿수가_늘어난다() { + given(submissionRepository.nextSubmissionSequence()).willReturn(10_000L); + + assertThat(submitted(festival()).getSubmissionNo()).isEqualTo("FM-2026-10000"); + } + } + + @Nested + @DisplayName("대표 이미지") + class Image { + + // 검증: FR-EVENT-13 + @Test + @DisplayName("제출은 pending 키를 확정본으로 바꿔 저장한다 — 저장되는 것은 요청 키가 아니다") + void pending_이미지_키가_확정_프리픽스로_복사되어_저장된다() { + given(imageStore.confirm(USER_ID, "event-submissions/pending/42/a.jpg")) + .willReturn("event-submissions/original/42/b4d1.jpg"); + + assertThat(submitted(festival()).getImageKey()).isEqualTo("event-submissions/original/42/b4d1.jpg"); + } + + // 검증: FR-EVENT-13 + @Test + @DisplayName("이미지 확정이 실패하면 신청이 저장되지 않는다") + void 이미지_확정이_실패하면_신청이_저장되지_않는다() { + given(imageStore.confirm(anyLong(), anyString())) + .willThrow(new ApiException(EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED)); + + assertThatThrownBy(() -> service.submit(USER_ID, festival())) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_NOT_UPLOADED); + then(submissionRepository).should(never()).save(any(EventSubmission.class)); + } + } +}