From 148ba5e636c6a48752d3f9ed48e87c49b34a3ee5 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sat, 29 Aug 2026 23:48:21 +0900 Subject: [PATCH 01/35] =?UTF-8?q?MSG-500=20docs:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=8B=AC=EC=82=AC=EC=99=80=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81,=20=EB=85=B8=EC=B6=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EC=8A=A4=ED=8E=99=EC=9D=84=20=EC=9E=91=EC=84=B1=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/spec/MSG-500.md | 600 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 600 insertions(+) create mode 100644 docs/spec/MSG-500.md diff --git a/docs/spec/MSG-500.md b/docs/spec/MSG-500.md new file mode 100644 index 00000000..348ef9e8 --- /dev/null +++ b/docs/spec/MSG-500.md @@ -0,0 +1,600 @@ +# MSG-500: 관리자가 신청을 심사해 승인하면 행사가 지도에 실린다 + +**Owner**: B (콘텐츠·인증) + +> PRD: `docs/prd/event-submission.md` (상태: 검토됨, FR-15~20·FR-25 담당. +> 아이디 변경 심사 절은 FR-23과 SRS FR-USER-16 배정에 따른 편입이다) +> 착수 게이트였던 "승인 행사 노출 방식"은 2026-08-29 정민 확정으로 해소됐다. +> PRD v2.2(이 확정 반영)는 MSG-502 브랜치(feature/MSG-502-event-participation)에 실리는 중이다. +> **머지 순서**: 이 스펙(과 구현)의 develop 머지는 PRD v2.2 머지보다 뒤여야 한다. 선행 머지되면 +> 유형별 분기를 서술하는 스펙과 분기 확정 전 PRD가 모순된 채 공존한다. +> 의존: MSG-498(event_submissions 스키마, V49, develop 머지됨), 참여형 분기 한정으로 MSG-502(V50). + +## 개요 + +관리자가 신청 큐에서 행사 등재 신청을 검토하고 승인 또는 반려한다. 승인하면 유형에 따라 +지도 노출 산출물이 생긴다. 지역축제와 팝업스토어 승인분은 미션으로 등재되어 지도 홈 칩 목록에 +실리고, 이벤트 참여형 승인분은 부모 이벤트(기존 행사 도메인)의 회차 아래 행사 위치로 반영된다. +반려는 항목 코드와 자유 서술을 함께 남기고, 승인 후 문제가 생긴 행사는 관리자가 노출을 +중지할 수 있다. 함께, MSG-497이 접수까지 만든 아이디(공식 이메일) 변경 요청의 관리자 +검토·승인 처리를 이 티켓이 닫는다(SRS FR-USER-16 배정). + +## 배경 · 목표 + +- MSG-498이 신청 접수까지 만들었지만 심사가 없어 신청이 심사 중 상태에 머무른다. 이 티켓이 + 파이프라인의 반대쪽 끝(심사, 승인 반영, 노출 관리)을 닫아 셀프서비스 등재가 완주된다. +- 승인 반영의 핵심 입력은 2026-08-29 정민 확정이다: 지역축제 승인분은 축제 미션 + (`MissionType.EVENT`), 팝업스토어 승인분은 팝업 미션(`MissionType.POPUP`)으로 등재하고, + 이벤트 참여형 승인분만 기존 행사 도메인(시리즈, 회차, 위치)을 쓴다. 티켓 본문의 "기존 행사 + 도메인에 시리즈·회차·위치 생성" 서술은 확정 전 문구라 참여형 경로에만 해당한다. + +### 용어 주의: 두 "이벤트"는 다른 것이다 (glossary.md) + +- **이벤트(행사 카테고리)**: 지역축제와 팝업스토어를 제외한 큰 행사 유형(구 행사방). 코드로는 + 행사 도메인(`event_series`, `event_occurrences`)이다. 참여형 신청의 부모가 이것이다. +- **`MissionType.EVENT`**: 미션 도메인의 값으로, 지도 홈 칩 "축제"에 대응한다. + `FestivalMissionSeeder`가 이 값으로 시딩한다. 위의 행사 카테고리 "이벤트"와 반대편을 + 가리키므로, 이 스펙에서 지역축제 승인분이 `MissionType.EVENT`가 되는 것은 "이벤트 카테고리 + 편입"이 아니라 "축제 칩 등재"라는 뜻이다. + +## 성공 기준 + +1. 관리자가 심사 큐를 상태별 건수와 함께 조회하고, 상세에서 위치 사각형과 노출 영역[^1]을 + 확인할 수 있다 (FR-15, FR-16). +2. 지역축제(팝업스토어) 신청을 승인하면 미션 1건과 판정 격자가 생기고, 재기동이나 재시드 없이 + 기존 미션 조회 API(지도 홈 칩)에 즉시 나타난다 (FR-17 확정 개정판). +3. 이벤트 참여형 신청을 승인하면 부모 회차 아래 행사 위치가 생기고 기존 노출 채널 + (`GET /api/event-occurrences` 계열)로 조회된다. 참여 속성(운영 주체, 소개, 공개 기간, + 참여 방식, 커버 이미지)이 위치에 붙어 함께 노출된다(PRD v2.2 확정 1). 기존 DDL 제약 + (회차 내 격자 단일 귀속[^2], 대표 격자 소속)을 그대로 통과한다 (FR-17, FR-18). +4. 승인 반영과 신청 상태 갱신(APPROVED, 승인 번호 부여, 이력 기록)은 한 트랜잭션이다. +5. 반려는 항목 코드 1개 이상과 자유 서술이 필수이고, 이력 테이블의 반려 행으로 저장되어 + 행사 운영자 상세(MSG-498 기존 API)에 그대로 나타난다 (FR-19). +6. 노출 중지하면 유저 지도(미션 칩, 격자 조회)와 행사 조회에서 즉시 사라지고, 행사 운영자의 + 공식 이메일로 사유가 발송된다 (FR-20). +7. 관리자가 승인 행사를 노출 중, 예정, 종료 탭 건수와 함께 조회할 수 있다 (FR-25). +8. 관리자가 아이디 변경 요청 큐를 조회해 승인하거나 반려할 수 있고, 승인하면 로그인 이메일이 + 요청값으로 바뀐다 (PRD FR-23, SRS FR-USER-16 잔여분). + +## API 명세 + +전부 `/api/admin` 아래라 SecurityConfig의 기존 ADMIN matcher가 필터 단계에서 역할을 거른다 +(AdminOrgAccountController 선례). 컨트롤러에 별도 역할 검사를 두지 않고, SecurityConfig 변경도 +없다. 관리자 조회에는 존재 은닉이 없다: 없는 id는 그대로 404다. + +### 1. 심사 큐: GET /api/admin/event-submissions + +- 쿼리: `status`(기본 IN_REVIEW, 대소문자 무관), `page`(기본 0), `size`(기본 20, 1~100). + 파라미터 검증 실패는 MSG-499 관리자 목록과 같은 방식으로 400이다. +- 응답 `AdminEventSubmissionListResponseDto`: + - `counts`: 상태별 건수 3종(inReview, approved, rejected). 필터와 무관하게 전체 기준이다 + (MSG-499 요청 큐 선례, 탭 뱃지 재료). + - `submissions[]`: id, submissionNo, type, status, title, organizerName(신청 폼의 주최 기관), + orgName(신청 계정의 기관명, users에서 조인), startsOn, endsOn, locationCount, createdAt, + updatedAt. 정렬은 접수 최신순(createdAt DESC, id DESC). + +### 2. 심사 상세: GET /api/admin/event-submissions/{submissionId} + +- 응답 `AdminEventSubmissionDetailResponseDto`: MSG-498 신청 상세와 같은 폼 필드 전체 + (이미지 presigned GET 포함, `EventSubmissionLocationResponseDto` 재사용)에 심사 재료를 더한다: + - 신청 계정: orgName, contactName, email (users 조회, userId 세타 조인 또는 findById). + - `exposureRect`: 전 위치 셀 합집합의 경계 사각형(minGridY, maxGridY, minGridX, maxGridX). + 조회 시점 계산이고 저장하지 않는다. 참여형 승인이 부모 회차 노출 영역을 얼마나 넓히는지, + 축제·팝업 미션이 어느 범위에 깔리는지의 검토 재료다. + - 상태 이력(기존 history DTO 재사용). +- 없는 id는 404(13430)다. + +### 3. 승인: POST /api/admin/event-submissions/{submissionId}/approve + +- 요청 본문: 없음. 승인 입력은 전부 저장된 신청에서 나온다. MSG-499의 검토 기준 시각 + (updatedAt 되돌리기, 1426) 가드는 두지 않는다. 근거는 D-7. +- 응답 `EventSubmissionApproveResponseDto`: submissionId, approvalNo("APR-2026-0001" 꼴), + status("APPROVED"). +- 실패: 없는 id 404(13430), 심사 중이 아님 409(13450), 기간이 이미 지남 409(13451), + 참여형 격자 충돌 409(13452). 13452를 받은 관리자의 다음 조작은 승인이 아니라 **AREA 코드 + 반려다**(MSG-502 스펙 D-7이 이 안내 정의를 이 티켓에 배정): 겹침은 신청자가 위치를 고쳐 + 재제출해야 풀리는 문제라, 반려 사유의 항목 코드 AREA와 서술로 되돌려 보낸다. + +### 4. 반려: POST /api/admin/event-submissions/{submissionId}/reject + +- 요청 `EventSubmissionRejectRequestDto`: + - `reasonCodes`: 문자열 배열, 1개 이상. 허용값은 PERIOD, AREA, IMAGE, INFO + (`EventSubmissionReasonCode`, MSG-498이 정의한 저장 형태). 빈 배열, 허용 밖 값, 중복은 + 400(13454)이다. 서비스에서 파싱해 검증한다(Jackson enum 역직렬화 실패를 공통 400에 맡기지 + 않고 도메인 코드로 낸다). + - `reasonText`: 자유 서술, `@NotBlank`. +- 반려 행은 이력 테이블에만 쓴다(MSG-498 D-3, 저장 원천 단일). V49의 CHECK 두 개 + (반려 행에만 사유가 있다)를 그대로 통과한다. +- 응답: data 없음(null). 실패: 404(13430), 409(13450). +- 메일은 보내지 않는다. 행사 운영자는 콘솔 상세(MSG-498)에서 반려 항목과 사유를 본다 (D-5). + +### 5. 승인 행사 목록: GET /api/admin/events + +- 원천은 `event_submissions`의 APPROVED 행이다. 승인 행사의 안정 식별자는 신청 id다 (D-11). +- 쿼리: `status`(기본 EXPOSED. EXPOSED 노출 중, UPCOMING 예정, ENDED 종료), `page`, `size`. +- 상태는 저장하지 않고 KST 오늘과 startsOn, endsOn으로 파생한다: 오늘이 시작 전이면 UPCOMING, + 기간 안이면 EXPOSED, 종료 후면 ENDED. +- 응답 `AdminApprovedEventListResponseDto`: + - `counts`: 탭 건수 3종(전체 기준). + - `events[]`: submissionId, approvalNo, submissionNo, type, title, organizerName, orgName, + startsOn, endsOn, status(파생값), unpublished(중지 여부), unpublishedAt, unpublishReason. + 중지된 행사도 파생 탭에 그대로 남고 unpublished 필드로 구분한다 (D-11). + +### 6. 노출 중지: POST /api/admin/events/{submissionId}/unpublish + +- 요청 `AdminEventUnpublishRequestDto`: `reason` 자유 서술, `@NotBlank` (FR-20 사유 필수). +- 효과: 신청 행에 중지 기록(unpublished_at, unpublish_reason)을 남기고, 같은 트랜잭션에서 + 승인 산출물을 숨긴다(D-3). 미션 경로는 missions.hidden_at, 참여형 경로는 생성했던 + event_locations 전부의 hidden_at을 세팅하고 부모 회차 노출 영역을 재계산한다. +- 통지: 커밋 성공 후 신청 계정의 공식 이메일(users.email)로 사유를 발송한다 (D-5, + `global.mail.MailSender` 재사용). 발송 실패는 삼키고 응답 `emailSent`를 false로 준다. + 상태는 유지된다(MSG-499 승인 발송 선례). 재발송 API는 만들지 않는다: 저장된 사유가 수기 + 재통지의 재료다. +- 응답 `AdminEventUnpublishResponseDto`: submissionId, unpublishedAt, emailSent. +- 실패: 승인된 신청이 아니거나 없는 id는 404(13430), 이미 중지됨 409(13453). +- 중지 해제(재노출)는 이 티켓 범위 밖이다. hidden_at이 시각 컬럼이라 해제는 NULL 세팅으로 + 열려 있고, 필요해지면 후속 티켓이 든다. + +### 7. 아이디 변경 요청 심사 (SRS FR-USER-16 편입) + +지라 티켓 범위에는 없던 항목이다. SRS FR-USER-16이 "잔여는 아이디 변경 요청의 관리자 +검토·승인 처리 하나이고 담당은 MSG-500"으로 배정했고, 근거는 PRD FR-23(아이디는 관리자가 +승인해야 바뀐다)이다. MSG-497이 접수와 저장(`org_email_change_requests`, PENDING 부분 +유니크)까지 구현했고, MSG-499 관리자 큐는 계정 발급 요청 전용이라 이 요청은 지금 어느 +화면에도 노출되지 않는다. 계정 관련 심사라 event가 아니라 user 도메인이고, 에러 대역도 +1xxx다(AdminOrgAccountController 계열 확장). + +- **GET /api/admin/email-change-requests**: `status`(기본 PENDING), `page`, `size`. + MSG-499 요청 큐와 같은 구조로 상태별 건수 3종(pending, approved, rejected)과 목록 + (id, userId, orgName, 현재 email, requestedEmail, status, createdAt, processedAt, + rejectReason)을 준다. 응답의 createdAt이 승인·반려 요청에 되돌려 보내야 하는 검토 기준 + 시각이다. 필터·페이지 검증 실패는 기존 1424·1425 재사용. +- **POST /api/admin/email-change-requests/{requestId}/approve**: 요청 본문 + `{ "requestedAt": ... }` (검토한 행의 접수 시각, 목록·상세의 createdAt 되돌리기, 필수). + 조건부 UPDATE로 PENDING에서 APPROVED로 전이하되 **술어에 createdAt 일치를 포함해 원자 + 비교한다** (D-13 검토 시점 가드): 접수 UPSERT가 재제출을 같은 PENDING 행 갱신으로 + 처리하므로, 상태만 보면 관리자가 큐를 띄운 뒤 재제출된, 본 적 없는 이메일을 승인하게 된다. + 어긋나면 409(1429)로 재검토를 요구한다(MSG-499의 1426 낙관 가드 패턴). 같은 트랜잭션에서 + users.email을 requestedEmail로 교체한다(함께 성공하거나 함께 실패). requestedEmail이 이미 + 다른 계정에 있으면 409(1409, MSG-499 이메일 충돌 선례 재사용): users.email UNIQUE가 + 백스톱이지만 제약 위반 500 대신 읽히는 에러를 위해 선검사하고, 동시 승인 경합의 위반 + 번역은 D-13이 다룬다. 승인 결과는 **새 이메일로 통지 메일을 보낸다**(MailSender, 실패 시 + emailSent=false, unpublish와 같은 패턴). 발급·반려 통보 수기 선례와 달리 메일을 보내는 + 이유는 로그인 수단 자체가 바뀌는 사건이라 미통지 시 운영자가 계정 접근을 잃기 때문이다. +- **POST /api/admin/email-change-requests/{requestId}/reject**: `reason` 필수(`@NotBlank`)와 + `requestedAt` 필수(승인과 같은 검토 시점 가드: 재제출로 내용이 바뀐 요청을 낡은 사유로 + 반려하는 것을 막는다. 어긋나면 409(1429)). PENDING에서 REJECTED로 전이하고 사유를 + 저장한다. 메일은 보내지 않는다(반려 통보 수기, MSG-499 반려 선례. 저장된 사유가 수기 + 통보의 재료다). +- 실패 분기: 없는 요청 404(1427), 이미 처리된 요청 409(1428), 검토 이후 재제출로 내용이 + 바뀐 요청 409(1429). 발급 요청용 1421·1422·1426을 재사용하지 않는 이유는 메시지가 "발급 + 요청"을 지목해 이 요청에 어긋나기 때문이다. +- 처리 후 PENDING이 빠지므로 부분 유니크(uq_org_email_change_requests_pending)에 의해 + 같은 계정의 재접수가 자연히 다시 열린다. + +신규 에러 코드 (UserErrorCode, 1xxx 대역): + +| developCode | 상수 | HTTP | 메시지 | +|---|---|---|---| +| 1427 | EMAIL_CHANGE_REQUEST_NOT_FOUND | 404 | 존재하지 않는 아이디 변경 요청입니다 | +| 1428 | EMAIL_CHANGE_REQUEST_ALREADY_PROCESSED | 409 | 이미 처리된 아이디 변경 요청입니다 | +| 1429 | EMAIL_CHANGE_REQUEST_MODIFIED | 409 | 검토한 이후 요청 내용이 변경되었습니다 | + +### 신규 에러 코드 (EventErrorCode, 13450대) + +MSG-502가 13440대를 예약해 이 티켓은 13450부터 쓴다 (세션 간 경계). + +| developCode | 상수 | HTTP | 메시지 | +|---|---|---|---| +| 13450 | SUBMISSION_NOT_REVIEWABLE | 409 | 심사 중인 신청만 승인하거나 반려할 수 있습니다 | +| 13451 | SUBMISSION_PERIOD_PASSED | 409 | 행사 기간이 이미 지나 승인할 수 없습니다 | +| 13452 | SUBMISSION_GRID_CONFLICT | 409 | 신청 영역이 부모 이벤트의 다른 위치와 겹칩니다 | +| 13453 | EVENT_ALREADY_UNPUBLISHED | 409 | 이미 노출이 중지된 행사입니다 | +| 13454 | INVALID_REJECT_REASON | 400 | 반려 항목 코드가 올바르지 않습니다 | + +409 셋은 권한이 아니라 리소스의 현재 상태와 요청의 충돌이라는 기존 결(13409, 13434 선례)을 +따른다. 404와 조회 재료(13430)는 MSG-498 상수를 재사용한다. + +## 도메인 로직 + +### 승인: 유형별 분기 (D-1) + +공통 선행: 조건부 UPDATE로 상태를 IN_REVIEW에서 APPROVED로 바꾸면서 승인 번호를 채운다 +(reopenRejected 선례의 원자 전이). 영향 행 0이면 findById로 가른다: 행이 없으면 13430, 있으면 +13450이다(동시 심사의 패자 포함). 이겼으면 이력에 APPROVED 행을 쌓고 유형 분기로 들어간다. +전이, 이력, 산출물 생성, 이미지 복사 보상 등록까지 전부 한 트랜잭션이다(비기능 데이터 정합). + +기간 가드: 승인 시점 KST 오늘이 endsOn을 지났으면 13451로 거부한다. 접수 검증(MSG-498 D-6)과 +같은 규칙을 심사 지연 구간에 다시 거는 것이다. 참여형은 부모 회차의 endsAt 경과도 같은 코드로 +거부한다(끝난 이벤트에 참여 위치를 다는 것은 반영할 노출이 없다). + +**지역축제, 팝업스토어 → 미션 등재 (D-2)** + +미션 도메인 신설 서비스 `MissionRegistrationService`(mission 패키지, 아래 협업 절)를 호출한다. +승인 서비스가 mission 리포지토리를 직접 쓰지 않는 것은 타 도메인 쓰기는 소유 도메인 서비스를 +거친다는 원칙 때문이다(컨벤션 영속 계층 절, event와 mission은 둘 다 Owner B지만 원칙은 같다). + +- 신청 1건이 미션 1건이 된다. 위치가 여러 개라도 칩 카드 하나가 자연스럽다. +- `type`: FESTIVAL이면 `MissionType.EVENT`(축제 칩), POPUP이면 `MissionType.POPUP`(팝업 칩). +- `title`: 신청 title(100자라 200자 컬럼에 절단 불요). `targetCount`: 1 + (축제 시더 선례: 관대함으로만 작용하고, 대표 격자 CHECK인 chk_missions_rep_grid_type의 + 전제이기도 하다). +- `startAt`, `endAt`: startsOn과 endsOn(KST 날짜 라벨)을 축제 시더와 같은 규칙으로 UTC 순간에 + 올린다. 시작은 KST 00:00:00, 끝은 KST 23:59:59의 UTC 변환값이다. `FestivalMissionSeeder`의 + `toUtcStart`, `toUtcEnd`를 public으로 승격해 재사용한다(규칙 두 벌 복제 금지). +- `source`: 새 상수 `"ORG_SUBMISSION"`. 시더 정리(`deleteEndedBySourceWithoutStamps`)와 + dedupe가 전부 source 한정이라 승인 미션은 시더 파이프라인과 완전히 절연된다. 값 상수는 + 러너가 아니라 등재 서비스가 보유한다(Mission.source 주석의 소유 규칙). +- `sourceKey`: submissionNo("FM-2026-0007", 30자 안). (source, source_key) 부분 유니크 + 인덱스[^3]가 이중 승인으로 미션이 두 번 생기는 것의 DB 백스톱이다. 1차 방어는 조건부 + UPDATE다. +- 판정 격자: 전 위치 셀의 합집합을 mission_grids로 넣는다. 위치 간 겹침은 PK가 자연히 + 한 번만 남긴다. 대표 격자[^4]는 이 합집합에 `RepresentativeGridResolver`를 다시 적용한 + 값이다. 위치별 저장값(FR-9)은 콘솔 표시 재료로 남고 미션에는 쓰지 않는다: 미션의 대표 + 격자는 하나뿐이고, 지연 FK(fk_missions_rep_grid)가 합집합 소속을 커밋 시점에 검증한다. +- 메타데이터: description은 신청 소개, operationTime은 팝업의 운영 시간(축제는 null), + placeName과 sourceUrl과 코스 지표는 null, imageUrl은 아래 이미지 복사 결과다. + +**미션 스냅숏 캐시 무효화 (D-12)**: 미션 목록 조회(`MissionQueryServiceImpl`)는 TTL 1시간의 +인메모리 전역 스냅숏(volatile 단일 홀더) 위에서 뷰포트, 클러스터, 지역 집계를 필터링한다. +DB에 hidden_at 술어를 넣는 것만으로는 부족하다: 무효화 없이는 승인 미션이 최대 1시간 지도에 +안 실리고, 중지 미션이 최대 1시간 계속 보인다(FR-17의 "즉시 노출", FR-20의 "즉시 제외" 양쪽 +위반). 그래서 `MissionQueryService`에 스냅숏 무효화 메서드(캐시 홀더를 비워 다음 조회가 +재계산하게 하는 것)를 추가하고, `MissionRegistrationService`의 register와 hide가 **트랜잭션 +커밋 후**에 이것을 호출한다(`TransactionSynchronization` afterCommit, +EventSubmissionImageStore의 기존 afterCommit 패턴 재사용). 커밋 후인 이유는 커밋 전에 비우면 +재계산이 아직 안 보이는 미커밋 행을 놓친 스냅숏을 도로 채울 수 있어서다. 시더는 기동 1회 +실행이라(스냅숏 생성 전) 무효화가 필요 없어 기존 경로는 불변이다. 멀티 인스턴스 불일치는 +캐시 주석의 기존 한계(단일 인스턴스 전제, Redis 이관 시점에 함께 해소) 그대로다. + +**공개 이미지 복사 (D-6)**: 신청 확정본 프리픽스(`event-submissions/original/`)는 공개 읽기가 +없어서(MSG-498 설계) presigned GET으로만 열린다. 승인 산출물의 이미지는 사용자 대면 목록이 +공개 URL로 소비하므로(미션 카드 image_url 선례, MSG-383 D7) 승인 시 확정본을 공개 +프리픽스로 복사한다. 미션 경로는 `missions/org-submission/{uuid}.{ext}`로 복사해 +`AwsProperties.publicUrl` 조립값을 mission.imageUrl에 저장하고, 참여형 경로는 +`event-locations/org-submission/{uuid}.{ext}`로 복사해 **키**를 event_locations.image_key에 +저장한다(조립은 조회 시 publicUrl. 위치가 여럿이면 공개 사본 하나를 공유한다: 위치별 재복사는 +같은 바이트의 중복이다). 복사 보상은 `EventSubmissionImageStore`의 deleteOnRollback 패턴을 +따른다(롤백 시 방금 만든 공개 사본 삭제). 원본 확정본은 지우지 않는다: 심사 상세와 콘솔 +상세가 계속 읽는다. + +**이벤트 참여형 → 부모 회차 아래 행사 위치 (D-8)** + +부모 회차 id와 참여 속성은 MSG-502가 V50으로 확장하는 컬럼에서 읽는다(컬럼 명칭과 참여형 폼 +필드는 MSG-502 스펙이 정본이고, 이 티켓은 읽기 전용이다: 세션 간 경계). PRD v2.2 확정 1이 +"참여 속성은 위치에 붙는 부가 정보"이므로, 승인은 신청 행의 속성을 위치 행으로 복사해야 +한다. 복사가 없으면 속성이 신청 테이블에 갇혀 승인 시점에 유실된다(MSG-502 스펙 비범위 절이 +이 반영을 이 티켓에 배정). + +- 신청 위치 하나가 `EventLocation` 하나가 된다. 기본 필드: + - `locationKey`: `"sub-{submissionNo}-{displayOrder}"` (예 "sub-FM-2026-0012-1", 60자 안). + 자연키 유니크가 위치 단위 멱등 백스톱이다. + - `name`: 신청 title. 위치가 여럿이면 `"{title} {n}"`으로 순번을 붙인다(name NOT NULL인데 + 신청 위치에는 이름이 없다는 접수 정책 #102를 메우는 규칙). name과 title이 둘 다 + VARCHAR(100)이라 접미사를 붙이면 넘칠 수 있으므로, **접미사(공백 + 순번, 위치 수 상한 + 20이라 최대 3자)를 먼저 만들고 그 길이만큼 title을 잘라낸 뒤 붙인다**. 결과가 항상 + 100자 안이라 승인이 DB 길이 오류로 죽지 않는다. + - `type`: `EventLocationType.ETC`(넷에 안 걸리는 위치의 도피처라는 enum 주석 그대로). + 참여 방식 문자열은 위치 유형이 아니다. + - `operatingHours`: null. `displayOrder`: 부모 회차 기존 위치 max + 1부터 신청 순번대로. + - `representativeGridId`: 접수 때 계산해 저장한 위치별 대표 격자를 그대로 쓴다(FR-9). + 셀 집합 소속이 이미 성립해 지연 FK(fk_event_loc_rep_grid)를 통과한다. +- **참여 속성 복사 매핑** (V51 확장 컬럼, 전부 nullable): 신청 행에서 생성되는 모든 위치 + 행으로 같은 값을 복사한다(속성이 신청 단위라 위치 N개가 같은 부가 정보를 갖는 것이 + v2.2 확정 1의 의미 그대로다). + - organizer_name ← 신청 운영 주체, description ← 소개, starts_on·ends_on ← 공개 기간, + participation_method ← 참여 방식, image_key ← D-6의 공개 사본 키. + - **공개 기간은 표기 정보다**: 위치의 노출 창이 아니다. 위치 노출은 종전대로 부모 회차 + 생명주기와 hidden_at이 지배한다. 기간을 노출 창으로 해석하는 순간 위치별 상태 판정 + 축이 하나 더 생기는데 PRD v2.2는 "부가 정보"라고만 확정했다. 노출 창 해석 여부는 + 미해결 질문으로 남긴다. +- 격자: 위치별 셀을 event_location_grids로 넣는다. 사전 검사와 삽입에 앞서 **부모 회차 행을 + 비관적 잠금[^6]으로 잡아 같은 회차의 승인을 직렬화한다** (D-9): 회차 리포지토리에 + `@Lock(PESSIMISTIC_WRITE)` 잠금 조회를 신설한다(`EventLocationRepository. + findWithLockByLocationKey` 선례와 같은 패턴). 잠금 없이는 서로 다른 두 신청이 같은 회차의 + 겹치는 셀로 동시 승인될 때 두 사전 검사가 모두 통과하고, uq_event_grid_per_occ가 지연 + 제약이라 지는 쪽이 커밋 시점 500이 된다. 잠금 뒤 겹침을 **두 방향으로** 사전 검사한다: + 부모 회차의 기존 격자와의 겹침, 그리고 이번 신청의 신규 위치들끼리의 겹침이다. 후자가 + 필요한 이유는 접수 검증(MSG-498)이 위치를 독립적으로 검증해 위치 간 겹침 입력이 심사까지 + 통과해 오기 때문이다. 어느 쪽이든 겹치면 삽입 전에 13452로 거부한다. + uq_event_grid_per_occ(지연 제약)는 커밋 시점 백스톱으로 남는다. 사전 검사를 두는 이유는 + 지연 제약 위반이 커밋 시점 DataIntegrityViolation으로 터져 관리자에게 읽을 수 없는 500이 + 되기 때문이다. 13452 이후의 관리자 조작은 AREA 코드 반려다(API 명세 3). +- 노출 영역 확장: 새 위치 셀들의 경계 사각형을 부모 회차의 노출 영역과 합집합으로 넓힌다 + (기존 min/max와의 min, max 연산). "노출 영역 = 위치 사각형들을 감싸는 범위"라는 기존 + 불변식(FR-16 각주 정의)을 유지하기 위해서다. `EventOccurrence.update`를 그대로 쓰면 + 시각이 불변이라 scheduleRevision이 오르지 않아 일정 변경 알림이 나가지 않는다. + +**참여 속성의 노출 계약**: 회차 상세의 위치 목록(`EventLocationResponseDto`, MSG-439 API 3) +하나에만 싣는다. 확장 필드 5종을 nullable로 추가한다: organizerName, description, +participationStartsOn·participationEndsOn(`LocalDate`, 표기 정보), participationMethod, +imageUrl(image_key의 publicUrl 조립값). 시드 위치는 확장 컬럼이 전부 NULL이라 기존 화면 +응답이 null 필드 추가 외에 불변이다. 위치 영상 목록(`EventLocationVideoPageResponseDto`)에는 +싣지 않는다: 헤더 없는 얇은 페이지 구조이고("총계도 담지 않는다"가 기존 설계), 피드 진입 +전에 위치 목록이 이미 속성을 줬다. 이 필드 추가는 FE 계약 추가라 계약 변경 절에 명시한다. + +구현 순서 의존: 참여형 분기는 V50과 EventSubmissionType의 참여형 값이 develop에 머지된 뒤에만 +구현할 수 있다. MSG-502보다 먼저 머지하게 되면 이 분기를 "미지원 유형" 방어(도달 불가: +V49 CHECK가 FESTIVAL, POPUP만 허용)로 남기고 후속 커밋으로 채운다. + +### 반려 + +조건부 UPDATE로 IN_REVIEW에서 REJECTED로 전이하고(0행이면 승인과 같은 분기), 이력에 +반려 행(status REJECTED, reasonCodes, reasonText)을 쌓는다. `EventSubmissionStatusHistory`에 +`rejected(...)` 팩토리를 추가한다(inReview 선례). 행사 운영자 쪽 반영은 코드 변경이 없다: +MSG-498 상세가 이력 최신 행을 이미 읽는다. + +### 노출 중지 (D-3) + +조건부 UPDATE 하나로 원자화한다: `status = APPROVED AND unpublished_at IS NULL`인 행에 +unpublished_at과 unpublish_reason을 세팅한다. 0행이면 findById로 가른다(없거나 미승인이면 +13430, 이미 중지면 13453). 이긴 뒤 같은 트랜잭션에서 산출물을 숨긴다: + +- 미션 경로: `MissionRegistrationService.hide(missionId, now)`가 missions.hidden_at을 세팅하고, + 커밋 후 미션 스냅숏 캐시를 무효화한다(D-12). 무효화 없이는 TTL 1시간짜리 스냅숏이 중지된 + 미션을 최대 1시간 계속 그려 "즉시 제외"(FR-20)가 깨진다. +- 참여형 경로: published 위치들(event_locations.hidden_at)을 세팅하고, **같은 트랜잭션에서 + 부모 회차의 노출 영역을 남은 가시 위치들(hidden_at IS NULL)의 격자 전체 경계 사각형으로 + 재계산한다**. 승인이 넓힌 영역을 되돌리지 않으면 "노출 영역 = 위치 사각형들을 감싸는 + 범위" 불변식이 깨지고, 숨긴 위치만 커버하던 뷰포트에서 부모 행사가 계속 노출된다. + 재계산도 승인 확장과 같은 규칙이다: `EventOccurrence.update` 경유, 시각 불변이라 + scheduleRevision이 오르지 않는다. 부모 회차 잠금(D-9의 비관적 잠금)을 같은 순서로 잡아 + 동시 승인과의 경합을 직렬화한다. 행사 조회 경로에는 스냅숏 캐시가 없어 무효화가 필요 + 없다. + +**숨김의 소비**: 사용자 대면 조회와 판정 경로 전부에 `hidden_at IS NULL` 술어가 들어간다. +기존 행은 전부 NULL이라 시드 산출물의 동작은 불변이다. + +- 미션 쪽(전수 grep 후 확정, 최소 다음): `MissionRepository.findActive`(칩, 뷰포트), + `findAwardCandidateIds`와 `findCompleted`(스탬프 판정), + `findByRepresentativeGridIdOrderById`(격자 선택 시 대표 격자 역조회, `getMissionsByGrid` + 경로: 활성·숨김 술어가 둘 다 없어 중지 미션이 격자 선택으로 계속 보인다), + MissionQueryServiceImpl의 네이티브 뷰포트, 클러스터(MSG-437), 지역 집계(MSG-461) 쿼리, + 미션 상세, 미션 경유 업로드(MSG-459)의 미션 조회, 그리고 **미션 영상 피드** + (`GET /api/missions/{missionId}/videos`)가 쓰는 `VideoRepository.findMissionVideos`와 + `findMissionVideosAfter`, 미션 격자별 영상 개수 쿼리(MSG-399): missions를 조인하면서 + hidden_at을 안 보므로 missionId를 아는 사용자가 중지 후에도 피드를 계속 볼 수 있다. + 피드는 빈 배열이 아니라 **미션 상세와 같은 404로 수렴**시킨다(숨긴 미션은 목록에서 빠져 + 존재 자체가 안 보이는데, 피드만 빈 200을 주면 "존재하지만 비어 있다"로 읽혀 은닉이 + 갈라진다). 진행도 findProgress는 걸지 않는다: 종료 미션도 진행도가 나오는 D11과 같은 + 이유로, 이미 완료한 사용자의 기록 화면을 깨지 않는다. +- 행사 쪽(전수 grep 후 확정, 최소 다음): 회차 상세의 위치 목록, 격자의 행사 위치 조회, + 행사 영상 업로드의 위치 확인, 위치 영상 목록, 그리고 **videoId 직접 접근 진입점**인 + 영상 상세(`EventVideoServiceImpl.getVideoDetail`)와 댓글·도움돼요 + (`EventVideoInteractionService` 경유 엔드포인트 전부). 이 진입점들은 지금 영상 가시성과 + 회차 은닉만 보고 위치 숨김을 보지 않아, 이미 알던 videoId로는 중지 후에도 재생과 반응이 + 계속 된다. 영상이 속한 위치의 hiddenAt이 세팅돼 있으면 기존 존재 은닉과 같은 + 404(13406/13404 수렴)로 거부한다. 회차 단위 조회(칩, 열람 인원)는 부모 이벤트가 시드 + 산출물이라 불변이다: 숨는 것은 참여 위치뿐이다. + +기각 대안 두 개. 산출물 삭제는 기각한다: user_missions FK가 스탬프 있는 미션의 하드삭제를 +차단하고, 스탬프와 영상을 지우는 것은 비회수 원칙과 충돌한다. 미션 end_at 소급(기간을 지금으로 +당겨 활성 술어에서 자연 탈락시키는 방식)도 기각한다: 쿼리 변경이 0이라는 장점은 있지만 +recorded_at 판정 창을 함께 왜곡하고, 저장값이 사실(행사 기간)과 달라져 3시 장애 대응 때 +데이터가 거짓말을 한다. 회차 생명주기에 상태를 추가하는 안도 기각한다: 생명주기 4단계는 +저장하지 않는 시각 파생 계산이고(EventOccurrence 설계 원칙), 중지 대상이 회차가 아니라 +미션과 위치라 끼워 넣을 자리도 맞지 않는다. + +### 아이디 변경 요청 심사 (D-13, SRS FR-USER-16 편입) + +승인은 두 갱신이 한 트랜잭션이다: 조건부 UPDATE로 요청을 PENDING에서 APPROVED로 전이하고, +같은 트랜잭션에서 `users.email`을 requestedEmail로 교체한다. + +**검토 시점 가드**: 접수 UPSERT(`upsertPending`, ON CONFLICT로 같은 PENDING 행 갱신)가 +재제출을 새 행이 아니라 제자리 갱신으로 처리하므로, 요청 id와 상태만 보면 관리자가 검토한 +뒤 재제출된 내용을 그대로 승인·반려하게 된다(본 적 없는 이메일 승인, 낡은 사유 반려). 그래서 +전이 UPDATE의 술어에 요청 본문의 requestedAt(검토한 행의 createdAt)을 포함해 원자 +비교한다(MSG-499의 ORG_ACCOUNT_REQUEST_MODIFIED 1426 낙관 가드 패턴, 상수는 이메일 변경용 +1429 신설). 영향 행 0이면 findById로 가른다: 없으면 1427, PENDING이 아니면 1428, PENDING인데 +시각이 다르면 1429다. + +**이메일 교체는 단일 컬럼 UPDATE다**: 이 프로젝트는 `@DynamicUpdate`를 쓰지 않아(User 엔티티 +주석 명문) 관리 엔티티의 flush가 전 컬럼 UPDATE를 낸다. 그 경로로 교체하면 이메일 승인과 +비밀번호·담당자 수정이 경합할 때 낡은 승인 스냅숏이 다른 컬럼을 되돌린다. 그래서 +UserRepository에 `@Modifying` JPQL UPDATE(email 컬럼 하나만 세팅)를 신설해 그걸로 교체한다. +선검사로 requestedEmail의 기존 계정 존재를 확인해 1409를 내지만 선검사는 경합에 진다: 서로 +다른 두 요청이 같은 이메일을 노리고 동시 승인되면 둘 다 선검사를 통과한다. 직접 UPDATE라 +uq_users_email 위반이 실행 즉시 `DataIntegrityViolationException`으로 뜨므로 그 자리에서 +1409로 번역한다(커밋 시점까지 미뤄지지 않아 잡을 자리가 있다). + +접수 시각과 처리 시각(processed_at), 반려 사유(reject_reason)는 요청 행에 남긴다. 이메일 +교체 후에도 세션(액세스 토큰)은 무효화하지 않는다: 토큰의 주체는 userId라 이메일 교체와 +무관하고, 다음 로그인부터 새 이메일이 쓰인다. 통지는 API 명세 7의 결정(승인만 새 이메일로 +발송, 반려는 수기)을 따른다. + +### 승인 번호 (D-4) + +`APR-{KST 연도}-{4자리 0패딩 순번}`. 신청 번호(FM-, MSG-498 D-4)와 같은 구조다: 전역 시퀀스 +`event_submission_approval_no_seq`, 연도별 리셋 없음, 9999를 넘으면 자릿수가 는다. KST인 +이유도 같다(사용자 대면 라벨). + +## 데이터 모델 + +Flyway `V51__event_submission_review.sql` (V50은 MSG-502 예약, 세션 간 경계): + +```sql +-- 심사 결과 (MSG-500). 승인 번호와 산출물 링크, 노출 중지 기록. +CREATE SEQUENCE event_submission_approval_no_seq; + +ALTER TABLE event_submissions + ADD COLUMN approval_no VARCHAR(20) UNIQUE, -- "APR-2026-0001", 승인 시 부여 + ADD COLUMN published_mission_id BIGINT REFERENCES missions(id), + ADD COLUMN unpublished_at TIMESTAMP, -- UTC (MSG-376 체계) + ADD COLUMN unpublish_reason TEXT; + +-- 미승인 행에 승인 흔적이 남지 않는다. 승인 행은 승인 번호가 반드시 있다. +ALTER TABLE event_submissions + ADD CONSTRAINT chk_event_sub_approval + CHECK ((status = 'APPROVED') = (approval_no IS NOT NULL)), + ADD CONSTRAINT chk_event_sub_unpublish_pair + CHECK ((unpublished_at IS NULL) = (unpublish_reason IS NULL)), + ADD CONSTRAINT chk_event_sub_unpublish_approved + CHECK (unpublished_at IS NULL OR status = 'APPROVED'); + +-- 노출 숨김 (D-3). NULL = 노출 중, 기존 행 전부 NULL이라 시드 산출물 동작 불변. +ALTER TABLE missions ADD COLUMN hidden_at TIMESTAMP; +ALTER TABLE event_locations ADD COLUMN hidden_at TIMESTAMP; + +-- 참여형 승인분의 참여 속성 (D-8 복사 매핑, PRD v2.2 확정 1 "위치에 붙는 부가 정보"). +-- 전부 nullable: 시드 위치는 전부 NULL이라 기존 행사 조회 동작이 불변이다. +ALTER TABLE event_locations + ADD COLUMN organizer_name VARCHAR(100), + ADD COLUMN description TEXT, + ADD COLUMN starts_on DATE, -- 공개 기간 표기 정보 (KST 날짜 라벨) + ADD COLUMN ends_on DATE, + ADD COLUMN participation_method TEXT, + ADD COLUMN image_key VARCHAR(255); -- 공개 프리픽스 사본 키 (D-6) + +-- 아이디 변경 요청 심사 결과 (D-13). MSG-497 접수 스키마에는 처리 흔적 자리가 없었다. +ALTER TABLE org_email_change_requests + ADD COLUMN processed_at TIMESTAMP, + ADD COLUMN reject_reason TEXT; +ALTER TABLE org_email_change_requests + ADD CONSTRAINT chk_org_email_change_processed + CHECK ((status = 'PENDING') = (processed_at IS NULL)), + -- 반려면 사유 필수, 반려 아니면 사유 없음. 양방향 동치식이어야 한다: 반쪽 OR 식은 + -- 반려 행의 사유 NULL 을 통과시킨다. + ADD CONSTRAINT chk_org_email_change_reject_reason + CHECK ((status = 'REJECTED') = (reject_reason IS NOT NULL)); +``` + +- `published_mission_id`의 FK는 NO ACTION이다. 시더 정리는 source 한정이라 승인 미션을 + 건드리지 않지만, 만에 하나의 삭제 시도를 DB가 막아 링크 고아를 차단한다. +- 참여형 산출물 링크는 컬럼을 더하지 않는다. locationKey 접두(`sub-{submissionNo}-`)로 + 결정적으로 역산되고, 위치는 여러 행이라 단일 FK 컬럼이 맞지 않는다. +- (source, source_key) 부분 유니크는 V14 기존 인덱스가 그대로 백스톱이 된다(신청 승인이 + source='ORG_SUBMISSION', source_key=submissionNo로 그 인덱스에 얹힌다). +- 반려 사유 저장(행사 등재 신청)은 스키마 변경이 없다: V49의 이력 테이블과 CHECK가 이미 + 계약이다. +- 엔티티 반영: EventSubmission에 approvalNo, publishedMissionId, unpublishedAt, + unpublishReason 필드와 상태 전이 메서드[^5](approve, unpublish 기록), Mission과 + EventLocation에 hiddenAt 필드와 hide 전이 메서드(setter 금지, markBlinded 선례), + EventLocation에 참여 속성 6필드, OrgEmailChangeRequest에 processedAt과 rejectReason + 필드를 추가한다. EventOccurrenceRepository에는 D-9 직렬화용 `@Lock(PESSIMISTIC_WRITE)` + 잠금 조회를, UserRepository에는 D-13의 email 단일 컬럼 `@Modifying` UPDATE를 신설한다. + +## 계약 변경 + +`GridQueryService`, `HotZoneService`, `UserGridQueryService`, `UserOidcCommandService` +시그니처 전부 불변이다. + +**FE 계약 추가 1건**: 회차 상세의 위치 목록 항목(`EventLocationResponseDto`)에 참여 속성 +필드 5종(organizerName, description, participationStartsOn·participationEndsOn, +participationMethod, imageUrl)이 nullable로 늘어난다(D-8 노출 계약). 시드 위치는 전부 +null이라 기존 소비는 깨지지 않고, 참여형 위치를 그리는 화면(FE 레인)이 이 필드를 소비한다. + +Owner B 내부 신설이 하나 있다: mission 패키지에 `MissionRegistrationService` +(register, hide)를 만들어 event 쪽 승인 서비스가 미션을 직접 쓰지 않고 이 인터페이스를 +거친다. 도메인 간 접점 인터페이스 원칙의 적용이지만 A와 B 사이 계약이 아니므로 상대 팀 +확인 대상은 아니다. 기존 `MissionQueryService`에는 스냅숏 무효화 메서드가 하나 늘어난다 +(D-12): B 내부 인터페이스라 역시 상대 팀 확인 대상은 아니다. + +## Owner 판정 근거 + +건드리는 패키지가 `event.*`(submission 심사, 위치 생성, 조회 필터), `mission.*`(등재 서비스, +숨김 필터), `user.*`(기관명·이메일 읽기, 아이디 변경 심사), `global.mail`(재사용)로 전부 +CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryService.resolveRegionNames` +읽기 재사용뿐이라 계약 변경이 없고, 따라서 공동이 아니라 **B 단독**이다. + +## 테스트 시나리오 + +접수(MSG-498) 테스트 픽스처를 재사용한다. 통합 테스트는 공유 로컬 DB 정리 규칙을 따른다. + +승인, 미션 경로: +- `축제_신청을_승인하면_축제_칩_미션이_생기고_활성_조회에_바로_잡힌다` (미션의 type이 + MissionType.EVENT — 신청 type 아님, "용어 주의" 절 참조. source ORG_SUBMISSION, + sourceKey=submissionNo, 기간 KST 경계 변환값 검증) +- `팝업_신청을_승인하면_팝업_미션이_생긴다` (type POPUP, operationTime 이관) +- `승인_미션의_격자는_전_위치_셀_합집합이고_대표_격자는_합집합_재적용_값이다` +- `승인하면_신청이_승인됨_상태가_되고_승인_번호와_이력이_함께_남는다` (한 트랜잭션 검증: + 미션 저장을 실패시키면 상태와 이력도 롤백) +- `같은_신청을_동시에_두_번_승인하면_한_번만_반영되고_늦은_쪽은_13450이다` +- `종료일이_지난_신청의_승인은_13451이다` +- `승인_이미지는_공개_프리픽스로_복사되고_롤백되면_사본이_정리된다` +- `승인_커밋_후_미션_스냅숏이_무효화되어_TTL_전에도_목록에_실린다` (D-12, 캐시 선적재 후 검증) + +승인, 참여형 경로 (MSG-502 머지 후): +- `참여형_신청을_승인하면_부모_회차_아래_위치와_격자가_생기고_회차_노출_영역이_넓어진다` +- `참여_속성이_신청_행에서_모든_위치_행으로_복사되어_위치_목록_응답에_실린다` (6필드 매핑, + imageUrl 공개 주소 조립 포함) +- `위치가_여럿이면_이름이_순번_접미사를_포함해_100자_안으로_잘린다` (100자 title 경계) +- `부모_회차의_기존_위치와_격자가_겹치면_13452로_거부된다` +- `같은_신청의_신규_위치끼리_격자가_겹쳐도_삽입_전_13452로_거부된다` (D-9 상호 대조) +- `같은_회차를_노리는_두_신청의_동시_승인은_직렬화되어_늦은_쪽이_13452다` (D-9 회차 잠금) +- `노출_영역_확장은_일정_개정_번호를_올리지_않는다` (변경 알림 미발송) +- `시드_위치의_참여_속성_필드는_전부_null이다` (기존 화면 불변 확인) + +반려: +- `반려는_항목_코드와_서술이_이력_반려_행으로_남고_상태가_반려됨이_된다` +- `항목_코드가_비었거나_허용_밖이면_13454다` +- `심사_중이_아닌_신청의_반려는_13450이다` + +심사 큐, 승인 행사 목록: +- `심사_큐는_상태_필터와_무관한_건수_3종을_함께_준다` +- `심사_상세는_노출_영역_경계_사각형과_신청_계정_정보를_준다` +- `승인_행사_목록의_탭_상태는_KST_오늘과_기간으로_파생된다` (경계일 포함 검증) +- `중지된_행사는_파생_탭에_남고_unpublished가_참이다` + +노출 중지: +- `중지하면_미션이_활성_조회와_스탬프_판정에서_사라진다` (hidden_at 필터) +- `중지된_미션은_격자_선택_조회에서도_빠진다` (대표 격자 역조회 필터) +- `중지된_미션의_영상_피드는_미션_상세와_같은_404다` (findMissionVideos 계열 필터, + 빈 200이 아니라 404 수렴) +- `중지_커밋_후_미션_스냅숏이_무효화되어_TTL_전에도_목록에서_빠진다` (D-12) +- `중지하면_참여형_위치가_회차_상세와_격자_조회와_업로드_확인에서_사라진다` +- `중지하면_부모_회차_노출_영역이_남은_가시_위치_범위로_줄어든다` (불변식 복원, + 개정 번호 불변) +- `중지된_위치의_영상은_알던_videoId로도_상세와_댓글과_도움돼요가_404다` (직접 접근 차단) +- `이미_완료한_사용자의_진행도와_스탬프는_중지_후에도_남는다` +- `중지_사유가_공식_이메일로_발송되고_실패해도_중지는_유지되며_emailSent가_거짓이다` +- `이미_중지된_행사의_재중지는_13453이다` +- `미승인_신청_id의_중지는_13430이다` + +아이디 변경 요청 심사: +- `승인하면_요청이_승인됨이_되고_로그인_이메일이_요청값으로_바뀐다` (한 트랜잭션 검증) +- `검토_후_재제출된_요청의_승인과_반려는_1429로_거부된다` (requestedAt 원자 비교) +- `요청_이메일이_이미_다른_계정에_있으면_1409로_거부된다` +- `같은_이메일을_노리는_두_요청의_동시_승인은_늦은_쪽이_1409다` (단일 컬럼 UPDATE의 + 유니크 위반 즉시 번역, 500 아님) +- `승인은_email_컬럼만_갱신해_동시_비밀번호_변경을_되돌리지_않는다` (전 컬럼 UPDATE 배제) +- `반려는_사유가_저장되고_이메일은_바뀌지_않는다` +- `이미_처리된_요청의_재처리는_1428이다` +- `처리_후_같은_계정이_다시_접수할_수_있다` (부분 유니크 해제 확인) +- `승인_통지_메일이_새_이메일로_발송되고_실패해도_승인은_유지된다` + +## 미해결 질문 + +- [ ] **[관리자 5] 시안의 목록 필드 실측**: 승인 행사 목록 아이템의 필드 세트를 시안 최소로 + 잡았다(레포 루트 event-v2-prompts.md "ver 14 판독 결과" 대조 필요). 이미지 썸네일이나 위치 + 수 등 화면이 더 요구하는 재료가 있으면 필드 추가는 계약 추가라 FE 레인과 맞춰야 한다. +- [ ] **중지 해제(재노출) 버튼의 시안 유무**: 이번 범위는 단방향 중지다. 시안에 해제가 있으면 + 후속 티켓이 필요하다(스키마는 NULL 세팅으로 이미 열려 있다). +- [ ] **공개 프리픽스 버킷 정책**: `missions/org-submission/`과 + `event-locations/org-submission/` 공개 읽기가 기존 버킷 정책(`missions/*` 여부)에 이미 + 포함되는지 dev에서 실측해야 한다. 미포함이면 정책 갱신 런북 한 줄이 배포 전 선행된다. +- [ ] **아이디 변경 요청 큐의 관리자 화면 자리**: ver 14 시안의 관리자 화면에 이 큐가 그려져 + 있는지 확인이 필요하다(FR-23이 Should라 시안 미반영 가능성이 있다). 서버 API는 이 스펙으로 + 확정하고, 화면이 없으면 FE 레인에 신규 화면 요청이 따라간다. +- [ ] **참여형 공개 기간의 노출 창 해석**: 이 스펙은 starts_on·ends_on을 표기 정보로만 쓴다 + (위치 노출은 부모 회차 생명주기와 hidden_at이 지배). 공개 기간이 지나면 위치를 자동으로 + 내리는 해석이 필요한지는 PRD v2.2가 확정하지 않았다. 필요해지면 위치 상태 판정 축이 + 하나 늘어나는 변경이라 별도 결정을 거친다. + +[^1]: 노출 영역(exposure): 지도 뷰포트와의 겹침 판정에 쓰는 격자 경계 사각형. 행사 위치 + 사각형들을 전부 감싸는 범위로, 상단 행사 칩이 어느 지역 화면에서 보일지를 정한다. +[^2]: 회차 내 격자 단일 귀속: 한 격자는 한 회차 안에서 위치 하나에만 속한다는 규칙. + DB의 지연 제약(DEFERRABLE, 문장마다가 아니라 커밋 시점에 검증하는 제약)으로 강제되어, + 위반은 커밋 순간에야 터진다. 그래서 이 스펙은 관리자에게 읽히는 에러를 주기 위해 + 같은 검사를 애플리케이션에서 먼저 한다. +[^3]: 부분 유니크 인덱스: 조건을 만족하는 행에만 걸리는 유니크 제약. missions에는 + (source, source_key) 쌍이 있는 행끼리만 중복을 막는 인덱스가 이미 있어, 승인 미션에 + submissionNo를 키로 실으면 같은 신청의 미션 이중 생성이 DB 수준에서 막힌다. +[^4]: 대표 격자: 위치나 미션의 영상이 실제로 연결되는 격자 하나. 홀수 행렬 직사각형이면 + 정중앙, 아니면 영역 중심에 가장 가까운 포함 격자를 계산하는 3단 규칙 + (`RepresentativeGridResolver`)이 행사와 미션 양쪽의 공용 정본이다. +[^5]: 상태 전이 메서드: setter를 열지 않고 의미 있는 동작 이름으로 필드를 바꾸는 엔티티 + 메서드. JPA 더티 체킹이 커밋 시점에 UPDATE를 내보내므로 별도 UPDATE문 없이 성립한다 + (Video.markBlinded 선례). +[^6]: 비관적 잠금(PESSIMISTIC_WRITE): 행을 읽는 시점에 SELECT FOR UPDATE로 배타 잠금을 + 걸어, 같은 행을 잠그려는 다른 트랜잭션을 커밋까지 대기시키는 방식. 겹침 검사와 삽입 + 사이에 다른 승인이 끼어드는 경합을 트랜잭션 순서로 직렬화한다. From f7bf219ddf86eb28c5440a173bc3500b6dc1301f Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:10:15 +0900 Subject: [PATCH 02/35] =?UTF-8?q?MSG-500=20feat:=20=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=ED=9D=94=EC=A0=81=EA=B3=BC=20=EC=88=A8=EA=B9=80=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EC=9D=84=20=EB=8B=B4=EB=8A=94=20V51=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=EC=99=80=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=99=95=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../submission/entity/EventSubmission.java | 27 ++ .../msg/fillmap/mission/entity/Mission.java | 8 + .../user/entity/OrgEmailChangeRequest.java | 11 + .../V51__event_submission_review.sql | 45 +++ .../EventSubmissionControllerTest.java | 4 +- .../EventSubmissionReviewSchemaTest.java | 260 ++++++++++++++++++ 6 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V51__event_submission_review.sql create mode 100644 src/test/java/com/msg/fillmap/event/submission/repository/EventSubmissionReviewSchemaTest.java diff --git a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java index bc97d738..b4196292 100644 --- a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmission.java @@ -89,6 +89,24 @@ public class EventSubmission { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + /** + * 심사 결과 4종 (MSG-500 V51). 승인 번호와 상태 전이는 조건부 UPDATE 가 한 문장으로 채우고(동시 심사 + * 직렬화), 여기서는 그 결과를 읽는다 — 그래서 approve 전이 메서드가 없다. + * chk_event_sub_approval 이 "승인 행 = 승인 번호 있는 행"을 DB 에서 강제한다. + */ + @Column(name = "approval_no", length = 20, unique = true) + private String approvalNo; + + /** 승인 산출물 링크 — 미션 경로에서만 값을 갖는다. 참여형은 위치가 여러 행이라 locationKey 접두로 역산한다. */ + @Column(name = "published_mission_id") + private Long publishedMissionId; + + @Column(name = "unpublished_at") + private LocalDateTime unpublishedAt; + + @Column(name = "unpublish_reason") + private String unpublishReason; + // 컬렉션 매핑의 근거(컨벤션 영속 계층 2항): 신청은 폼 하나로 통째로 만들어졌다 통째로 교체되는 // 애그리거트이고 위치 수 상한이 20이라(D-10) 크기가 분명하다. 연관 주인은 FK 를 가진 자식 쪽 // @ManyToOne 이고 여기는 mappedBy 읽기 쪽이다 — 그래야 자식 INSERT 한 번으로 FK 가 채워진다. @@ -142,4 +160,13 @@ public void replaceLocations(List newLocations) { locations.add(location); } } + + /** + * 승인 산출물 링크 기록 (MSG-500 D-2) — 상태 전이는 조건부 UPDATE 가 이미 끝냈고, 그 뒤 재로드한 + * 애그리거트에 미션 id 를 달아 더티 체킹이 UPDATE 를 내보낸다 (setter 를 열지 않는 상태 전이 메서드, + * {@code Video.markBlinded()} 선례). + */ + public void linkPublishedMission(Long missionId) { + this.publishedMissionId = missionId; + } } diff --git a/src/main/java/com/msg/fillmap/mission/entity/Mission.java b/src/main/java/com/msg/fillmap/mission/entity/Mission.java index 3627e4a6..34b3555e 100644 --- a/src/main/java/com/msg/fillmap/mission/entity/Mission.java +++ b/src/main/java/com/msg/fillmap/mission/entity/Mission.java @@ -83,6 +83,14 @@ public class Mission { @Column(name = "representative_grid_id", length = 20) private String representativeGridId; + /** + * 노출 중지 시각 (V51, MSG-500 D-3). NULL = 노출 중이고, 값이 있으면 사용자 대면 조회·스탬프 판정에서 + * 빠진다. 시드 미션은 전부 NULL 이라 기존 동작이 불변이다. 삭제가 아니라 숨김인 것은 user_missions FK 가 + * 스탬프 걸린 미션의 하드삭제를 막고, 이미 받은 스탬프를 뺏는 것이 비회수 원칙과 충돌하기 때문이다. + */ + @Column(name = "hidden_at") + private LocalDateTime hiddenAt; + /** insertable=false — DB DEFAULT(CURRENT_TIMESTAMP) 위임. save 직후엔 null, 재조회 시 채워진다(MSG-224). */ @Column(name = "created_at", nullable = false, insertable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/com/msg/fillmap/user/entity/OrgEmailChangeRequest.java b/src/main/java/com/msg/fillmap/user/entity/OrgEmailChangeRequest.java index baeecf3e..c700766d 100644 --- a/src/main/java/com/msg/fillmap/user/entity/OrgEmailChangeRequest.java +++ b/src/main/java/com/msg/fillmap/user/entity/OrgEmailChangeRequest.java @@ -45,4 +45,15 @@ public class OrgEmailChangeRequest { /** 접수 시각(UTC). 쓰기 경로가 native UPSERT 하나라 서비스가 주입받은 Clock 값을 바인딩한다. */ @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; + + /** + * 심사 처리 시각·반려 사유 (V51, MSG-500 D-13). 전이는 조건부 UPDATE 한 문장이 상태와 함께 채우므로 + * 여기서는 관리자 큐가 읽기만 한다. 두 컬럼의 CHECK 가 "PENDING 이면 처리 시각 없음"과 + * "반려면 사유 있음"을 DB 에서 강제한다. + */ + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "reject_reason") + private String rejectReason; } diff --git a/src/main/resources/db/migration/V51__event_submission_review.sql b/src/main/resources/db/migration/V51__event_submission_review.sql new file mode 100644 index 00000000..39b7577d --- /dev/null +++ b/src/main/resources/db/migration/V51__event_submission_review.sql @@ -0,0 +1,45 @@ +-- 행사 등재 심사 (MSG-500): 승인 번호와 산출물 링크, 노출 중지 기록, 아이디 변경 요청 처리 흔적. +-- V50 은 MSG-502(참여형 확장) 예약이라 이 티켓은 V51 을 쓴다. + +CREATE SEQUENCE event_submission_approval_no_seq; + +ALTER TABLE event_submissions + ADD COLUMN approval_no VARCHAR(20) UNIQUE, -- "APR-2026-0001", 승인 시 부여 + ADD COLUMN published_mission_id BIGINT REFERENCES missions(id), -- NO ACTION: 링크 고아 차단 + ADD COLUMN unpublished_at TIMESTAMP, -- UTC (MSG-376 체계) + ADD COLUMN unpublish_reason TEXT; + +-- 미승인 행에 승인 흔적이 남지 않는다. 승인 행은 승인 번호가 반드시 있다. +ALTER TABLE event_submissions + ADD CONSTRAINT chk_event_sub_approval + CHECK ((status = 'APPROVED') = (approval_no IS NOT NULL)), + ADD CONSTRAINT chk_event_sub_unpublish_pair + CHECK ((unpublished_at IS NULL) = (unpublish_reason IS NULL)), + ADD CONSTRAINT chk_event_sub_unpublish_approved + CHECK (unpublished_at IS NULL OR status = 'APPROVED'); + +-- 노출 숨김 (D-3). NULL = 노출 중, 기존 행 전부 NULL 이라 시드 산출물 동작 불변. +ALTER TABLE missions ADD COLUMN hidden_at TIMESTAMP; +ALTER TABLE event_locations ADD COLUMN hidden_at TIMESTAMP; + +-- 참여형 승인분의 참여 속성 (D-8 복사 매핑, PRD v2.2 확정 1 "위치에 붙는 부가 정보"). +-- 전부 nullable: 시드 위치는 전부 NULL 이라 기존 행사 조회 동작이 불변이다. +ALTER TABLE event_locations + ADD COLUMN organizer_name VARCHAR(100), + ADD COLUMN description TEXT, + ADD COLUMN starts_on DATE, -- 공개 기간 표기 정보 (KST 날짜 라벨) + ADD COLUMN ends_on DATE, + ADD COLUMN participation_method TEXT, + ADD COLUMN image_key VARCHAR(255); -- 공개 프리픽스 사본 키 (D-6) + +-- 아이디 변경 요청 심사 결과 (D-13). MSG-497 접수 스키마에는 처리 흔적 자리가 없었다. +ALTER TABLE org_email_change_requests + ADD COLUMN processed_at TIMESTAMP, + ADD COLUMN reject_reason TEXT; +ALTER TABLE org_email_change_requests + ADD CONSTRAINT chk_org_email_change_processed + CHECK ((status = 'PENDING') = (processed_at IS NULL)), + -- 반려면 사유 필수, 반려 아니면 사유 없음. 양방향 동치식이어야 한다: 반쪽 OR 식은 + -- 반려 행의 사유 NULL 을 통과시킨다. + ADD CONSTRAINT chk_org_email_change_reject_reason + CHECK ((status = 'REJECTED') = (reject_reason IS NOT NULL)); diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java index e04b8014..59679665 100644 --- a/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/controller/EventSubmissionControllerTest.java @@ -159,9 +159,11 @@ private String bearer(User user) { entityManager.clear(); } + /** 승인 번호를 함께 넣는다 — V51 의 chk_event_sub_approval 이 "승인 행 = 승인 번호 있는 행"을 강제한다(MSG-500). */ private void 승인한다(long submissionId) { entityManager.flush(); - jdbcTemplate.update("UPDATE event_submissions SET status = 'APPROVED' WHERE id = ?", submissionId); + jdbcTemplate.update("UPDATE event_submissions SET status = 'APPROVED', approval_no = ? WHERE id = ?", + "APR-2026-" + submissionId, submissionId); entityManager.clear(); } diff --git a/src/test/java/com/msg/fillmap/event/submission/repository/EventSubmissionReviewSchemaTest.java b/src/test/java/com/msg/fillmap/event/submission/repository/EventSubmissionReviewSchemaTest.java new file mode 100644 index 00000000..24bf2e58 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/repository/EventSubmissionReviewSchemaTest.java @@ -0,0 +1,260 @@ +package com.msg.fillmap.event.submission.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +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.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import com.msg.fillmap.user.entity.OrgEmailChangeRequest; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.OrgEmailChangeRequestRepository; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 심사 스키마 V51 (MSG-500, 실 DB). 검증 대상이 전부 CHECK 제약이라 엔티티만 봐서는 성립하지 않는다 — + * "승인 행에만 승인 번호", "중지 기록은 짝으로", "반려 행에만 사유"는 DB 가 강제하는 성질이고, 이 뒤의 + * 모든 심사 로직이 그 강제를 백스톱으로 깔고 짜인다. + *

+ * 위반 검사는 한 테스트에 하나씩 둔다 — PostgreSQL 은 제약 위반이 트랜잭션을 통째로 중단시켜, 같은 + * 트랜잭션에서 두 번째 문장을 실행할 수 없다. {@code @Transactional} 롤백 격리로 공유 로컬 DB 에 + * 계정·신청을 남기지 않는다 (OrgAccountRequestPersistenceTest 선례). + */ +@SpringBootTest +@Transactional +@DisplayName("행사 등재 심사 스키마 (V51, 실 DB)") +class EventSubmissionReviewSchemaTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private OrgEmailChangeRequestRepository emailChangeRequestRepository; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EntityManager entityManager; + + private User organizer; + + @BeforeEach + void setUp() { + organizer = saveOrganizer(); + } + + 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 LocalDateTime now() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** 신청 1건을 SQL 로 심는다 — 접수 경로(MSG-498)를 타지 않고 스키마만 보는 테스트라서다. */ + private long 신청을_심는다(String status, String approvalNo) { + String submissionNo = "FM-2026-" + UUID.randomUUID().toString().substring(0, 8); + jdbcTemplate.update(""" + INSERT INTO event_submissions + (submission_no, user_id, type, status, title, organizer_name, starts_on, ends_on, + program_description, description, image_key, created_at, updated_at, approval_no) + VALUES (?, ?, 'FESTIVAL', ?, '부산불꽃축제', '부산문화관광축제조직위원회', ?, ?, + '멀티불꽃쇼', '광안리 일원에서 열리는 부산 대표 불꽃 축제', 'event-submissions/original/1/a.jpg', + ?, ?, ?) + """, submissionNo, organizer.getId(), status, LocalDate.of(2026, 11, 7), LocalDate.of(2026, 11, 7), + now(), now(), approvalNo); + return jdbcTemplate.queryForObject( + "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); + } + + private long 접수한_아이디_변경_요청() { + emailChangeRequestRepository.upsertPending(organizer.getId(), "new-" + UUID.randomUUID() + "@fillmap.dev", + now()); + entityManager.clear(); + return emailChangeRequestRepository.findAllByUserId(organizer.getId()).get(0).getId(); + } + + @Nested + @DisplayName("승인 흔적") + class ApprovalTrace { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인 번호 없는 승인 행은 저장되지 않는다") + void 승인_번호_없는_승인_행은_저장되지_않는다() { + assertThatThrownBy(() -> 신청을_심는다("APPROVED", null)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_event_sub_approval"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("심사 중인 신청에는 승인 번호가 붙지 않는다") + void 심사_중인_신청에는_승인_번호가_붙지_않는다() { + assertThatThrownBy(() -> 신청을_심는다("IN_REVIEW", "APR-2026-0001")) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_event_sub_approval"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인 번호는 신청 사이에서 중복되지 않는다") + void 승인_번호는_신청_사이에서_중복되지_않는다() { + 신청을_심는다("APPROVED", "APR-2026-9001"); + + assertThatThrownBy(() -> 신청을_심는다("APPROVED", "APR-2026-9001")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인된 신청은 승인 번호와 산출물 링크를 엔티티로 읽는다") + void 승인된_신청은_승인_번호와_산출물_링크를_엔티티로_읽는다() { + long id = 신청을_심는다("APPROVED", "APR-2026-9002"); + entityManager.clear(); + + assertThat(submissionRepository.findById(id)).get() + .satisfies(submission -> { + assertThat(submission.getApprovalNo()).isEqualTo("APR-2026-9002"); + assertThat(submission.getPublishedMissionId()).isNull(); + assertThat(submission.getUnpublishedAt()).isNull(); + assertThat(submission.getUnpublishReason()).isNull(); + }); + } + } + + @Nested + @DisplayName("노출 중지 기록") + class UnpublishTrace { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("중지 시각과 사유는 짝으로만 저장된다") + void 중지_시각과_사유는_짝으로만_저장된다() { + long id = 신청을_심는다("APPROVED", "APR-2026-9003"); + + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE event_submissions SET unpublished_at = ? WHERE id = ?", now(), id)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_event_sub_unpublish_pair"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인되지 않은 신청은 중지 기록을 가질 수 없다") + void 승인되지_않은_신청은_중지_기록을_가질_수_없다() { + long id = 신청을_심는다("IN_REVIEW", null); + + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE event_submissions SET unpublished_at = ?, unpublish_reason = ? WHERE id = ?", + now(), "행사가 취소되었습니다", id)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_event_sub_unpublish_approved"); + } + } + + @Nested + @DisplayName("확장 컬럼") + class AddedColumns { + + /** + * 컬럼 이름 오타를 잡는 유일한 자리다 — 없는 컬럼을 물으면 쿼리 자체가 실패한다. 값 단언("아직 아무도 + * 안 채웠다")은 이 6종을 쓰는 코드가 M6 뿐이라 성립하고, 표가 비어 있어도 COUNT 0 이라 공유 로컬 DB 의 + * 데이터 유무에 끌려가지 않는다. + */ + // 검증: FR-EVENT-15 + @Test + @DisplayName("행사 위치의 참여 속성 6종은 아직 어느 행에도 채워지지 않았다") + void 행사_위치의_참여_속성은_아직_어느_행에도_없다() { + assertThat(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM event_locations + WHERE organizer_name IS NOT NULL OR description IS NOT NULL + OR starts_on IS NOT NULL OR ends_on IS NOT NULL + OR participation_method IS NOT NULL OR image_key IS NOT NULL + """, Long.class)).isZero(); + } + } + + @Nested + @DisplayName("아이디 변경 요청 처리 흔적") + class EmailChangeTrace { + + // 검증: FR-USER-16 + @Test + @DisplayName("접수된 요청은 처리 시각도 반려 사유도 없다") + void 접수된_요청은_처리_시각도_반려_사유도_없다() { + long requestId = 접수한_아이디_변경_요청(); + + OrgEmailChangeRequest request = emailChangeRequestRepository.findById(requestId).orElseThrow(); + assertThat(request.getProcessedAt()).isNull(); + assertThat(request.getRejectReason()).isNull(); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("처리된 요청에는 처리 시각이 반드시 있다") + void 처리된_요청에는_처리_시각이_반드시_있다() { + long requestId = 접수한_아이디_변경_요청(); + + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE org_email_change_requests SET status = 'APPROVED' WHERE id = ?", requestId)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_org_email_change_processed"); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("반려 사유 없는 반려 행은 저장되지 않는다") + void 반려_사유_없는_반려_행은_저장되지_않는다() { + long requestId = 접수한_아이디_변경_요청(); + + assertThatThrownBy(() -> jdbcTemplate.update( + "UPDATE org_email_change_requests SET status = 'REJECTED', processed_at = ? WHERE id = ?", + now(), requestId)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_org_email_change_reject_reason"); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("승인 행에는 반려 사유가 붙지 않는다") + void 승인_행에는_반려_사유가_붙지_않는다() { + long requestId = 접수한_아이디_변경_요청(); + + assertThatThrownBy(() -> jdbcTemplate.update(""" + UPDATE org_email_change_requests + SET status = 'APPROVED', processed_at = ?, reject_reason = '사유' + WHERE id = ? + """, now(), requestId)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("chk_org_email_change_reject_reason"); + } + } +} From ddc7402e13e5b12e27c8c38430bbf6b5fc4de94a Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:10:26 +0900 Subject: [PATCH 03/35] =?UTF-8?q?MSG-500=20feat:=20=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=C2=B7=EB=85=B8=EC=B6=9C=20=EC=A4=91=EC=A7=80=EC=9A=A9=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=207=EC=A2=85=EC=9D=84?= =?UTF-8?q?=20event=20=EB=8C=80=EC=97=AD=EC=97=90=20=EC=8B=A0=EC=84=A4?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13450~13454는 스펙 표, 13455·13456은 목록 검증용. user 대역 1424·1425를 event 컨트롤러가 내지 않기 위한 신설이고 13440대는 MSG-502 예약이라 피했다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../msg/fillmap/event/exception/EventErrorCode.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java b/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java index c1c1f45f..0a48221f 100644 --- a/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java +++ b/src/main/java/com/msg/fillmap/event/exception/EventErrorCode.java @@ -57,6 +57,17 @@ public enum EventErrorCode implements ErrorCodeIfs { SUBMISSION_IMAGE_UNSUPPORTED(13437, HttpStatus.UNSUPPORTED_MEDIA_TYPE, "jpg, png 이미지만 올릴 수 있습니다"), SUBMISSION_IMAGE_TOO_LARGE(13438, HttpStatus.PAYLOAD_TOO_LARGE, "이미지는 최대 10MB 입니다"), SUBMISSION_REQUIRED_FIELD_MISSING(13439, HttpStatus.BAD_REQUEST, "등록 유형에 필요한 항목이 올바르지 않습니다"), + + // 관리자 심사 블록 (MSG-500). 13440 대는 MSG-502(참여형) 예약이라 13450 부터 쓴다. + // 목록 파라미터 두 코드는 판정과 메시지가 MSG-499 관리자 큐(1424·1425)와 같고 대역만 event 다 — + // 도메인 예외는 도메인이 갖는다는 규칙(뷰포트 두 코드가 mission·grid 와 같은 판정에 다른 대역인 것과 같은 결). + SUBMISSION_STATUS_NOT_REVIEWABLE(13450, HttpStatus.CONFLICT, "심사 중인 신청만 승인하거나 반려할 수 있습니다"), + SUBMISSION_PERIOD_PASSED(13451, HttpStatus.CONFLICT, "행사 기간이 이미 지나 승인할 수 없습니다"), + SUBMISSION_GRID_CONFLICT(13452, HttpStatus.CONFLICT, "신청 영역이 부모 이벤트의 다른 위치와 겹칩니다"), + EVENT_ALREADY_UNPUBLISHED(13453, HttpStatus.CONFLICT, "이미 노출이 중지된 행사입니다"), + INVALID_REJECT_REASON(13454, HttpStatus.BAD_REQUEST, "반려 항목 코드가 올바르지 않습니다"), + INVALID_SUBMISSION_STATUS_FILTER(13455, HttpStatus.BAD_REQUEST, "지원하지 않는 상태 필터입니다"), + INVALID_PAGE_RANGE(13456, HttpStatus.BAD_REQUEST, "페이지 번호 또는 크기가 유효하지 않습니다"), ; private final Integer errorCode; From cbcf2d4f1b4f540d73926f241b166f0cc0d4697a Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:10:26 +0900 Subject: [PATCH 04/35] =?UTF-8?q?MSG-500=20refactor:=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=EC=9D=84=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EB=B7=B0=EB=A1=9C=20=EC=B6=94=EC=B6=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 상세와 신청자 상세가 같은 영역 그림을 보게 하기 위한 이동이고 로직 변경은 없다. 읽기 경로의 재검증(81칸 상한)은 제거됐다 — 저장 데이터는 접수 검증을 이미 통과했다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../service/EventSubmissionLocationView.java | 80 +++++++++++++++++++ .../service/EventSubmissionServiceImpl.java | 55 ++----------- .../EventSubmissionValidationTest.java | 4 +- 3 files changed, 87 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java new file mode 100644 index 00000000..bf5f09be --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java @@ -0,0 +1,80 @@ +package com.msg.fillmap.event.submission.service; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionLocationResponseDto; +import com.msg.fillmap.event.submission.entity.EventSubmissionAreaRect; +import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; +import com.msg.fillmap.global.geo.AreaCell; +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 §API 4 · MSG-500 §API 2). 행사 운영자 상세와 관리자 + * 심사 상세가 같은 위치 표현을 줘야 해서(심사자가 보는 영역과 신청자가 보는 영역이 다르면 반려 사유가 + * 서로 다른 그림을 가리킨다) 조립을 두 벌로 두지 않는다. + *

+ * 표시명 재료는 대표 격자 기준으로 서버가 계산해 동봉하고 FE 는 조립만 한다 — 구역은 요청당 리졸버 1회로 + * 순수 계산하고(MSG-341 계약), 행정동은 대표 격자를 한 번에 넘겨 받는다(위치마다 묻지 않는다). + */ +@Component +@RequiredArgsConstructor +public class EventSubmissionLocationView { + + private final ZoneNameQueryService zoneNameQueryService; + private final GridQueryService gridQueryService; + + public List describe(List locations) { + 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()); + dtos.add(new EventSubmissionLocationResponseDto( + location.getDisplayOrder(), + gridId, + zone.zoneName(), + zone.zoneCell(), + regionNames.get(gridId), + cellCount(location.getRects()), + location.getRects().stream().map(EventSubmissionAreaRectDto::from).toList())); + } + return dtos; + } + + /** + * 합집합 칸 수 (D-7) — 겹치는 사각형을 두 번 세지 않는다. 저장된 사각형은 접수 검증을 이미 통과했으므로 + * (위치당 81칸 상한·인덱스 범위) 여기서 다시 검증하지 않고 세기만 한다. + */ + private int cellCount(List rects) { + Set cells = new LinkedHashSet<>(); + for (EventSubmissionAreaRect rect : rects) { + for (int gridY = rect.getMinGridY(); gridY <= rect.getMaxGridY(); gridY++) { + for (int gridX = rect.getMinGridX(); gridX <= rect.getMaxGridX(); gridX++) { + cells.add(new AreaCell(gridY, gridX)); + } + } + } + return cells.size(); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java index dccd5bbd..3cecd86b 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionServiceImpl.java @@ -24,7 +24,6 @@ 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; @@ -41,12 +40,6 @@ 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규칙(영역·기간·유형별 항목)과 대표 격자 계산이 제출과 재제출에서 @@ -72,27 +65,24 @@ public class EventSubmissionServiceImpl implements EventSubmissionService { private final EventSubmissionRepository submissionRepository; private final EventSubmissionStatusHistoryRepository historyRepository; private final EventSubmissionImageStore imageStore; - private final ZoneNameQueryService zoneNameQueryService; - private final GridQueryService gridQueryService; + private final EventSubmissionLocationView locationView; 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()); + EventSubmissionLocationView locationView) { + this(submissionRepository, historyRepository, imageStore, locationView, Clock.systemUTC()); } public EventSubmissionServiceImpl(EventSubmissionRepository submissionRepository, EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, - ZoneNameQueryService zoneNameQueryService, GridQueryService gridQueryService, Clock clock) { + EventSubmissionLocationView locationView, Clock clock) { this.submissionRepository = submissionRepository; this.historyRepository = historyRepository; this.imageStore = imageStore; - this.zoneNameQueryService = zoneNameQueryService; - this.gridQueryService = gridQueryService; + this.locationView = locationView; this.clock = clock; } @@ -155,7 +145,7 @@ public EventSubmissionDetailResponseDto getSubmission(Long userId, Long submissi submission.getProgramDescription(), submission.getDescription(), imageStore.presignGet(submission.getImageKey()), - toLocationDtos(submission), + locationView.describe(submission.getLocations()), toRejection(submission, history), history.stream().map(EventSubmissionHistoryResponseDto::from).toList(), submission.getUpdatedAt()); @@ -311,39 +301,6 @@ private String nextSubmissionNo() { 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) — 신청 행에 중복 저장하지 않는다. *

diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java index 3a31fd97..af16e90f 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionValidationTest.java @@ -36,8 +36,6 @@ 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 가 필요 없는 순수 판정이라 목으로 짠다 — @@ -64,7 +62,7 @@ void setUp() { given(imageStore.confirm(anyLong(), anyString())).willReturn("event-submissions/original/42/a.jpg"); service = new EventSubmissionServiceImpl(submissionRepository, mock(EventSubmissionStatusHistoryRepository.class), imageStore, - mock(ZoneNameQueryService.class), mock(GridQueryService.class), KST_NEW_DAY); + mock(EventSubmissionLocationView.class), KST_NEW_DAY); } private EventSubmissionCreateRequestDto festival() { From 61eea77f59cb2909ac104901ad1c5fb06e0b8893 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:10:37 +0900 Subject: [PATCH 05/35] =?UTF-8?q?MSG-500=20feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=8B=AC=EC=82=AC=20=ED=81=90=EC=99=80=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=83=81=EC=84=B8=EB=A5=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../AdminEventSubmissionController.java | 71 +++++ ...AdminEventSubmissionDetailResponseDto.java | 80 +++++ .../AdminEventSubmissionItemResponseDto.java | 59 ++++ .../AdminEventSubmissionListResponseDto.java | 41 +++ .../repository/EventSubmissionRepository.java | 35 +++ .../service/AdminEventSubmissionService.java | 138 +++++++++ .../AdminEventSubmissionControllerTest.java | 277 ++++++++++++++++++ 7 files changed, 701 insertions(+) create mode 100644 src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java create mode 100644 src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionItemResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionListResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java create mode 100644 src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java diff --git a/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java b/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java new file mode 100644 index 00000000..eb411059 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java @@ -0,0 +1,71 @@ +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 org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +import com.msg.fillmap.event.submission.dto.AdminEventSubmissionDetailResponseDto; +import com.msg.fillmap.event.submission.dto.AdminEventSubmissionListResponseDto; +import com.msg.fillmap.event.submission.service.AdminEventSubmissionService; +import com.msg.fillmap.response.SuccessResponse; + +/** + * 관리자 행사 등재 심사 API (MSG-500). ADMIN 검사는 여기에 없다 — SecurityConfig 의 {@code /api/admin/**} + * matcher 가 필터 단계에서 거른다 (AdminOrgAccountController 선례). + */ +@Tag( + name = "관리자 행사 등재 심사 (Admin Event Submission)", + description = "행사 등재 신청을 검토하고 승인·반려하는 API (MSG-500). ADMIN 권한 필수." +) +@RestController +@RequestMapping("/api/admin/event-submissions") +@RequiredArgsConstructor +public class AdminEventSubmissionController { + + private final AdminEventSubmissionService adminEventSubmissionService; + + @Operation( + summary = "심사 큐 조회", + description = "상태 필터 기준으로 신청을 접수 최신순 페이지 단위로 조회한다. 기본은 심사 중(IN_REVIEW) " + + "신청이다. 상태별 건수 3종이 필터와 무관하게 함께 실려 탭 뱃지를 그릴 수 있다.\n\n" + + "항목의 organizerName 은 신청 폼의 주최 기관이고 orgName 은 신청 계정에 등록된 기관명이라, 둘이 " + + "다르면 그 자체가 심사 신호다.\n\n" + + "지원하지 않는 status 는 400(13455), page 음수나 size 범위(1~100) 밖은 400(13456) 이다." + ) + @GetMapping + public SuccessResponse getSubmissions( + @Parameter(description = "신청 상태 필터 (IN_REVIEW, APPROVED, REJECTED — 대소문자 무관)", + example = "IN_REVIEW") + @RequestParam(defaultValue = "IN_REVIEW") String status, + + @Parameter(description = "페이지 번호 (0부터)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기 (1~100)", example = "20") + @RequestParam(defaultValue = "20") int size + ) { + return SuccessResponse.of(adminEventSubmissionService.getSubmissions(status, page, size)); + } + + @Operation( + summary = "심사 상세 조회", + description = "신청 폼 필드 전체(대표 이미지는 presigned GET URL)에 심사 재료를 더해 조회한다 — 신청 " + + "계정 정보, 전 위치를 감싸는 노출 영역 사각형, 상태 이력이다. 노출 영역은 조회 시점 계산값이라 " + + "저장되지 않는다.\n\n" + + "관리자 조회에는 존재 은닉이 없다 — 없는 신청은 그대로 404(13430) 다." + ) + @GetMapping("/{submissionId}") + public SuccessResponse getSubmission( + @Parameter(description = "조회할 신청 id", example = "7") @PathVariable Long submissionId + ) { + return SuccessResponse.of(adminEventSubmissionService.getSubmission(submissionId)); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java new file mode 100644 index 00000000..a9d2b47f --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java @@ -0,0 +1,80 @@ +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-500 §API 2). 행사 운영자 상세(MSG-498)와 같은 폼 필드에 심사 재료 셋을 + * 더한다 — 신청 계정 정보(누가 냈나), 노출 영역 사각형(어디에 깔리나), 상태 이력이다. + *

+ * 존재 은닉이 없다: 없는 id 는 그대로 404 다(관리자는 전체를 보는 주체라 숨길 것이 없다). + */ +@Schema(description = "관리자 심사 상세", + requiredProperties = {"id", "submissionNo", "type", "status", "title", "organizerName", "startsOn", "endsOn", + "operatingHours", "programDescription", "description", "imageUrl", "orgName", "contactName", "email", + "locations", "exposureRect", "history", "createdAt", "updatedAt"}) +public record AdminEventSubmissionDetailResponseDto( + @Schema(description = "신청 id", example = "7") + Long id, + + @Schema(description = "신청 번호", example = "FM-2026-0007") + String submissionNo, + + @Schema(description = "등록 유형", example = "FESTIVAL") + String type, + + @Schema(description = "신청 상태", example = "IN_REVIEW") + 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 = "신청 계정의 기관명", example = "부산광역시 부산진구청", nullable = true) + String orgName, + + @Schema(description = "신청 계정의 담당자 이름", example = "김담당") + String contactName, + + @Schema(description = "신청 계정의 공식 이메일 (로그인 아이디)", example = "event@busanjin.go.kr") + String email, + + @Schema(description = "위치 목록 — 순번 오름차순") + List locations, + + @Schema(description = "전 위치 셀 합집합의 경계 사각형 — 조회 시점 계산이고 저장하지 않는다") + EventSubmissionAreaRectDto exposureRect, + + @Schema(description = "상태 이력 — 발생 순") + List history, + + @Schema(description = "접수 시각 (UTC)", example = "2026-08-28T02:00:00Z") + LocalDateTime createdAt, + + @Schema(description = "마지막 변경 시각 (UTC)", example = "2026-08-28T02:11:00Z") + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionItemResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionItemResponseDto.java new file mode 100644 index 00000000..9d6eab5c --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionItemResponseDto.java @@ -0,0 +1,59 @@ +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.EventSubmissionStatus; +import com.msg.fillmap.event.submission.entity.EventSubmissionType; + +/** + * 관리자 심사 큐의 한 줄 (MSG-500 §API 1). 리포지토리 생성자 프로젝션이 직접 만드는 타입이라 유형·상태가 + * enum 그대로다 — 와이어에는 Jackson 이 상수명을 실어 다른 응답의 문자열과 같은 값이 나간다. + *

+ * 주최 기관({@code organizerName}, 신청 폼 값)과 기관명({@code orgName}, 신청 계정 값)이 둘 다 있는 것은 + * 심사자가 대조해야 하는 두 값이기 때문이다 — 폼에 적힌 주최자와 계정 발급 시 확인된 기관이 다르면 + * 그 자체가 심사 신호다. + */ +@Schema(description = "관리자 심사 큐 항목", + requiredProperties = {"id", "submissionNo", "type", "status", "title", "organizerName", "orgName", "startsOn", + "endsOn", "locationCount", "createdAt", "updatedAt"}) +public record AdminEventSubmissionItemResponseDto( + @Schema(description = "신청 id", example = "7") + Long id, + + @Schema(description = "신청 번호", example = "FM-2026-0007") + String submissionNo, + + @Schema(description = "등록 유형", example = "FESTIVAL") + EventSubmissionType type, + + @Schema(description = "신청 상태", example = "IN_REVIEW") + EventSubmissionStatus status, + + @Schema(description = "축제명 / 팝업명", example = "부산불꽃축제") + String title, + + @Schema(description = "주최 기관 — 신청 폼에 적힌 값", example = "부산문화관광축제조직위원회") + String organizerName, + + @Schema(description = "기관명 — 신청 계정에 등록된 값", example = "부산광역시 부산진구청", nullable = true) + String orgName, + + @Schema(description = "행사 시작일", example = "2026-11-07") + LocalDate startsOn, + + @Schema(description = "행사 종료일", example = "2026-11-07") + LocalDate endsOn, + + @Schema(description = "신청에 담긴 위치 수", example = "2") + int locationCount, + + @Schema(description = "접수 시각 (UTC)", example = "2026-08-28T02:00:00Z") + LocalDateTime createdAt, + + @Schema(description = "마지막 변경 시각 (UTC)", example = "2026-08-28T02:11:00Z") + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionListResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionListResponseDto.java new file mode 100644 index 00000000..192707a0 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionListResponseDto.java @@ -0,0 +1,41 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.springframework.data.domain.Page; + +/** + * 관리자 심사 큐 (MSG-500 §API 1). 관리자 전용 소량 데이터라 커서가 아니라 오프셋 페이징이다 + * (MSG-499 요청 큐 선례). 건수 3종은 상태 필터와 무관한 전체 집계로 화면 탭 뱃지의 재료다. + */ +@Schema(description = "관리자 심사 큐 — 상태 필터 기준 한 페이지와 상태별 전체 건수.", + requiredProperties = {"counts", "totalElements", "page", "size", "submissions"}) +public record AdminEventSubmissionListResponseDto( + @Schema(description = "상태별 전체 건수 (필터와 무관)") + EventSubmissionStatusCountsResponseDto counts, + + @Schema(description = "필터에 해당하는 전체 신청 수", example = "3") + long totalElements, + + @Schema(description = "현재 페이지 번호 (0부터)", example = "0") + int page, + + @Schema(description = "페이지 크기", example = "20") + int size, + + @Schema(description = "이 페이지의 신청 목록. 정렬은 접수 최신순 고정") + List submissions +) { + + public static AdminEventSubmissionListResponseDto of(Page page, + long inReview, long approved, long rejected) { + return new AdminEventSubmissionListResponseDto( + new EventSubmissionStatusCountsResponseDto(inReview, approved, rejected), + page.getTotalElements(), + page.getNumber(), + page.getSize(), + page.getContent()); + } +} 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 index da3ec3aa..3e69240d 100644 --- a/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java +++ b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java @@ -4,13 +4,17 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; 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.dto.AdminEventSubmissionItemResponseDto; import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; public interface EventSubmissionRepository extends JpaRepository { @@ -47,6 +51,37 @@ public interface EventSubmissionRepository extends JpaRepository + * 정렬은 접수 최신순이고 id 는 같은 시각 접수의 결정성 보험이다(내 신청 목록과 같은 규칙). + * countQuery 를 따로 주는 것은 프로젝션·조인이 붙은 자동 생성 count 가 부정확해질 여지를 없애기 위해서다. + */ + @Query(value = """ + SELECT new com.msg.fillmap.event.submission.dto.AdminEventSubmissionItemResponseDto( + s.id, s.submissionNo, s.type, s.status, s.title, s.organizerName, u.orgName, + s.startsOn, s.endsOn, size(s.locations), s.createdAt, s.updatedAt) + FROM EventSubmission s, User u + WHERE u.id = s.userId AND s.status = :status + ORDER BY s.createdAt DESC, s.id DESC + """, + countQuery = "SELECT COUNT(s) FROM EventSubmission s WHERE s.status = :status") + Page findAdminPageByStatus( + @Param("status") EventSubmissionStatus status, Pageable pageable); + + /** 탭 뱃지용 상태별 전체 건수 — 필터와 무관하다 (MSG-499 요청 큐 선례). */ + long countByStatus(EventSubmissionStatus status); + + /** + * 관리자 심사 상세 (MSG-500 §API 2). 소유 술어가 없다 — 관리자 조회에는 존재 은닉이 없어 없는 id 가 + * 그대로 404 다. 위치는 상세 응답이 통째로 그리므로 EntityGraph 로 함께 든다. + */ + @EntityGraph(attributePaths = "locations") + Optional findWithLocationsById(Long id); + /** * 신청 번호의 순번 (D-4). 연도별 리셋이 없는 전역 증가값이라 리셋 기계 없이 UNIQUE 가 보장된다. * PostgreSQL 시퀀스 함수라 native 다 (JPA 표준에 동등 표현이 없다). diff --git a/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java b/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java new file mode 100644 index 00000000..4586da2b --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java @@ -0,0 +1,138 @@ +package com.msg.fillmap.event.submission.service; + +import java.util.List; +import java.util.Locale; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.AdminEventSubmissionDetailResponseDto; +import com.msg.fillmap.event.submission.dto.AdminEventSubmissionListResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionHistoryResponseDto; +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.EventSubmissionStatus; +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.user.entity.User; +import com.msg.fillmap.user.exception.UserErrorCode; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 관리자 심사 조회 (MSG-500 §API 1·2). 행사 운영자 조회(MSG-498)와 존재 은닉이 반대다 — 소유 술어가 + * 없고 없는 id 는 그대로 404(13430)다. 관리자는 전체를 보는 주체라 숨길 대상이 없다. + * + *

역할 검사는 여기에 없다. SecurityConfig 의 {@code /api/admin/**} matcher 가 필터 단계에서 거른다 + * (AdminOrgAccountController 선례). + */ +@Service +@RequiredArgsConstructor +public class AdminEventSubmissionService { + + private static final int MIN_PAGE_SIZE = 1; + private static final int MAX_PAGE_SIZE = 100; + + private final EventSubmissionRepository submissionRepository; + private final EventSubmissionStatusHistoryRepository historyRepository; + private final EventSubmissionImageStore imageStore; + private final EventSubmissionLocationView locationView; + private final UserRepository userRepository; + + /** + * 심사 큐 (§API 1). 상태 필터 기준 접수 최신순 오프셋 페이징이고, 탭 뱃지용 건수 3종은 필터와 무관한 + * 전체 집계다. 거부 판정은 처음 걸린 하나를 돌려준다: 상태 파싱 실패(13455) → 페이지 범위 밖(13456). + */ + @Transactional(readOnly = true) + public AdminEventSubmissionListResponseDto getSubmissions(String status, int page, int size) { + EventSubmissionStatus filter = parseStatus(status); + // PageRequest.of 에 그냥 넘기면 IllegalArgumentException 이 catch-all 핸들러에서 500 이 된다. + // 오프셋(page*size)이 int 를 넘는 극단 양수도 같다 (MSG-499 관리자 큐 선례, 대역만 event 다). + if (page < 0 || size < MIN_PAGE_SIZE || size > MAX_PAGE_SIZE + || (long) page * size > Integer.MAX_VALUE) { + throw new ApiException(EventErrorCode.INVALID_PAGE_RANGE); + } + return AdminEventSubmissionListResponseDto.of( + submissionRepository.findAdminPageByStatus(filter, PageRequest.of(page, size)), + submissionRepository.countByStatus(EventSubmissionStatus.IN_REVIEW), + submissionRepository.countByStatus(EventSubmissionStatus.APPROVED), + submissionRepository.countByStatus(EventSubmissionStatus.REJECTED)); + } + + /** + * 심사 상세 (§API 2). 폼 필드 전체에 심사 재료 셋을 더한다 — 신청 계정 정보, 노출 영역 사각형, 이력이다. + * 위치 표현은 행사 운영자 상세와 같은 조립기를 쓴다({@link EventSubmissionLocationView}) — 심사자와 + * 신청자가 서로 다른 그림을 보면 반려 사유가 가리키는 대상이 어긋난다. + */ + @Transactional(readOnly = true) + public AdminEventSubmissionDetailResponseDto getSubmission(Long submissionId) { + EventSubmission submission = submissionRepository.findWithLocationsById(submissionId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + // 신청 계정은 FK 가 보장하는 존재라 빈 결과가 정상 흐름에 없다 — 그래도 삼키지 않고 1404 로 드러낸다. + User applicant = userRepository.findById(submission.getUserId()) + .orElseThrow(() -> new ApiException(UserErrorCode.USER_NOT_FOUND)); + + return new AdminEventSubmissionDetailResponseDto( + 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()), + applicant.getOrgName(), + applicant.getNickname(), + applicant.getEmail(), + locationView.describe(submission.getLocations()), + exposureRect(submission.getLocations()), + historyRepository.findByEventSubmissionIdOrderByIdAsc(submissionId).stream() + .map(EventSubmissionHistoryResponseDto::from) + .toList(), + submission.getCreatedAt(), + submission.getUpdatedAt()); + } + + /** + * 노출 영역 (§API 2) — 전 위치 사각형을 감싸는 경계 사각형이다. 셀을 전개하지 않고 사각형의 min·max 만 + * 접는 것으로 충분하다: 합집합의 경계는 원본 사각형들의 경계와 같다. 조회 시점 계산이라 저장하지 않는다. + *

+ * 선행 조건은 "위치 1개 이상, 위치마다 사각형 1개 이상"이고 접수 검증(MSG-498 buildLocations·expand)이 + * 그 둘을 강제한다 — 신청을 만드는 경로가 그 애그리거트 하나뿐이라 빈 입력이 여기 닿지 못한다. + * 그래서 빈 경우의 분기를 두지 않는다(응답 필드도 non-null 계약이다). + */ + private EventSubmissionAreaRectDto exposureRect(List locations) { + int minGridY = Integer.MAX_VALUE; + int maxGridY = Integer.MIN_VALUE; + int minGridX = Integer.MAX_VALUE; + int maxGridX = Integer.MIN_VALUE; + for (EventSubmissionLocation location : locations) { + for (EventSubmissionAreaRect rect : location.getRects()) { + minGridY = Math.min(minGridY, rect.getMinGridY()); + maxGridY = Math.max(maxGridY, rect.getMaxGridY()); + minGridX = Math.min(minGridX, rect.getMinGridX()); + maxGridX = Math.max(maxGridX, rect.getMaxGridX()); + } + } + return new EventSubmissionAreaRectDto(minGridY, maxGridY, minGridX, maxGridX); + } + + /** 클라이언트 문자열 → 상태 (MSG-499 parseStatus 선례). 조용한 기본값 폴백 없이 13455 로 거른다. */ + private EventSubmissionStatus parseStatus(String status) { + try { + return EventSubmissionStatus.valueOf(status.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_STATUS_FILTER); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java new file mode 100644 index 00000000..c0dc63c8 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java @@ -0,0 +1,277 @@ +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 com.msg.fillmap.event.submission.EventSubmissionFixtures.rect; +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.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 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.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-500 §API 1·2, 실 DB). 검증 대상이 신청 계정 조인, 위치 수 상관 서브쿼리, + * 상태 필터와 무관한 전체 건수, 저장된 사각형에서 접는 노출 영역이라 전부 DB 동작이다 — 목으로는 + * 프로젝션이 실제로 조립되는지조차 확인되지 않는다. + *

+ * 건수 단언은 절대값이 아니라 증분이다: 건수 3종이 필터와 무관한 전역 집계라 공유 로컬 DB 의 기존 + * 신청이 그대로 더해지기 때문이다. {@code @Transactional} 롤백 격리로 계정·신청을 남기지 않는다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("관리자 행사 등재 심사 조회 (MSG-500, 실 DB)") +class AdminEventSubmissionControllerTest { + + private static final String ADMIN_URL = "/api/admin/event-submissions"; + private static final String ORG_URL = "/api/org/event-submissions"; + + /** 광안리 위치와 겹치지 않는 둘째 위치 — 노출 영역이 두 위치를 함께 감싸는지 보려면 떨어져 있어야 한다. */ + private static final String HAEUNDAE_RECT = rect(16870, 16872, 11520, 11525); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @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 admin; + + @BeforeEach + void setUp() { + organizer = saveUser(UserRole.ORG); + admin = saveUser(UserRole.ADMIN); + 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 saveUser(UserRole role) { + // createOrgUser 를 쓰지 않는다 — 강제 변경 플래그가 서면 게이트 인터셉터가 /api/org 제출을 막는다. + User user = User.createLocalUser("admin-sub-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", role); + ReflectionTestUtils.setField(user, "orgName", "부산광역시 부산진구청"); + return userRepository.saveAndFlush(user); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + + private long 신청한다(String... locations) throws Exception { + String response = mockMvc.perform(post(ORG_URL) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(festivalBody(organizer.getId(), locations))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return ((Number) JsonPath.read(response, "$.data.id")).longValue(); + } + + /** 승인 대역 — 승인 경로(M3)는 아직 없고 여기서 필요한 것은 "승인 상태 행"뿐이다. */ + private void 승인_상태로_바꾼다(long submissionId) { + entityManager.flush(); + jdbcTemplate.update("UPDATE event_submissions SET status = 'APPROVED', approval_no = ? WHERE id = ?", + "APR-2026-" + submissionId, submissionId); + entityManager.clear(); + } + + private ResultActions 큐를_조회한다(String query) throws Exception { + entityManager.flush(); + entityManager.clear(); + return mockMvc.perform(get(ADMIN_URL + query).header(HttpHeaders.AUTHORIZATION, bearer(admin))); + } + + private ResultActions 상세를_조회한다(long submissionId) throws Exception { + entityManager.flush(); + entityManager.clear(); + return mockMvc.perform(get(ADMIN_URL + "/" + submissionId) + .header(HttpHeaders.AUTHORIZATION, bearer(admin))); + } + + private long 큐_건수(String field, String query) throws Exception { + String response = 큐를_조회한다(query).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return ((Number) JsonPath.read(response, "$.data.counts." + field)).longValue(); + } + + @Nested + @DisplayName("심사 큐") + class Queue { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("건수 3종은 상태 필터와 무관한 전체 집계다") + void 심사_큐는_상태_필터와_무관한_건수_3종을_함께_준다() throws Exception { + long 처음_심사_중 = 큐_건수("inReview", ""); + long 처음_승인 = 큐_건수("approved", ""); + + 신청한다(location(GWANGALLI_RECT)); + 승인_상태로_바꾼다(신청한다(location(GWANGALLI_RECT))); + + // 필터가 심사 중이어도 승인 건수가 함께 늘어난다 — 탭 뱃지 재료라 필터를 타지 않는다. + 큐를_조회한다("?status=IN_REVIEW") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.counts.inReview").value(처음_심사_중 + 1)) + .andExpect(jsonPath("$.data.counts.approved").value(처음_승인 + 1)); + // 목록 자체는 필터를 탄다 — 방금 승인한 건은 심사 중 목록에 없다. + 큐를_조회한다("?status=APPROVED&size=100") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.submissions[?(@.status == 'IN_REVIEW')]").isEmpty()); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("항목에 신청 계정의 기관명과 위치 수가 실린다") + void 심사_큐_항목은_기관명과_위치_수를_함께_준다() throws Exception { + long id = 신청한다(location(GWANGALLI_RECT), location(HAEUNDAE_RECT)); + + 큐를_조회한다("?status=IN_REVIEW&size=100") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.submissions[?(@.id == %d)].orgName".formatted(id)) + .value("부산광역시 부산진구청")) + .andExpect(jsonPath("$.data.submissions[?(@.id == %d)].locationCount".formatted(id)).value(2)) + .andExpect(jsonPath("$.data.submissions[?(@.id == %d)].organizerName".formatted(id)) + .value("부산문화관광축제조직위원회")) + .andExpect(jsonPath("$.data.submissions[?(@.id == %d)].type".formatted(id)).value("FESTIVAL")); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("지원하지 않는 상태 필터는 13455 다") + void 지원하지_않는_상태_필터는_13455다() throws Exception { + 큐를_조회한다("?status=UNKNOWN") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(13455)); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("페이지 번호나 크기가 범위 밖이면 13456 이다") + void 페이지_범위_밖_요청은_13456이다() throws Exception { + for (String query : new String[] {"?page=-1", "?size=0", "?size=101"}) { + 큐를_조회한다(query) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(13456)); + } + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("관리자가 아니면 심사 큐에 접근할 수 없다") + void 관리자가_아니면_심사_큐에_접근할_수_없다() throws Exception { + mockMvc.perform(get(ADMIN_URL).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(ADMIN_URL)).andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("심사 상세") + class Detail { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("노출 영역 경계 사각형과 신청 계정 정보를 준다") + void 심사_상세는_노출_영역_경계_사각형과_신청_계정_정보를_준다() throws Exception { + long id = 신청한다(location(GWANGALLI_RECT), location(HAEUNDAE_RECT)); + + 상세를_조회한다(id) + .andExpect(status().isOk()) + // 두 위치를 함께 감싸는 하나의 사각형 — 위치별 사각형이 아니라 합집합의 경계다. + .andExpect(jsonPath("$.data.exposureRect.minGridY").value(16859)) + .andExpect(jsonPath("$.data.exposureRect.maxGridY").value(16872)) + .andExpect(jsonPath("$.data.exposureRect.minGridX").value(11509)) + .andExpect(jsonPath("$.data.exposureRect.maxGridX").value(11525)) + .andExpect(jsonPath("$.data.orgName").value("부산광역시 부산진구청")) + .andExpect(jsonPath("$.data.contactName").value("김담당")) + .andExpect(jsonPath("$.data.email").value(organizer.getEmail())) + .andExpect(jsonPath("$.data.locations.length()").value(2)) + .andExpect(jsonPath("$.data.locations[0].representativeGridId").value("16860_11512")) + .andExpect(jsonPath("$.data.locations[0].cellCount").value(21)) + .andExpect(jsonPath("$.data.imageUrl").value("https://signed.example/image.jpg")) + .andExpect(jsonPath("$.data.history.length()").value(1)) + .andExpect(jsonPath("$.data.history[0].status").value("IN_REVIEW")); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("없는 신청은 존재 은닉 없이 그대로 13430 이다") + void 없는_신청의_상세는_13430이다() throws Exception { + 상세를_조회한다(99_999_999L) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(13430)); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("남의 신청도 관리자에게는 그대로 보인다") + void 남의_신청도_관리자에게는_보인다() throws Exception { + long id = 신청한다(location(GWANGALLI_RECT)); + + 상세를_조회한다(id) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("IN_REVIEW")) + .andExpect(jsonPath("$.data.submissionNo").exists()); + } + } +} From eb41d9ee35045d422e6a2140a9187a39a5f9edba Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:10:37 +0900 Subject: [PATCH 06/35] =?UTF-8?q?MSG-500=20docs:=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=ED=91=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=98=84=EC=8B=A4=EC=97=90=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13450 상수명 정정(SUBMISSION_STATUS_NOT_REVIEWABLE), 13455·13456 행 추가, M5의 1424·1425 재사용 근거 명시. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/spec/MSG-500.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/spec/MSG-500.md b/docs/spec/MSG-500.md index 348ef9e8..c6a17953 100644 --- a/docs/spec/MSG-500.md +++ b/docs/spec/MSG-500.md @@ -65,7 +65,8 @@ ### 1. 심사 큐: GET /api/admin/event-submissions - 쿼리: `status`(기본 IN_REVIEW, 대소문자 무관), `page`(기본 0), `size`(기본 20, 1~100). - 파라미터 검증 실패는 MSG-499 관리자 목록과 같은 방식으로 400이다. + 파라미터 검증 실패는 MSG-499 관리자 목록과 같은 방식으로 400이다 — 판정은 같고 코드는 event 대역이라 + 상태 필터는 13455, 페이지 범위는 13456이다(아래 신규 에러 코드 표). - 응답 `AdminEventSubmissionListResponseDto`: - `counts`: 상태별 건수 3종(inReview, approved, rejected). 필터와 무관하게 전체 기준이다 (MSG-499 요청 큐 선례, 탭 뱃지 재료). @@ -186,15 +187,29 @@ MSG-502가 13440대를 예약해 이 티켓은 13450부터 쓴다 (세션 간 | developCode | 상수 | HTTP | 메시지 | |---|---|---|---| -| 13450 | SUBMISSION_NOT_REVIEWABLE | 409 | 심사 중인 신청만 승인하거나 반려할 수 있습니다 | +| 13450 | SUBMISSION_STATUS_NOT_REVIEWABLE | 409 | 심사 중인 신청만 승인하거나 반려할 수 있습니다 | | 13451 | SUBMISSION_PERIOD_PASSED | 409 | 행사 기간이 이미 지나 승인할 수 없습니다 | | 13452 | SUBMISSION_GRID_CONFLICT | 409 | 신청 영역이 부모 이벤트의 다른 위치와 겹칩니다 | | 13453 | EVENT_ALREADY_UNPUBLISHED | 409 | 이미 노출이 중지된 행사입니다 | | 13454 | INVALID_REJECT_REASON | 400 | 반려 항목 코드가 올바르지 않습니다 | +| 13455 | INVALID_SUBMISSION_STATUS_FILTER | 400 | 지원하지 않는 상태 필터입니다 | +| 13456 | INVALID_PAGE_RANGE | 400 | 페이지 번호 또는 크기가 유효하지 않습니다 | 409 셋은 권한이 아니라 리소스의 현재 상태와 요청의 충돌이라는 기존 결(13409, 13434 선례)을 따른다. 404와 조회 재료(13430)는 MSG-498 상수를 재사용한다. +13450의 상수명이 `SUBMISSION_NOT_REVIEWABLE`이 아니라 `SUBMISSION_STATUS_NOT_REVIEWABLE`인 것은 +같은 파일의 `SUBMISSION_NOT_EDITABLE`(13434, 반려본 수정 가드)과 한 글자 차이라 호출부에서 눈으로 +갈리지 않기 때문이다 (2026-08-29 구현 중 개명 확정). + +심사 큐(§API 1)의 파라미터 실패 두 코드가 여기 있는 이유: 판정과 메시지는 MSG-499 관리자 큐(1424·1425)와 +같지만 **대역은 event다**. 도메인 예외는 도메인이 갖는다는 규칙(뷰포트 두 코드가 mission·grid와 같은 +판정에 다른 대역인 것과 같은 결)이라, event 컨트롤러가 user 대역 코드를 내지 않는다. 스펙 §API 1의 +"MSG-499와 같은 방식"은 **방식 동일·대역 상이**로 읽는다 (2026-08-29 리더 판정). + +**반대로 §API 7(아이디 변경 요청 심사)은 1424·1425를 그대로 재사용한다** — 그쪽은 user 도메인이라 +대역이 이미 맞고, 신규 상수를 만들면 같은 판정에 같은 도메인 코드가 두 벌이 된다. + ## 도메인 로직 ### 승인: 유형별 분기 (D-1) From 26d38fec955c2a17ed5ab94310168799978f7669 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:10:37 +0900 Subject: [PATCH 07/35] =?UTF-8?q?MSG-500=20docs:=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A4=91=EC=A7=80=EC=99=80=20=EC=8A=B9=EC=9D=B8=20=ED=96=89?= =?UTF-8?q?=EC=82=AC=20=EB=AA=A9=EB=A1=9D=20=EC=9A=94=EA=B5=AC=EB=A5=BC=20?= =?UTF-8?q?SRS=EC=97=90=20=EC=84=A0=EB=93=B1=EC=9E=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-EVENT-17·18 신설, FR-EVENT-15는 유형별 분기 확정으로 재작성. M4 테스트가 인용할 ID를 소급 주석 없이 확보한다(MSG-390 교훈). Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/srs-changelog.md | 2 ++ docs/srs.md | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/srs-changelog.md b/docs/srs-changelog.md index 28d752b4..1fc15497 100644 --- a/docs/srs-changelog.md +++ b/docs/srs-changelog.md @@ -142,3 +142,5 @@ union이 붙어 양쪽 행이 그대로 남는다. 2026-08-14에 같은 자리 | 2026-08-28 | FR-EVENT-16을 진행 중에서 구현됨으로 올렸다. 행사 운영자 콘솔의 승인 이벤트 목록 조회(`GET /api/org/events`)가 검증 테스트 25건과 같은 브랜치에 실렸다. 노출 조건은 종료 전 회차만이고 노출 시작 전 예정 회차도 콘솔에는 보인다(참여 신청 준비 기간 근거) | MSG-501 구현 (2026-08-28) | | 2026-08-29 | FR-AUTH-13을 진행 중에서 구현됨으로 올렸다. 문안은 불변이다. 행사 운영자 계정 발급 축이 서버에 전부 들어갔다 — 관리자 직접 발급과 발급 요청 승인이 같은 발급 코어를 쓰고(계정 생성·초기 비밀번호 생성·커밋 후 발송·발송 실패 격리), 초기 비밀번호 평문은 메일 본문 한 번 외에 응답·로그·DB 어디에도 남지 않는다(로그 캡처 단언, 발송 실패 예외를 로거에 넘기지 않는 것까지 회귀 테스트로 고정). 재생성은 재발급이라 새 비밀번호로 교체하고 이전 값을 즉시 무효화하며, 대상은 아직 초기 로그인을 마치지 않은 계정으로 한정한다(사용 중 계정에 재발급하면 발급자가 다시 비밀번호를 아는 상태가 되어 FR-AUTH-15의 목적이 무너진다). 검증 테스트 48건이 같은 브랜치에 실렸다. 계정이 없는 신청자의 공개 접수 폼과 관리자 큐(PRD FR-6)도 같은 티켓이 구현했으나 대응 SRS 행이 없어 등재 여부는 별도 판단으로 남긴다. FR-USER-16은 진행 중 그대로 두고 잔여 범위를 좁혔다 — 이번에 생긴 관리자 큐는 계정 발급 요청 전용이고 아이디 변경 요청의 검토·승인은 MSG-500 몫이다 | MSG-499 구현 (2026-08-29), 스펙 `docs/spec/MSG-499.md` | | 2026-08-29 | FR-EVENT-13·FR-EVENT-14를 계획에서 구현됨으로 올렸다. 행사 운영자 콘솔의 신청 제출(presign 포함)·내 목록·상세·반려본 재제출 API 5개가 검증 테스트와 같은 브랜치에 실렸다. 위치당 영역 상한은 사각형 합집합 기준 81칸으로 판정하고, 존재 은닉은 13430 단일 응답, 재제출 전이는 소유권 술어 포함 조건부 UPDATE로 원자화했다. 반려 행 쓰기와 승인 반영(FR-EVENT-15)은 MSG-500 몫으로 계획 유지 | MSG-498 구현 (2026-08-29) | +| 2026-08-30 | FR-EVENT-15 문장을 재작성하고 계획에서 진행 중으로 올렸다. 승인 반영이 유형별 분기로 확정되어(지역축제는 축제 미션, 팝업스토어는 팝업 미션으로 칩 등재, 이벤트 참여형만 부모 이벤트 아래 행사 위치로 반영 + 참여 속성 부가 노출) 구 "행사 도메인에 시리즈·회차·위치 생성" 서술을 대체했다. 반려 항목 코드 4종 필수도 함께 등재했다. FR-EVENT-01 근거의 "등재 기준 재정의 대상" 예고는 이 확정으로 닫았다 — 행사 칩에 새 행사가 오르는 경로는 여전히 수동 시드뿐이다 | 2026-08-29 정민 확정, PRD `docs/prd/event-submission.md` v2.2(MSG-502 브랜치 반영 중), 스펙 `docs/spec/MSG-500.md` | +| 2026-08-30 | FR-EVENT-17(관리자 노출 중지 — 사유 필수, 사용자 대면 전 경로 즉시 제외, 직접 접근 포함, 완료 기록·뱃지 비회수, 운영자 사유 통지)과 FR-EVENT-18(승인 행사 상태별 목록과 건수, 중지 식별)을 진행 중으로 등재했다. PRD FR-20·FR-25에 대응하는 SRS 행이 없어 MSG-500의 M4 테스트가 인용할 ID가 비어 있던 갭을 선등재로 닫았다(MSG-390의 소급 주석 교훈) | MSG-500 구현 중 (2026-08-30), PRD `docs/prd/event-submission.md` FR-20·25 | diff --git a/docs/srs.md b/docs/srs.md index fcdb3125..542e9f30 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -380,7 +380,7 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | ID | 요구사항 | 상태 | 근거 | |----|----------|------|------| -| FR-EVENT-01 | 사용자는 예정(시작 전 노출 기간 이내) 또는 진행 중인 행사 목록을 조회할 수 있다. 조회는 지도 뷰포트 기준이다. 행사마다 노출 영역이 있어 뷰포트와 겹치는 행사만 목록에 담기고, 지도를 다른 도시로 옮기면 칩이 그 지역 행사로 바뀐다. 각 행사에는 이름, 기간, 대상 지역, 상태(시작까지 남은 일수 또는 진행 중)가 담긴다. 대상 지역은 시 단위 이름이고 상단 칩을 시별로 묶는 기준이다(칩 행은 시 칩 뒤에 그 시의 행사 칩들이 나열된다). 한 뷰포트에 여러 시, 시마다 여러 행사가 잡혀도 목록이 성립하고, 겹치는 행사가 없으면 실패가 아니라 빈 목록이다. 등재는 상단 칩에 올릴 초대형 행사만이며(눈높이 기준은 BTS 부산 콘서트급, 2026-08-20 정민 확정) 도시별(서울, 부산)로 한두 개씩 수동 시드로 선등재하되, 행사 추가는 시드 행 추가만으로 끝난다. 지도 홈 행사 칩의 재료다 | 구현됨 | 구 PRD `docs/prd/event-room.md` FR-1에서 승계 (2026-08-20 뷰포트 기반 노출·수동 시드 확정, 시 칩 묶음은 같은 날 상단 칩 디자인 편입 — 노드 `14981:3278` 부근). 2026-08-20 정본 전환(위치별 영상 모델) 후에도 유효 — 정본 PRD US-001이 같은 칩 구조를 전제. MSG-439 구현 (2026-08-21, `GET /api/event-occurrences` — 비로그인 조회 허용은 같은 날 사용자 확정). 행사 등재 콘솔 계획(FR-EVENT-13~15, 2026-08-27)이 확정되면 "시드 행 추가만"이라는 등재 경로 서술과 BTS급 등재 눈높이는 재정의 대상이다(승인 행사 노출 방식 미확정과 연동, 미해결 질문 참조) | +| FR-EVENT-01 | 사용자는 예정(시작 전 노출 기간 이내) 또는 진행 중인 행사 목록을 조회할 수 있다. 조회는 지도 뷰포트 기준이다. 행사마다 노출 영역이 있어 뷰포트와 겹치는 행사만 목록에 담기고, 지도를 다른 도시로 옮기면 칩이 그 지역 행사로 바뀐다. 각 행사에는 이름, 기간, 대상 지역, 상태(시작까지 남은 일수 또는 진행 중)가 담긴다. 대상 지역은 시 단위 이름이고 상단 칩을 시별로 묶는 기준이다(칩 행은 시 칩 뒤에 그 시의 행사 칩들이 나열된다). 한 뷰포트에 여러 시, 시마다 여러 행사가 잡혀도 목록이 성립하고, 겹치는 행사가 없으면 실패가 아니라 빈 목록이다. 등재는 상단 칩에 올릴 초대형 행사만이며(눈높이 기준은 BTS 부산 콘서트급, 2026-08-20 정민 확정) 도시별(서울, 부산)로 한두 개씩 수동 시드로 선등재하되, 행사 추가는 시드 행 추가만으로 끝난다. 지도 홈 행사 칩의 재료다 | 구현됨 | 구 PRD `docs/prd/event-room.md` FR-1에서 승계 (2026-08-20 뷰포트 기반 노출·수동 시드 확정, 시 칩 묶음은 같은 날 상단 칩 디자인 편입 — 노드 `14981:3278` 부근). 2026-08-20 정본 전환(위치별 영상 모델) 후에도 유효 — 정본 PRD US-001이 같은 칩 구조를 전제. MSG-439 구현 (2026-08-21, `GET /api/event-occurrences` — 비로그인 조회 허용은 같은 날 사용자 확정). 승인 행사 노출 방식이 확정되면서(2026-08-29 정민, FR-EVENT-15 참조) 이 목록의 등재 기준은 그대로 유지된다 — 지역축제·팝업 승인분은 이 칩이 아니라 미션 칩으로 실리고, 참여형 승인분은 새 행사가 아니라 기존 행사 아래 위치 추가라, 행사 칩에 새 행사가 오르는 경로는 여전히 수동 시드뿐이다(구 "재정의 대상" 예고는 이 확정으로 닫힘) | | FR-EVENT-02 | 사용자는 행사방에서 공식 정보를 본다. 행사 헤더(이름, 기간, 상태)와 행사 위치 목록(위치마다 이름, 유형, 운영 시간, 격자 영역, 영상 수)이다. 영상 수는 별도 집계 없이 조회 시점 실측이다 | 구현됨 | 정본 PRD US-002 (2026-08-20 갱신 — 위치 유형·운영 시간·영상 수 편입, "일정과 프로그램 안내"는 구 PRD 잔재라 삭제 — 2026-08-20 사용자 승인, MSG-439 리뷰 적발). MSG-439 구현 (2026-08-21, 회차 상세·위치 목록 API — 영상 수는 MSG-440 피드 노출 술어와 동등한 단일 집계) | | FR-EVENT-03 | ~~사용자는 행사방에 참여할 수 있고 참여한 사용자만 제보와 댓글을 쓸 수 있다. 사용자 글은 제보 하나다~~ | 폐기됨 | 2026-08-20 정민 확정으로 텍스트 제보·참여 모델 폐기. 사용자 콘텐츠는 위치별 영상으로 대체(FR-EVENT-08), 참여 개념은 알림 구독만 남음(FR-EVENT-06) | | FR-EVENT-04 | ~~사용자는 제보에 댓글을 달 수 있다~~ | 폐기됨 | 2026-08-20 제보 폐기에 따라 소멸. 댓글은 영상 단위로 대체(FR-EVENT-09) | @@ -394,8 +394,10 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | FR-EVENT-12 | 행사 영상은 미션 집계 어디에도 잡히지 않는다. 미션 진행도, 완료 판정, 방문 스팟, 미션 영상 목록(첫 페이지와 다음 페이지), 격자별 미션 영상 수 전부에서 제외된다. 행사 영상이 일반 업로드의 부수효과(점령, 스트릭, 핫스코어, 뱃지 등)를 그대로 받는 가운데 유일한 제외 항목이다 | 구현됨 | 구 PRD "미션 축 연계 비목표" 승계 확정 (2026-08-20). 계약 정본 `docs/spec/MSG-438.md` §부수효과 계약. MSG-450 구현 (2026-08-22 — `videos` 를 직접 조인하는 미션 쿼리 여섯 곳(진행도·완료 판정·방문 스팟·미션 영상 목록 두 쿼리·격자별 영상 수)에 `NOT EXISTS event_videos` 안티조인을 넣었다. 쿼리 1~3 은 LEFT/INNER 조인의 ON 절, 4~6 은 WHERE 절이라 미진행 미션의 0 진행도가 유지된다. 술어 동등 계약을 안티조인 포함으로 갱신, 새 테이블·인덱스·마이그레이션 없음). 쓰기 경로의 판정 시점 가시성 계약은 MSG-440 이 미션 판정 훅 자체를 행사 업로드에서 빼는 방식으로 이미 만족한다(`VideoServiceImpl.confirmAndStore` 는 미션 훅 밖) | | FR-EVENT-13 | 행사 운영자는 등록 유형(지역축제, 팝업스토어) 중 하나를 골라 행사를 신청할 수 있고 유형마다 기본 정보 항목이 다르다(세 번째 유형인 이벤트 참여형은 승인 이벤트를 골라 참여를 신청하는 별도 구조로 MSG-501·MSG-502에 분리). 위치는 지도 격자 사각형 영역으로 지정하며 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있고, 위치당 영역은 사각형 합집합 기준 최대 격자 81칸이다. 대표 격자는 신청자가 아니라 서버가 계산한다. 접수된 신청은 심사 중 상태가 되고 신청 번호가 부여된다. 임시 저장은 미포함 확정 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 개정 (2026-08-28 승인, 유형 재편·81칸 상한 확정·임시 저장 미포함) · 스펙 `docs/spec/MSG-498.md` (2026-08-28). 위치 영역 형식은 기존 시드 areaRects와 동일. MSG-498 구현 (2026-08-29 — `POST /api/org/event-submissions` + presign, V49 스키마 4테이블, 81칸 합집합 판정, 신청 번호 FM-{연도}-{4자리} 전역 시퀀스, 대표 격자 서버 계산) | | FR-EVENT-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-15 | 관리자는 신청을 상태별로 조회하고 상세에서 위치 사각형과 노출 영역을 검토해 승인하거나 반려할 수 있다. 반려에는 항목 코드(행사 기간, 위치 영역, 홍보 이미지, 행사 정보 중 1개 이상)와 자유 서술이 필수다. 승인 반영은 유형에 따라 갈린다. 지역축제 승인분은 축제 미션으로, 팝업스토어 승인분은 팝업 미션으로 등재되어 지도 홈의 기존 축제·팝업 칩 목록에 실리고, 이벤트 참여형 승인분은 부모 이벤트 아래 행사 위치로 반영되며 운영 주체·소개·공개 기간·참여 방식·커버 이미지가 위치의 부가 정보로 함께 노출된다. 승인에는 승인 번호가 부여되고, 승인 산출물은 기존 산출물과 같은 규칙(대표 격자 결정, 회차 내 격자 단일 귀속, 생명주기)을 따르며 승인 즉시 지도 조회에 반영된다 | 진행 중 | PRD `docs/prd/event-submission.md` v2.2 (2026-08-29 정민 확정 — 노출 방식 유형별 분기, MSG-502 브랜치 반영 중. 구 "행사 도메인에 시리즈·회차·위치 생성" 서술은 참여형 한정으로 좁혀짐). 스펙 `docs/spec/MSG-500.md` (2026-08-29). MSG-500 구현 중. 승인 후 일정 수정 정책은 유보 확정(운영팀 문의 안내) | | FR-EVENT-16 | 행사 운영자는 등록 유형 "이벤트"에서 참여할 승인 이벤트 목록을 조회할 수 있다. 목록은 이벤트 카테고리(지역축제와 팝업스토어를 제외한 큰 행사)의 종료 전 회차만 담고, 시·도 필터와 이벤트 이름 검색과 시·도별 건수를 제공하며, 항목마다 이름·기간·장소 라벨·시·도가 담긴다. 노출 시작 전 예정 회차도 이 콘솔 목록에는 보인다 | 구현됨 | MSG-501 구현 (2026-08-28, `GET /api/org/events` — 검증 테스트 25건 동반). PRD `docs/prd/event-submission.md` FR-26 (2026-08-28 최소 개정), v2.1 [행사 운영자 3-1] 이벤트 선택 모달. 부모 범위는 이벤트 카테고리만으로 사용자 확정(2026-08-28, 시안의 지역축제/팝업스토어 배지는 구판 잔재). FR-EVENT-13의 유형 서술(행사방)을 v2.1(이벤트 참여형)로 갱신하는 것은 MSG-498 레인 몫이라 여기서 손대지 않는다 | +| FR-EVENT-17 | 관리자는 승인된 행사의 노출을 사유와 함께 중지할 수 있다. 중지하면 그 승인 산출물이 사용자 대면 조회와 판정 경로 전부(지도 칩 목록, 격자 선택, 영상 목록, 이미 알고 있는 식별자로의 직접 접근 포함)에서 즉시 사라지고, 행사 운영자에게 사유가 통지된다. 이미 완료한 사용자의 진행 기록과 뱃지 지표는 회수되지 않고, 관리자 화면에서는 중지된 행사의 기록이 계속 조회된다 | 진행 중 | PRD `docs/prd/event-submission.md` FR-20 (2026-08-28 승인, v2 [관리자 5] 시안). 스펙 `docs/spec/MSG-500.md` D-3·D-5 (2026-08-29). MSG-500 구현 중. 중지 해제(재노출)는 미결정이라 범위 밖 | +| FR-EVENT-18 | 관리자는 승인된 행사를 노출 중, 예정, 종료 상태별로 조회할 수 있고 상태별 건수가 함께 제공된다. 상태는 조회 시점과 행사 기간으로 판정하며, 중지된 행사는 목록에서 중지로 식별된다 | 진행 중 | PRD `docs/prd/event-submission.md` FR-25 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-11 (2026-08-29). MSG-500 구현 중 | ### ROUTE: AI 경로 추천 From b548764f2b7463102b3be643d7c1a98f9781b2de Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:35:05 +0900 Subject: [PATCH 08/35] =?UTF-8?q?MSG-500=20feat:=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EB=93=B1=EC=9E=AC=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20=EC=BB=A4=EB=B0=8B=20=ED=9B=84=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=88=8F=20=EB=AC=B4=ED=9A=A8=ED=99=94=EB=A5=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시더의 KST 변환·대표 격자 규칙을 public 승격해 재사용하고(복제 0), 무효화는 refreshLock 안에서 홀더를 비워 옛 결과 되채움을 차단한다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../mission/seed/FestivalMissionSeeder.java | 12 ++- .../seed/MissionRepresentativeGrids.java | 7 +- .../mission/service/MissionQueryService.java | 10 ++ .../service/MissionRegistrationService.java | 46 ++++++++++ .../service/impl/MissionQueryServiceImpl.java | 14 +++ .../impl/MissionRegistrationServiceImpl.java | 92 +++++++++++++++++++ .../service/MissionQueryServiceCacheTest.java | 16 ++++ 7 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java create mode 100644 src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java diff --git a/src/main/java/com/msg/fillmap/mission/seed/FestivalMissionSeeder.java b/src/main/java/com/msg/fillmap/mission/seed/FestivalMissionSeeder.java index 6b2e9cca..e1ce7946 100644 --- a/src/main/java/com/msg/fillmap/mission/seed/FestivalMissionSeeder.java +++ b/src/main/java/com/msg/fillmap/mission/seed/FestivalMissionSeeder.java @@ -256,13 +256,17 @@ static List expandGrids(String centerGridId) { return gridIds; } - /** start_at = KST 00:00:00 의 UTC 순간 (D5). */ - static LocalDateTime toUtcStart(LocalDate kstDate) { + /** + * start_at = KST 00:00:00 의 UTC 순간 (D5). public 인 것은 관리자 승인 등재 + * (MissionRegistrationServiceImpl, MSG-500)가 같은 규칙을 써야 하기 때문이다 — 기간 변환 규칙이 두 + * 벌이 되면 시더 미션과 승인 미션의 하루 경계가 서로 다르게 잘린다. + */ + public static LocalDateTime toUtcStart(LocalDate kstDate) { return kstDate.atStartOfDay(KST).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); } - /** end_at = KST 23:59:59 의 UTC 순간 — 판정이 양끝 포함이라 종료일 자정 직전까지 인정된다 (D5). */ - static LocalDateTime toUtcEnd(LocalDate kstDate) { + /** end_at = KST 23:59:59 의 UTC 순간 — 판정이 양끝 포함이라 종료일 자정 직전까지 인정된다 (D5, toUtcStart 와 같은 이유로 public). */ + public static LocalDateTime toUtcEnd(LocalDate kstDate) { return kstDate.atTime(23, 59, 59).atZone(KST).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); } diff --git a/src/main/java/com/msg/fillmap/mission/seed/MissionRepresentativeGrids.java b/src/main/java/com/msg/fillmap/mission/seed/MissionRepresentativeGrids.java index 234aabbd..1793c775 100644 --- a/src/main/java/com/msg/fillmap/mission/seed/MissionRepresentativeGrids.java +++ b/src/main/java/com/msg/fillmap/mission/seed/MissionRepresentativeGrids.java @@ -11,11 +11,12 @@ import com.msg.fillmap.mission.entity.MissionType; /** - * 시더의 대표 격자 유지 규칙 (MSG-459 D-6) — 축제·팝업 두 시더가 적재·갱신 경로에서 함께 쓴다. + * 대표 격자 유지 규칙 (MSG-459 D-6) — 축제·팝업 두 시더가 적재·갱신 경로에서 함께 쓰고, 관리자 승인 + * 등재(MSG-500)도 같은 규칙을 부른다(적재 주체가 늘어도 규칙은 한 벌이다). * 산출 자체는 행사방과 같은 클래스 하나({@link RepresentativeGridResolver})가 하고, 여기서는 * 언제 다시 산출하는가만 정한다. */ -final class MissionRepresentativeGrids { +public final class MissionRepresentativeGrids { /** 대표 격자를 갖는 유형 — chk_missions_rep_grid_type 과 같은 집합이어야 한다. */ private static final Set ELIGIBLE_TYPES = Set.of(MissionType.EVENT, MissionType.POPUP); @@ -36,7 +37,7 @@ private MissionRepresentativeGrids() { * @param gridIds 그 미션의 현재 판정 격자 집합 * @return 값을 새로 산출해 넣었으면 true (호출자가 경고 로그·건수 집계에 쓴다) */ - static boolean reassignIfOutside(Mission mission, Collection gridIds) { + public static boolean reassignIfOutside(Mission mission, Collection gridIds) { if (!ELIGIBLE_TYPES.contains(mission.getType()) || mission.getTargetCount() != 1 || gridIds.isEmpty()) { return false; } diff --git a/src/main/java/com/msg/fillmap/mission/service/MissionQueryService.java b/src/main/java/com/msg/fillmap/mission/service/MissionQueryService.java index 1e1963da..ff0eeb5e 100644 --- a/src/main/java/com/msg/fillmap/mission/service/MissionQueryService.java +++ b/src/main/java/com/msg/fillmap/mission/service/MissionQueryService.java @@ -77,4 +77,14 @@ List getMissionAggregates( * 1시간 스냅샷 캐시를 읽지 않는다 — 방금 시작하거나 방금 끝난 미션이 누락되면 안 된다. */ List getMissionsByGrid(String gridId); + + /** + * 목록 스냅숏 무효화 (MSG-500 D-12) — 다음 조회가 TTL 을 기다리지 않고 다시 계산하게 한다. + * 미션이 요청 경로 밖에서 생기거나 숨겨질 때 부르며(관리자 승인·노출 중지), 그렇게 하지 않으면 승인 + * 미션이 최대 1시간 지도에 안 실리고 중지 미션이 최대 1시간 계속 보인다. + * + *

커밋 이후에 부른다. 커밋 전에 비우면 재계산이 아직 안 보이는 미커밋 행을 놓친 스냅숏을 + * 도로 채울 수 있다. 시더는 기동 1회라(스냅숏 생성 전) 부를 필요가 없다. + */ + void invalidateSnapshot(); } diff --git a/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java b/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java new file mode 100644 index 00000000..b88022c5 --- /dev/null +++ b/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java @@ -0,0 +1,46 @@ +package com.msg.fillmap.mission.service; + +import java.time.LocalDate; +import java.util.List; + +import com.msg.fillmap.mission.entity.MissionType; + +/** + * 미션 등재 (MSG-500 D-2) — 요청 경로에서 미션을 만드는 유일한 문이다. 지금까지 미션을 만드는 쪽은 + * 기동 1회 시더뿐이었고, 관리자 승인이 처음으로 사용자 요청 중에 미션을 만든다. + * + *

승인 서비스(event 도메인)가 mission 리포지토리를 직접 쓰지 않고 이 인터페이스를 거치는 것은 + * "타 도메인 쓰기는 소유 도메인 서비스를 거친다"는 원칙 때문이다 — event 와 mission 이 둘 다 Owner B + * 라도 같다. 그래서 KST 날짜 → UTC 순간 변환, 대표 격자 산출, source 값, 스냅숏 무효화처럼 미션의 + * 규칙은 전부 이 구현 안에 있고 호출자는 신청에서 읽은 값만 넘긴다. + */ +public interface MissionRegistrationService { + + /** + * 승인 미션 1건 등재 (신청 1건 = 미션 1건). 호출자의 트랜잭션 안에서 실행되므로 승인 전이·이력과 함께 + * 커밋되거나 함께 롤백된다. 스냅숏 무효화는 그 커밋 이후로 예약된다(D-12). + * + * @return 만들어진 미션 id — 호출자가 신청 행에 산출물 링크로 기록한다 + */ + long register(MissionRegistration registration); + + /** + * 등재 입력 (신청에서 읽은 값 그대로). 기간은 KST 날짜 라벨이다 — UTC 순간으로 올리는 규칙은 + * 축제 시더와 한 벌이어야 해서 구현이 갖는다. imageKey 는 버킷 상대 키이고 공개 주소 조립도 구현 몫이다 + * (미션 시더가 이미 그렇게 한다 — 앱은 주소가 아니라 키를 입력으로 받는다). + * + * @param gridIds 판정 대상 격자 전량(전 위치 셀 합집합). 대표 격자는 이 집합에서 산출한다 + */ + record MissionRegistration( + MissionType type, + String title, + LocalDate startsOn, + LocalDate endsOn, + String sourceKey, + String description, + String operationTime, + String imageKey, + List gridIds + ) { + } +} diff --git a/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java b/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java index bc393ab0..5d1aa37d 100644 --- a/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java +++ b/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java @@ -458,6 +458,20 @@ private boolean intersects(GridRange mission, GridRange view, long pad) { && mission.maxGridX() >= view.minGridX() - pad && mission.minGridX() <= view.maxGridX() + pad; } + /** + * 스냅숏 무효화 (MSG-500 D-12) — 홀더를 비워 다음 조회가 재계산하게 한다. 재계산 락 안에서 비우는 것은 + * 이미 진행 중인 재계산이 방금 비운 홀더를 옛 결과로 도로 채우는 순서를 막기 위해서다. 그래도 남는 + * 경합이 하나 있다: 무효화 직전에 락을 잡아 재계산을 마친 스레드의 결과는 지워진다(그 재계산은 무효화를 + * 부른 변경을 못 봤을 수 있다). 호출부가 커밋 후에 부르므로 그 창은 커밋 시점과 이 호출 사이로 + * 좁고, 최악의 결과는 다음 조회 한 번이 옛 스냅숏을 보는 것이다(TTL 1시간 지연과 비교하면 미미하다). + */ + @Override + public void invalidateSnapshot() { + synchronized (refreshLock) { + this.cache = null; + } + } + private List snapshot() { CacheEntry current = cache; if (current != null && clock.millis() < current.expiresAtMillis()) { diff --git a/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java b/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java new file mode 100644 index 00000000..3dd478c7 --- /dev/null +++ b/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java @@ -0,0 +1,92 @@ +package com.msg.fillmap.mission.service.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import lombok.RequiredArgsConstructor; + +import com.msg.fillmap.global.config.AwsProperties; +import com.msg.fillmap.mission.entity.Mission; +import com.msg.fillmap.mission.entity.MissionGrid; +import com.msg.fillmap.mission.entity.MissionMetadata; +import com.msg.fillmap.mission.repository.MissionGridRepository; +import com.msg.fillmap.mission.repository.MissionRepository; +import com.msg.fillmap.mission.seed.FestivalMissionSeeder; +import com.msg.fillmap.mission.seed.MissionRepresentativeGrids; +import com.msg.fillmap.mission.service.MissionQueryService; +import com.msg.fillmap.mission.service.MissionRegistrationService; + +/** + * 승인 미션 등재 (MSG-500 D-2). 시더가 만드는 미션과 다른 점은 셋뿐이다: source 가 + * {@code ORG_SUBMISSION} 이라 시더 정리·dedupe 가 건드리지 않고, sourceKey 가 신청 번호라 이중 승인이 + * DB 부분 유니크에서 막히며, 커밋 후 목록 스냅숏을 비운다(요청 경로에서 만들어지므로). + */ +@Service +@RequiredArgsConstructor +public class MissionRegistrationServiceImpl implements MissionRegistrationService { + + /** + * 적재 출처 값 — 이 경로 산출물 식별자다. 시더 정리({@code deleteEndedBySourceWithoutStamps})와 dedupe 가 + * 전부 source 한정이라, 이 값 하나로 승인 미션이 시더 파이프라인과 완전히 절연된다. + * 값 상수를 러너가 아니라 등재 서비스가 갖는 것은 Mission.source 의 소유 규칙 그대로다. + */ + public static final String SOURCE_ORG_SUBMISSION = "ORG_SUBMISSION"; + + /** + * 목표 칸수 1 (축제 시더 선례) — 관대함으로만 작용하고, 대표 격자를 갖는 행의 전제 + * ({@code chk_missions_rep_grid_type})이기도 하다. + */ + private static final int TARGET_COUNT = 1; + + private final MissionRepository missionRepository; + private final MissionGridRepository missionGridRepository; + private final MissionQueryService missionQueryService; + private final AwsProperties awsProperties; + + @Override + @Transactional + public long register(MissionRegistration registration) { + Mission mission = missionRepository.save(Mission.builder() + .type(registration.type()) + .title(registration.title()) + // KST 날짜 라벨 → UTC 순간. 축제 시더의 규칙을 그대로 부른다 — 두 벌로 복제하면 한쪽만 고쳐진다. + .startAt(FestivalMissionSeeder.toUtcStart(registration.startsOn())) + .endAt(FestivalMissionSeeder.toUtcEnd(registration.endsOn())) + .targetCount(TARGET_COUNT) + .source(SOURCE_ORG_SUBMISSION) + .sourceKey(registration.sourceKey()) + .metadata(new MissionMetadata(registration.description(), null, null, registration.operationTime(), + awsProperties.publicUrl(registration.imageKey()), null, null, null)) + .build()); + + missionGridRepository.saveAll(registration.gridIds().stream() + .map(gridId -> new MissionGrid(mission.getId(), gridId)) + .toList()); + // 대표 격자도 시더와 같은 규칙 하나를 쓴다 — 홀수 직사각형이면 정중앙, 아니면 중심 최근접 포함 격자다. + // 판정 집합 소속은 지연 FK(fk_missions_rep_grid)가 커밋 시점에 다시 확인한다. + MissionRepresentativeGrids.reassignIfOutside(mission, registration.gridIds()); + + invalidateSnapshotAfterCommit(); + return mission.getId(); + } + + /** + * 스냅숏 무효화 예약 (D-12) — 커밋 후다. 커밋 전에 비우면 재계산이 아직 안 보이는 이 미션을 놓친 + * 스냅숏을 도로 채워, 무효화를 하고도 최대 1시간 안 실리는 결과가 그대로 남는다. + * 트랜잭션이 없으면(단위 테스트 등) 그냥 지금 실행한다 (EventSubmissionImageStore.afterCommit 패턴). + */ + private void invalidateSnapshotAfterCommit() { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + missionQueryService.invalidateSnapshot(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + missionQueryService.invalidateSnapshot(); + } + }); + } +} diff --git a/src/test/java/com/msg/fillmap/mission/service/MissionQueryServiceCacheTest.java b/src/test/java/com/msg/fillmap/mission/service/MissionQueryServiceCacheTest.java index fc087f29..1cdee9eb 100644 --- a/src/test/java/com/msg/fillmap/mission/service/MissionQueryServiceCacheTest.java +++ b/src/test/java/com/msg/fillmap/mission/service/MissionQueryServiceCacheTest.java @@ -191,6 +191,22 @@ private MissionQueryService newService(MutableClock clock) { verify(missionRepository, times(2)).findActive(any()); } + // 검증: FR-EVENT-15 + @Test + @DisplayName("무효화하면 TTL 전에도 다시 계산한다 — 승인 미션이 최대 1시간 안 실리는 창을 없앤다 (MSG-500 D-12)") + void 무효화하면_TTL_전에도_다시_계산한다() { + MutableClock clock = new MutableClock(Instant.ofEpochMilli(0)); + MissionQueryService service = newService(clock); + given(missionRepository.findActive(any())).willReturn(List.of()); + + service.getMissionsInViewport(강남_뷰포트, MissionType.EVENT); + service.invalidateSnapshot(); + // 시각은 그대로다 — TTL 이 아직 한참 남았는데도 다시 계산해야 무효화가 성립한다. + service.getMissionsInViewport(강남_뷰포트, MissionType.EVENT); + + verify(missionRepository, times(2)).findActive(any()); + } + @Test @DisplayName("재계산된 스냅샷에도 사각형이 함께 들어온다 — 만료 후에도 뷰포트 판정이 유지된다 (MSG-398 D2)") void 재계산된_스냅샷에도_사각형이_함께_들어온다() { From 23a7ab9c6df69c1c06b005732a8ad75d7fa7f201 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:35:05 +0900 Subject: [PATCH 09/35] =?UTF-8?q?MSG-500=20feat:=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=EB=A5=BC=20=EB=A7=8C=EB=93=A0=EB=8B=A4=20-?= =?UTF-8?q?=20=EC=85=80=20=EC=82=B0=EC=88=A0=20=EA=B3=B5=EC=9A=A9=ED=99=94?= =?UTF-8?q?,=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC,=20=EC=9B=90=EC=9E=90=20=EC=A0=84=EC=9D=B4?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 셀 전개를 EventSubmissionCells 한 곳으로 모아 상세 칸 수와 승인 판정 격자가 같은 산술을 쓴다. 이미지 복사는 보상 등록이 복사보다 선행한다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../EventSubmissionApproveResponseDto.java | 21 ++++++ .../dto/EventSubmissionRejectRequestDto.java | 26 +++++++ .../entity/EventSubmissionStatusHistory.java | 25 ++++++- .../repository/EventSubmissionRepository.java | 36 ++++++++++ .../service/EventSubmissionCells.java | 46 ++++++++++++ .../service/EventSubmissionImageStore.java | 37 ++++++++++ .../service/EventSubmissionLocationView.java | 22 +----- .../EventSubmissionImageStoreTest.java | 72 +++++++++++++++++++ 8 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionApproveResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectRequestDto.java create mode 100644 src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionCells.java diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionApproveResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionApproveResponseDto.java new file mode 100644 index 00000000..4ad67094 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionApproveResponseDto.java @@ -0,0 +1,21 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 승인 결과 (MSG-500 §API 3). 승인 입력이 전부 저장된 신청에서 나오므로 요청 본문이 없고, 응답도 승인 + * 번호와 전이 결과만 담는다 — 산출물(미션 id)은 관리자 화면이 쓰지 않는 내부 링크라 싣지 않는다. + */ +@Schema(description = "행사 등재 신청 승인 결과", + requiredProperties = {"submissionId", "approvalNo", "status"}) +public record EventSubmissionApproveResponseDto( + @Schema(description = "승인한 신청 id", example = "7") + Long submissionId, + + @Schema(description = "부여된 승인 번호", example = "APR-2026-0001") + String approvalNo, + + @Schema(description = "전이 후 상태", example = "APPROVED") + String status +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectRequestDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectRequestDto.java new file mode 100644 index 00000000..9bd9c55e --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/EventSubmissionRejectRequestDto.java @@ -0,0 +1,26 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; + +/** + * 반려 요청 (MSG-500 §API 4). 항목 코드와 자유 서술이 둘 다 필수다 — 화면이 항목 카드와 본문을 + * 함께 그리고, "반려 행에는 사유 두 벌이 있다"는 DDL CHECK 도 그 형태를 요구한다. + * + *

코드 목록에 Bean Validation 을 걸지 않는 것은 의도다: 빈 배열·허용 밖 값·중복을 전부 도메인 코드 + * 13454 로 내야 하는데, {@code @NotEmpty} 나 enum 역직렬화 실패에 맡기면 공통 400 으로 뭉개진다. + * 검증은 서비스가 한다. + */ +@Schema(description = "행사 등재 신청 반려 요청", requiredProperties = {"reasonCodes", "reasonText"}) +public record EventSubmissionRejectRequestDto( + @Schema(description = "반려 항목 코드 1개 이상 (PERIOD, AREA, IMAGE, INFO — 중복 불가)", + example = "[\"AREA\", \"INFO\"]") + List reasonCodes, + + @Schema(description = "반려 사유 본문", example = "신청 영역이 행사 실제 범위보다 넓습니다") + @NotBlank String reasonText +) { +} 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 index 8686360e..b1b606ad 100644 --- a/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatusHistory.java +++ b/src/main/java/com/msg/fillmap/event/submission/entity/EventSubmissionStatusHistory.java @@ -53,9 +53,32 @@ public class EventSubmissionStatusHistory { /** 심사 중 전이 기록 (제출·재제출). 사유가 없는 상태 행이라 인자에도 사유가 없다. */ public static EventSubmissionStatusHistory inReview(Long eventSubmissionId, LocalDateTime now) { + return of(eventSubmissionId, EventSubmissionStatus.IN_REVIEW, now); + } + + /** 승인 전이 기록 (MSG-500). 승인에도 사유가 없다 — 사유는 반려 행만 갖는다(DDL CHECK). */ + public static EventSubmissionStatusHistory approved(Long eventSubmissionId, LocalDateTime now) { + return of(eventSubmissionId, EventSubmissionStatus.APPROVED, now); + } + + /** + * 반려 전이 기록 (MSG-500 FR-19) — 반려 사유의 저장 원천이 이 행 하나다(D-3). 항목 코드 1개 이상과 + * 본문이 둘 다 있어야 하고 그 강제는 DDL CHECK 두 개가 한다. 호출자(승인 서비스)가 코드 목록을 + * 이미 검증해 넘긴다. + */ + public static EventSubmissionStatusHistory rejected(Long eventSubmissionId, + List reasonCodes, String reasonText, LocalDateTime now) { + EventSubmissionStatusHistory history = of(eventSubmissionId, EventSubmissionStatus.REJECTED, now); + history.reasonCodes = reasonCodes; + history.reasonText = reasonText; + return history; + } + + private static EventSubmissionStatusHistory of(Long eventSubmissionId, EventSubmissionStatus status, + LocalDateTime now) { EventSubmissionStatusHistory history = new EventSubmissionStatusHistory(); history.eventSubmissionId = eventSubmissionId; - history.status = EventSubmissionStatus.IN_REVIEW; + history.status = status; history.createdAt = now; return history; } 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 index 3e69240d..eaf56760 100644 --- a/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java +++ b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java @@ -82,10 +82,46 @@ Page findAdminPageByStatus( @EntityGraph(attributePaths = "locations") Optional findWithLocationsById(Long id); + /** + * 승인 전이 (MSG-500 D-1) — 상태 확인과 갱신이 한 문장이라 동시 승인 두 건이 둘 다 IN_REVIEW 를 + * 관찰하고 둘 다 성공하는 경합이 성립하지 않는다. 승인 번호를 같은 문장에서 채우는 것은 DDL CHECK + * (chk_event_sub_approval)가 "승인 행 = 승인 번호 있는 행"을 요구하기 때문이다. + *

+ * 영향 행이 0이면 호출자가 재조회로 가른다 — 행이 없으면 13430, 있으면 13450(동시 승인의 패자 포함)이다. + * {@code clearAutomatically} 는 벌크 UPDATE 가 우회한 영속성 컨텍스트의 스테일 스냅숏을 비운다 — + * 호출자가 산출물 링크를 달기 전에 새 상태를 다시 읽어야 하기 때문이다. + */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE EventSubmission s + SET s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED, + s.approvalNo = :approvalNo, + s.updatedAt = :now + WHERE s.id = :id + AND s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.IN_REVIEW + """) + int approveInReview(@Param("id") Long id, @Param("approvalNo") String approvalNo, + @Param("now") LocalDateTime now); + + /** 반려 전이 (MSG-500 §반려) — 승인과 같은 원자 전이다. 사유는 신청 행이 아니라 이력 행에 쌓인다(D-3). */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE EventSubmission s + SET s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.REJECTED, + s.updatedAt = :now + WHERE s.id = :id + AND s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.IN_REVIEW + """) + int rejectInReview(@Param("id") Long id, @Param("now") LocalDateTime now); + /** * 신청 번호의 순번 (D-4). 연도별 리셋이 없는 전역 증가값이라 리셋 기계 없이 UNIQUE 가 보장된다. * PostgreSQL 시퀀스 함수라 native 다 (JPA 표준에 동등 표현이 없다). */ @Query(value = "SELECT nextval('event_submission_no_seq')", nativeQuery = true) long nextSubmissionSequence(); + + /** 승인 번호의 순번 (MSG-500 D-4) — 신청 번호와 같은 구조의 전역 시퀀스다(연도별 리셋 없음). */ + @Query(value = "SELECT nextval('event_submission_approval_no_seq')", nativeQuery = true) + long nextApprovalSequence(); } diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionCells.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionCells.java new file mode 100644 index 00000000..06a787f1 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionCells.java @@ -0,0 +1,46 @@ +package com.msg.fillmap.event.submission.service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import com.msg.fillmap.event.submission.entity.EventSubmissionAreaRect; +import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; +import com.msg.fillmap.global.geo.AreaCell; + +/** + * 저장된 신청 사각형을 격자 집합으로 펼치는 한 곳 (MSG-498 D-7 합집합 판정의 재사용). 겹치는 사각형을 + * Set 이 자연히 한 번만 남기므로 "표현이 아니라 기하로 결정된다"는 성질이 상세 표시(칸 수)와 승인 + * 산출물(판정 격자)에서 같게 성립한다 — 두 곳이 서로 다른 방식으로 세면 콘솔이 보여준 칸 수와 실제로 + * 깔린 격자가 어긋난다. + * + *

저장된 사각형은 접수 검증(위치당 81칸 상한·인덱스 범위)을 이미 통과했으므로 여기서는 다시 검증하지 + * 않는다 — 검증은 입구인 접수 경로 하나가 맡는다. + */ +final class EventSubmissionCells { + + private EventSubmissionCells() { + } + + /** 위치 하나의 셀 집합. */ + static Set of(EventSubmissionLocation location) { + Set cells = new LinkedHashSet<>(); + for (EventSubmissionAreaRect rect : location.getRects()) { + for (int gridY = rect.getMinGridY(); gridY <= rect.getMaxGridY(); gridY++) { + for (int gridX = rect.getMinGridX(); gridX <= rect.getMaxGridX(); gridX++) { + cells.add(new AreaCell(gridY, gridX)); + } + } + } + return cells; + } + + /** 전 위치의 셀 합집합 — 위치끼리 겹쳐도 한 번만 남는다(승인 미션의 판정 격자, MSG-500 D-2). */ + static Set union(List locations) { + Set cells = new LinkedHashSet<>(); + for (EventSubmissionLocation location : locations) { + cells.addAll(of(location)); + } + return cells; + } +} 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 index 762d3320..0e77674b 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java @@ -62,6 +62,9 @@ public class EventSubmissionImageStore { private static final String PENDING_PREFIX = "event-submissions/pending/"; private static final String ORIGINAL_PREFIX = "event-submissions/original/"; + /** 승인 미션 카드가 공개 URL 로 읽는 자리 (MSG-500 D-6). 참여형 위치용 프리픽스는 그 분기와 함께 온다. */ + private static final String MISSION_PUBLIC_PREFIX = "missions/org-submission/"; + private final S3Presigner s3Presigner; private final S3Client s3Client; private final AwsProperties awsProperties; @@ -131,6 +134,26 @@ public String confirm(Long userId, String pendingKey) { return originalKey; } + /** + * 승인 미션의 공개 사본 (MSG-500 D-6). 확정본 프리픽스에는 공개 읽기가 없어 presigned GET 으로만 열리는데, + * 미션 카드는 사용자 대면 목록이라 공개 URL 로 소비된다(image_url 선례) — 그래서 승인 시점에 공개 + * 프리픽스로 복사한다. 원본 확정본은 지우지 않는다: 심사 상세와 콘솔 상세가 계속 읽는다. + *

+ * 보상 등록이 복사 호출보다 앞인 것과 롤백 시 삭제하는 이유는 {@link #confirm} 과 같다 — 승인 + * 트랜잭션이 뒤에서 실패하면 아무도 참조하지 않는 사본이 남는다. + */ + public String copyToMissionImage(String originalKey) { + String publicKey = "%s%s.%s".formatted(MISSION_PUBLIC_PREFIX, UUID.randomUUID(), extensionOf(originalKey)); + deleteOnRollback(publicKey); + s3Client.copyObject(CopyObjectRequest.builder() + .sourceBucket(awsProperties.s3().bucket()) + .sourceKey(originalKey) + .destinationBucket(awsProperties.s3().bucket()) + .destinationKey(publicKey) + .build()); + return publicKey; + } + /** 열람용 presigned GET (§API 4). 발급 방식·TTL 은 썸네일 선례 상수를 그대로 쓴다. */ public String presignGet(String imageKey) { return thumbnailUrlPresigner.presign(imageKey); @@ -143,6 +166,20 @@ public void deleteAfterCommit(String imageKey) { } } + /** + * 저장 키의 확장자. 확정본 키는 이 클래스가 만든 값이라(화이트리스트 확장자 + uuid) 형식이 보장되지만, + * 그래도 없으면 jpg 로 떨어뜨린다 — 승인이 확장자 때문에 실패하는 것보다 낫고, 실제 바이트는 복사로 + * 그대로 옮겨진다. + */ + private String extensionOf(String s3Key) { + int extensionAt = s3Key.lastIndexOf('.'); + if (extensionAt < 0) { + return "jpg"; + } + String extension = s3Key.substring(extensionAt + 1).toLowerCase(); + return ALLOWED_IMAGE_TYPES.containsKey(extension) ? extension : "jpg"; + } + private String validatePendingKey(Long userId, String pendingKey) { if (!pendingKey.startsWith("%s%d/".formatted(PENDING_PREFIX, userId))) { throw new ApiException(EventErrorCode.SUBMISSION_IMAGE_KEY_INVALID); diff --git a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java index bf5f09be..f35b900f 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionLocationView.java @@ -1,10 +1,8 @@ package com.msg.fillmap.event.submission.service; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; import org.springframework.stereotype.Component; @@ -12,9 +10,7 @@ import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; import com.msg.fillmap.event.submission.dto.EventSubmissionLocationResponseDto; -import com.msg.fillmap.event.submission.entity.EventSubmissionAreaRect; import com.msg.fillmap.event.submission.entity.EventSubmissionLocation; -import com.msg.fillmap.global.geo.AreaCell; import com.msg.fillmap.grid.GridEncoder; import com.msg.fillmap.grid.GridEncoder.GridIndex; import com.msg.fillmap.grid.service.GridQueryService; @@ -56,25 +52,9 @@ public List describe(List rects) { - Set cells = new LinkedHashSet<>(); - for (EventSubmissionAreaRect rect : rects) { - for (int gridY = rect.getMinGridY(); gridY <= rect.getMaxGridY(); gridY++) { - for (int gridX = rect.getMinGridX(); gridX <= rect.getMaxGridX(); gridX++) { - cells.add(new AreaCell(gridY, gridX)); - } - } - } - return cells.size(); - } } 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 index b271368e..875cf254 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStoreTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStoreTest.java @@ -6,12 +6,17 @@ 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 org.junit.jupiter.api.AfterEach; 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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionSynchronizationUtils; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -190,4 +195,71 @@ class Confirm { .hasFieldOrPropertyWithValue("errorCode", EventErrorCode.SUBMISSION_IMAGE_TOO_LARGE); } } + + @Nested + @DisplayName("승인 공개 사본 (MSG-500)") + class MissionCopy { + + private static final String ORIGINAL_KEY = "event-submissions/original/42/8b1c.png"; + + @BeforeEach + void 트랜잭션을_연다() { + // 보상 등록은 동기화가 살아 있을 때만 걸린다 — 승인 트랜잭션 안에서 도는 실제 상황을 흉내낸다. + TransactionSynchronizationManager.initSynchronization(); + } + + @AfterEach + void 트랜잭션을_닫는다() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인 사본은 미션 공개 프리픽스로 복사되고 확장자를 유지한다 — 원본은 남는다") + void 승인_이미지는_공개_프리픽스로_복사된다() { + 업로드된_객체가_있다(1024L); + + String publicKey = imageStore.copyToMissionImage(ORIGINAL_KEY); + + assertThat(publicKey).matches("missions/org-submission/[0-9a-f-]{36}\\.png"); + ArgumentCaptor captor = ArgumentCaptor.forClass(CopyObjectRequest.class); + then(s3Client).should().copyObject(captor.capture()); + assertThat(captor.getValue().sourceKey()).isEqualTo(ORIGINAL_KEY); + assertThat(captor.getValue().destinationKey()).isEqualTo(publicKey); + // 확정본은 심사·콘솔 상세가 계속 읽는다 — 복사가 원본을 지우지 않는다. + then(s3Client).should(never()).deleteObject(any(DeleteObjectRequest.class)); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인이 롤백되면 방금 만든 공개 사본이 정리된다 — 아무도 참조하지 않는 고아를 남기지 않는다") + void 승인이_롤백되면_공개_사본이_정리된다() { + 업로드된_객체가_있다(1024L); + String publicKey = imageStore.copyToMissionImage(ORIGINAL_KEY); + + TransactionSynchronizationUtils.invokeAfterCompletion( + TransactionSynchronizationManager.getSynchronizations(), + TransactionSynchronization.STATUS_ROLLED_BACK); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + then(s3Client).should().deleteObject(captor.capture()); + assertThat(captor.getValue().key()).isEqualTo(publicKey); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("커밋되면 공개 사본을 지우지 않는다 — 미션 카드가 그 주소를 읽는다") + void 커밋되면_공개_사본을_지우지_않는다() { + 업로드된_객체가_있다(1024L); + imageStore.copyToMissionImage(ORIGINAL_KEY); + + TransactionSynchronizationUtils.invokeAfterCompletion( + TransactionSynchronizationManager.getSynchronizations(), + TransactionSynchronization.STATUS_COMMITTED); + + then(s3Client).should(never()).deleteObject(any(DeleteObjectRequest.class)); + } + } } From e668c1a9d0a722e445e7fbbdbd8e5a3e2d474c01 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 11:35:05 +0900 Subject: [PATCH 10/35] =?UTF-8?q?MSG-500=20feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=8B=A0=EC=B2=AD=EC=9D=84=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=ED=95=B4=20=EB=AF=B8=EC=85=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=93=B1=EC=9E=AC=ED=95=98=EA=B1=B0=EB=82=98=20=EB=B0=98?= =?UTF-8?q?=EB=A0=A4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인은 선검사, 전이와 승인 번호 한 문장 UPDATE, 재로드 후 등재 순서의 한 트랜잭션이다. 검증 19건. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../AdminEventSubmissionController.java | 43 ++ .../service/AdminEventSubmissionService.java | 185 +++++++- .../submission/EventSubmissionFixtures.java | 16 + .../AdminEventSubmissionReviewTest.java | 402 ++++++++++++++++++ ...ventSubmissionApprovalConcurrencyTest.java | 231 ++++++++++ 5 files changed, 873 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java create mode 100644 src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java diff --git a/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java b/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java index eb411059..c9bb65aa 100644 --- a/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java +++ b/src/main/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionController.java @@ -4,8 +4,12 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -14,6 +18,8 @@ import com.msg.fillmap.event.submission.dto.AdminEventSubmissionDetailResponseDto; import com.msg.fillmap.event.submission.dto.AdminEventSubmissionListResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionRejectRequestDto; import com.msg.fillmap.event.submission.service.AdminEventSubmissionService; import com.msg.fillmap.response.SuccessResponse; @@ -68,4 +74,41 @@ public SuccessResponse getSubmission( ) { return SuccessResponse.of(adminEventSubmissionService.getSubmission(submissionId)); } + + @Operation( + summary = "신청 승인", + description = "신청을 승인하고 승인 번호(APR-2026-XXXX 꼴)를 부여한다. 요청 본문이 없다 — 승인 입력은 " + + "전부 저장된 신청에서 나온다.\n\n" + + "지역축제는 지도 홈 축제 칩 미션으로, 팝업스토어는 팝업 칩 미션으로 등재되어 " + + "재기동이나 재시드 없이 기존 미션 조회 API 에 즉시 나타난다. 판정 격자는 신청한 전 위치의 셀 " + + "합집합이고 대표 격자는 서버가 그 합집합에서 다시 계산한다.\n\n" + + "전이·이력·미션 등재가 한 트랜잭션이라 절반만 반영되는 결과가 없다. 응답을 받지 못했으면 " + + "상세를 재조회해 APPROVED 인지 확인한다 — 같은 신청을 다시 승인하면 409(13450) 다.\n\n" + + "없는 신청은 404(13430), 심사 중이 아니거나 동시 승인의 늦은 쪽은 409(13450), 종료일이 이미 " + + "지난 신청은 409(13451) 다." + ) + @PostMapping("/{submissionId}/approve") + public SuccessResponse approve( + @Parameter(description = "승인할 신청 id", example = "7") @PathVariable Long submissionId + ) { + return SuccessResponse.of(adminEventSubmissionService.approve(submissionId)); + } + + @Operation( + summary = "신청 반려", + description = "신청을 반려하고 항목 코드와 사유를 이력에 남긴다. 항목 코드는 1개 이상이어야 하고 " + + "PERIOD·AREA·IMAGE·INFO 만 쓸 수 있으며 중복은 허용하지 않는다. 사유 본문도 필수다.\n\n" + + "메일은 발송되지 않는다 — 행사 운영자가 콘솔 상세에서 항목과 사유를 보고 고쳐서 다시 낸다. " + + "승인 시 격자 겹침(13452)을 만난 경우의 다음 조작도 이 반려이고, 항목 코드는 AREA 다.\n\n" + + "없는 신청은 404(13430), 심사 중이 아니면 409(13450), 항목 코드가 비었거나 허용 밖이거나 " + + "중복이면 400(13454) 이다." + ) + @PostMapping("/{submissionId}/reject") + public SuccessResponse reject( + @Parameter(description = "반려할 신청 id", example = "7") @PathVariable Long submissionId, + @Valid @RequestBody EventSubmissionRejectRequestDto request + ) { + adminEventSubmissionService.reject(submissionId, request); + return new SuccessResponse<>(null); + } } diff --git a/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java b/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java index 4586da2b..3cf689e5 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java @@ -1,49 +1,93 @@ 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.List; import java.util.Locale; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; - import com.msg.fillmap.event.exception.EventErrorCode; import com.msg.fillmap.event.submission.dto.AdminEventSubmissionDetailResponseDto; import com.msg.fillmap.event.submission.dto.AdminEventSubmissionListResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; import com.msg.fillmap.event.submission.dto.EventSubmissionHistoryResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionRejectRequestDto; 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.EventSubmissionReasonCode; import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatusHistory; 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.mission.entity.MissionType; +import com.msg.fillmap.mission.service.MissionRegistrationService; +import com.msg.fillmap.mission.service.MissionRegistrationService.MissionRegistration; import com.msg.fillmap.user.entity.User; import com.msg.fillmap.user.exception.UserErrorCode; import com.msg.fillmap.user.repository.UserRepository; /** - * 관리자 심사 조회 (MSG-500 §API 1·2). 행사 운영자 조회(MSG-498)와 존재 은닉이 반대다 — 소유 술어가 + * 관리자 심사 (MSG-500 §API 1~4). 행사 운영자 조회(MSG-498)와 존재 은닉이 반대다 — 소유 술어가 * 없고 없는 id 는 그대로 404(13430)다. 관리자는 전체를 보는 주체라 숨길 대상이 없다. * + *

승인·반려는 조건부 UPDATE 하나로 상태를 전이한다 — 읽고 확인한 뒤 쓰면 동시 심사 두 건이 같은 + * IN_REVIEW 를 보고 둘 다 성공한다. 전이·이력·산출물 생성·이미지 복사 보상이 전부 한 트랜잭션이라 + * "미션은 생겼는데 신청은 심사 중" 같은 어긋난 절반이 남지 않는다. + * *

역할 검사는 여기에 없다. SecurityConfig 의 {@code /api/admin/**} matcher 가 필터 단계에서 거른다 * (AdminOrgAccountController 선례). */ @Service -@RequiredArgsConstructor public class AdminEventSubmissionService { private static final int MIN_PAGE_SIZE = 1; private static final int MAX_PAGE_SIZE = 100; + /** 승인 번호의 연도 라벨과 기간 판정의 "오늘"은 사용자·관리자가 읽는 값이라 KST 다 (D-4, 접수 선례). */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final EventSubmissionRepository submissionRepository; private final EventSubmissionStatusHistoryRepository historyRepository; private final EventSubmissionImageStore imageStore; private final EventSubmissionLocationView locationView; private final UserRepository userRepository; + private final MissionRegistrationService missionRegistrationService; + private final Clock clock; + + /** 프로덕션 생성자 — clock 을 systemUTC 로 고정해 전체 생성자로 위임한다 (EventSubmissionServiceImpl 선례). */ + @Autowired + public AdminEventSubmissionService(EventSubmissionRepository submissionRepository, + EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, + EventSubmissionLocationView locationView, UserRepository userRepository, + MissionRegistrationService missionRegistrationService) { + this(submissionRepository, historyRepository, imageStore, locationView, userRepository, + missionRegistrationService, Clock.systemUTC()); + } + + public AdminEventSubmissionService(EventSubmissionRepository submissionRepository, + EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, + EventSubmissionLocationView locationView, UserRepository userRepository, + MissionRegistrationService missionRegistrationService, Clock clock) { + this.submissionRepository = submissionRepository; + this.historyRepository = historyRepository; + this.imageStore = imageStore; + this.locationView = locationView; + this.userRepository = userRepository; + this.missionRegistrationService = missionRegistrationService; + this.clock = clock; + } /** * 심사 큐 (§API 1). 상태 필터 기준 접수 최신순 오프셋 페이징이고, 탭 뱃지용 건수 3종은 필터와 무관한 @@ -103,6 +147,139 @@ public AdminEventSubmissionDetailResponseDto getSubmission(Long submissionId) { submission.getUpdatedAt()); } + /** + * 승인 (§API 3, D-1). 실행 순서가 계약이다. + *

+ * ① 신청을 읽어 상태와 기간을 먼저 본다 — 여기서 걸러야 승인 번호 시퀀스를 헛되이 소비하지 않는다 + * (시퀀스는 롤백해도 되돌아오지 않는다). ② 조건부 UPDATE 로 IN_REVIEW → APPROVED 전이와 승인 번호 + * 부여를 한 문장에 담는다 — 이것이 동시 승인의 진짜 판정이고 ①은 빠른 거절일 뿐이다. ③ 영향 행이 + * 0이면 재조회로 가른다: 없으면 13430, 있으면 13450(동시 승인의 패자 포함). ④ 이겼으면 이력을 쌓고 + * 애그리거트를 재로드해 산출물을 만든다 — 벌크 UPDATE 가 영속성 컨텍스트를 우회했으므로 ① 에서 + * 읽은 엔티티는 스테일한 IN_REVIEW 다. + *

+ * 전이·이력·미션 등재·이미지 복사 보상이 전부 이 트랜잭션 하나다. 뒤에서 실패하면 미션도 승인 상태도 + * 함께 사라지고 공개 이미지 사본은 롤백 보상이 지운다. + */ + @Transactional + public EventSubmissionApproveResponseDto approve(Long submissionId) { + EventSubmission submission = findForReview(submissionId); + requirePeriodNotPassed(submission); + + LocalDateTime now = LocalDateTime.now(clock); + String approvalNo = nextApprovalNo(); + if (submissionRepository.approveInReview(submissionId, approvalNo, now) == 0) { + throw transitionFailure(submissionId); + } + historyRepository.save(EventSubmissionStatusHistory.approved(submissionId, now)); + + EventSubmission approved = submissionRepository.findWithLocationsById(submissionId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + approved.linkPublishedMission(publish(approved)); + return new EventSubmissionApproveResponseDto(submissionId, approvalNo, + EventSubmissionStatus.APPROVED.name()); + } + + /** + * 반려 (§API 4, FR-19). 승인과 같은 원자 전이이고, 사유는 신청 행이 아니라 이력의 반려 행에 쌓인다 + * (D-3 저장 원천 단일). 항목 코드 검증을 전이보다 먼저 하는 것은 잘못된 요청이 상태를 바꾸지 못하게 + * 하기 위해서다. 메일은 보내지 않는다 — 행사 운영자는 콘솔 상세에서 항목과 사유를 본다(D-5). + */ + @Transactional + public void reject(Long submissionId, EventSubmissionRejectRequestDto request) { + List reasonCodes = parseReasonCodes(request.reasonCodes()); + + LocalDateTime now = LocalDateTime.now(clock); + if (submissionRepository.rejectInReview(submissionId, now) == 0) { + throw transitionFailure(submissionId); + } + historyRepository.save( + EventSubmissionStatusHistory.rejected(submissionId, reasonCodes, request.reasonText(), now)); + } + + /** + * 승인 산출물 (D-2) — 지역축제·팝업스토어는 미션 1건이 된다. 위치가 여럿이어도 칩 카드는 하나가 + * 자연스럽다. + *

+ * switch 에 default 를 두지 않는 것은 의도된 미구현 가드다: MSG-502 가 참여형 유형 값을 추가하는 + * 순간 이 분기가 컴파일되지 않아, 참여형 승인이 채워지지 않은 채 미션으로 잘못 흘러가는 경로가 만들어질 + * 수 없다. 지금은 V49 CHECK 가 두 값만 허용해 도달 자체가 불가능하다. + */ + private long publish(EventSubmission submission) { + MissionType missionType = switch (submission.getType()) { + case FESTIVAL -> MissionType.EVENT; // 지도 홈 "축제" 칩 — 행사 카테고리 "이벤트"와 무관하다 + case POPUP -> MissionType.POPUP; + }; + // 운영 시간은 팝업만 값을 갖는다 — 접수 검증이 축제의 운영 시간을 애초에 거부하므로 분기가 필요 없다. + return missionRegistrationService.register(new MissionRegistration( + missionType, + submission.getTitle(), + submission.getStartsOn(), + submission.getEndsOn(), + submission.getSubmissionNo(), + submission.getDescription(), + submission.getOperatingHours(), + imageStore.copyToMissionImage(submission.getImageKey()), + EventSubmissionCells.union(submission.getLocations()).stream().map(AreaCell::gridId).toList())); + } + + /** 심사 대상 조회 — 없으면 13430, 심사 중이 아니면 13450 (전이 전 빠른 거절). */ + private EventSubmission findForReview(Long submissionId) { + EventSubmission submission = submissionRepository.findWithLocationsById(submissionId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + if (submission.getStatus() != EventSubmissionStatus.IN_REVIEW) { + throw new ApiException(EventErrorCode.SUBMISSION_STATUS_NOT_REVIEWABLE); + } + return submission; + } + + /** + * 기간 가드 (D-1) — 접수 검증(MSG-498 D-6)과 같은 규칙을 심사 지연 구간에 다시 건다. 끝난 행사를 + * 승인하면 지도에 실릴 것이 없는 미션이 생긴다. "오늘"은 관리자가 생각하는 날짜라 KST 다. + */ + private void requirePeriodNotPassed(EventSubmission submission) { + if (submission.getEndsOn().isBefore(LocalDate.now(clock.withZone(KST)))) { + throw new ApiException(EventErrorCode.SUBMISSION_PERIOD_PASSED); + } + } + + /** 조건부 UPDATE 가 0행일 때의 이유 — 그 사이 사라졌으면 13430, 남아 있으면 상태 위반이다. */ + private ApiException transitionFailure(Long submissionId) { + submissionRepository.findById(submissionId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + return new ApiException(EventErrorCode.SUBMISSION_STATUS_NOT_REVIEWABLE); + } + + /** + * 반려 항목 코드 파싱 (§API 4) — 빈 목록·허용 밖 값·중복을 전부 13454 로 낸다. Jackson enum 역직렬화에 + * 맡기지 않는 이유는 그 실패가 공통 400 으로 뭉개져 "무엇이 잘못됐는지"를 화면이 구분하지 못해서다. + */ + private List parseReasonCodes(List rawCodes) { + if (rawCodes == null || rawCodes.isEmpty()) { + throw new ApiException(EventErrorCode.INVALID_REJECT_REASON); + } + List codes = new ArrayList<>(); + for (String rawCode : rawCodes) { + if (rawCode == null) { + throw new ApiException(EventErrorCode.INVALID_REJECT_REASON); + } + try { + codes.add(EventSubmissionReasonCode.valueOf(rawCode.toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + throw new ApiException(EventErrorCode.INVALID_REJECT_REASON, e); + } + } + if (codes.size() != Set.copyOf(codes).size()) { + throw new ApiException(EventErrorCode.INVALID_REJECT_REASON); + } + return codes; + } + + /** {@code APR-{KST 연도}-{4자리 0패딩 순번}} (D-4) — 신청 번호와 같은 구조의 전역 시퀀스다. */ + private String nextApprovalNo() { + return "APR-%d-%04d".formatted( + LocalDate.now(clock.withZone(KST)).getYear(), submissionRepository.nextApprovalSequence()); + } + /** * 노출 영역 (§API 2) — 전 위치 사각형을 감싸는 경계 사각형이다. 셀을 전개하지 않고 사각형의 min·max 만 * 접는 것으로 충분하다: 합집합의 경계는 원본 사각형들의 경계와 같다. 조회 시점 계산이라 저장하지 않는다. diff --git a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java index 08ec7f7b..cf4fd2d9 100644 --- a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java +++ b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java @@ -46,6 +46,22 @@ public static String pastFestivalBody(long userId, String... locations) { "광안리해수욕장 일원에서 열렸던 부산 대표 불꽃 축제", pendingKey(userId), locations); } + /** 팝업 신청 — 유형별 필수 항목이 축제와 반대다(운영 시간 있음·주요 프로그램 없음). */ + public static String popupBody(long userId, String... locations) { + return """ + { + "type": "POPUP", + "title": "필맵 팝업스토어", + "organizerName": "필맵 주식회사", + "startsOn": "2026-11-07", + "endsOn": "2026-11-07", + "operatingHours": "11:00 ~ 20:00", + "description": "광안리 해변가에서 여는 필맵 브랜드 팝업스토어", + "imageS3Key": "%s", + "locations": [%s] + }""".formatted(pendingKey(userId), String.join(", ", locations)); + } + public static String festivalBodyWithTitle(long userId, String title, String... locations) { return body(title, "2026-11-07", "2026-11-07", "광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제", pendingKey(userId), locations); diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java new file mode 100644 index 00000000..a2c4d90c --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java @@ -0,0 +1,402 @@ +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 com.msg.fillmap.event.submission.EventSubmissionFixtures.popupBody; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.rect; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.atLeastOnce; +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.time.LocalDate; +import java.time.LocalDateTime; +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.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.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.repository.EventSubmissionRepository; +import com.msg.fillmap.mission.entity.Mission; +import com.msg.fillmap.mission.entity.MissionGrid; +import com.msg.fillmap.mission.entity.MissionType; +import com.msg.fillmap.mission.repository.MissionGridRepository; +import com.msg.fillmap.mission.repository.MissionRepository; +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-500 §API 3·4, 실 DB). 검증 대상이 조건부 UPDATE 원자 전이, 미션 1건과 판정 격자 + * 삽입, 지연 FK 를 통과하는 대표 격자, 한 트랜잭션 안의 이력·산출물이라 전부 DB 동작이다. + *

+ * 커밋 후 동작(스냅숏 무효화)은 여기서 검증하지 않는다 — {@code @Transactional} 롤백 격리에서는 커밋이 + * 없어 afterCommit 훅이 아예 실행되지 않기 때문이고, 무효화 자체는 MissionQueryServiceCacheTest 가 + * 단위로 본다. 여기서 보는 것은 "DB 에 활성 미션으로 들어갔는가"다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("관리자 행사 등재 심사 승인·반려 (MSG-500, 실 DB)") +class AdminEventSubmissionReviewTest { + + private static final String ADMIN_URL = "/api/admin/event-submissions"; + private static final String ORG_URL = "/api/org/event-submissions"; + + /** 광안리 3행 7열을 3열 + 4열로 쪼갠 두 위치 — 합집합이 다시 홀수 직사각형이라 정중앙이 결정적이다. */ + private static final String WEST_RECT = rect(16859, 16861, 11509, 11511); + private static final String EAST_RECT = rect(16859, 16861, 11512, 11515); + private static final String UNION_CENTER = "16860_11512"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private MissionRepository missionRepository; + + @Autowired + private MissionGridRepository missionGridRepository; + + @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 admin; + + @BeforeEach + void setUp() { + organizer = saveUser(UserRole.ORG); + admin = saveUser(UserRole.ADMIN); + 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 saveUser(UserRole role) { + User user = User.createLocalUser("review-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", role); + ReflectionTestUtils.setField(user, "orgName", "부산광역시 부산진구청"); + return userRepository.saveAndFlush(user); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + + private long 신청한다(String body) throws Exception { + String response = mockMvc.perform(post(ORG_URL) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return ((Number) JsonPath.read(response, "$.data.id")).longValue(); + } + + private long 축제를_신청한다() throws Exception { + return 신청한다(festivalBody(organizer.getId(), location(GWANGALLI_RECT))); + } + + private ResultActions 승인한다(long submissionId) throws Exception { + entityManager.flush(); + return mockMvc.perform(post(ADMIN_URL + "/" + submissionId + "/approve") + .header(HttpHeaders.AUTHORIZATION, bearer(admin))); + } + + private ResultActions 반려한다(long submissionId, String body) throws Exception { + entityManager.flush(); + return mockMvc.perform(post(ADMIN_URL + "/" + submissionId + "/reject") + .header(HttpHeaders.AUTHORIZATION, bearer(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } + + private String rejectBody(String reasonCodes, String reasonText) { + return """ + {"reasonCodes": %s, "reasonText": "%s"}""".formatted(reasonCodes, reasonText); + } + + private EventSubmission 저장된_신청(long submissionId) { + entityManager.flush(); + entityManager.clear(); + return submissionRepository.findById(submissionId).orElseThrow(); + } + + /** 승인 산출물 미션 — 신청 행의 링크로 찾는다(승인이 그 링크를 남기는 것 자체가 계약이다). */ + private Mission 승인_미션(long submissionId) { + Long missionId = 저장된_신청(submissionId).getPublishedMissionId(); + assertThat(missionId).isNotNull(); + return missionRepository.findById(missionId).orElseThrow(); + } + + @Nested + @DisplayName("승인 — 미션 등재") + class ApproveToMission { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("축제 신청을 승인하면 축제 칩 미션이 생기고 활성 조회에 바로 잡힌다") + void 축제_신청을_승인하면_축제_칩_미션이_생기고_활성_조회에_바로_잡힌다() throws Exception { + long id = 축제를_신청한다(); + + 승인한다(id).andExpect(status().isOk()); + + Mission mission = 승인_미션(id); + assertThat(mission.getType()).isEqualTo(MissionType.EVENT); + assertThat(mission.getTitle()).isEqualTo("부산불꽃축제"); + assertThat(mission.getSource()).isEqualTo("ORG_SUBMISSION"); + assertThat(mission.getSourceKey()).isEqualTo(저장된_신청(id).getSubmissionNo()); + assertThat(mission.getTargetCount()).isEqualTo(1); + // KST 날짜 라벨 → UTC 순간 (축제 시더와 같은 규칙): 시작 KST 00:00, 끝 KST 23:59:59. + assertThat(mission.getStartAt()).isEqualTo(LocalDateTime.of(2026, 11, 6, 15, 0, 0)); + assertThat(mission.getEndAt()).isEqualTo(LocalDateTime.of(2026, 11, 7, 14, 59, 59)); + assertThat(mission.getDescription()).isEqualTo("광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제"); + assertThat(mission.getOperationTime()).isNull(); + + // 재시드·재기동 없이 기존 활성 조회 술어에 그대로 잡힌다 (기간 안의 시각으로 확인). + assertThat(missionRepository.findActive(LocalDateTime.of(2026, 11, 7, 3, 0))) + .extracting(Mission::getId) + .contains(mission.getId()); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("팝업 신청을 승인하면 팝업 미션이 생기고 운영 시간이 함께 넘어간다") + void 팝업_신청을_승인하면_팝업_미션이_생긴다() throws Exception { + long id = 신청한다(popupBody(organizer.getId(), location(GWANGALLI_RECT))); + + 승인한다(id).andExpect(status().isOk()); + + Mission mission = 승인_미션(id); + assertThat(mission.getType()).isEqualTo(MissionType.POPUP); + assertThat(mission.getOperationTime()).isEqualTo("11:00 ~ 20:00"); + assertThat(mission.getTitle()).isEqualTo("필맵 팝업스토어"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("판정 격자는 전 위치 셀 합집합이고 대표 격자는 합집합에 규칙을 다시 적용한 값이다") + void 승인_미션의_격자는_전_위치_셀_합집합이고_대표_격자는_합집합_재적용_값이다() throws Exception { + long id = 신청한다(festivalBody(organizer.getId(), location(WEST_RECT), location(EAST_RECT))); + + 승인한다(id).andExpect(status().isOk()); + + Mission mission = 승인_미션(id); + List grids = missionGridRepository.findByMissionIds(List.of(mission.getId())); + assertThat(grids).hasSize(21); // 3행 3열 + 3행 4열, 겹침 없음 + // 위치별 저장값(3×3 중앙 16860_11510)이 아니라 합집합 3×7 의 정중앙이다. + assertThat(mission.getRepresentativeGridId()).isEqualTo(UNION_CENTER); + assertThat(grids).extracting(MissionGrid::getGridId).contains(UNION_CENTER); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인하면 승인됨 상태와 APR 꼴 승인 번호, 승인 이력이 함께 남는다") + void 승인하면_신청이_승인됨_상태가_되고_승인_번호와_이력이_함께_남는다() throws Exception { + long id = 축제를_신청한다(); + + 승인한다(id) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.approvalNo").value(org.hamcrest.Matchers.matchesPattern( + "APR-\\d{4}-\\d{4,}"))); + + EventSubmission approved = 저장된_신청(id); + assertThat(approved.getStatus().name()).isEqualTo("APPROVED"); + assertThat(approved.getApprovalNo()).matches("APR-\\d{4}-\\d{4,}"); + assertThat(approved.getPublishedMissionId()).isNotNull(); + mockMvc.perform(get(ADMIN_URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(jsonPath("$.data.history.length()").value(2)) + .andExpect(jsonPath("$.data.history[1].status").value("APPROVED")) + .andExpect(jsonPath("$.data.history[1].reasonCodes").isEmpty()); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("대표 이미지는 공개 프리픽스로 복사되고 미션은 그 공개 주소를 갖는다") + void 승인_이미지는_공개_프리픽스로_복사된다() throws Exception { + long id = 축제를_신청한다(); + + 승인한다(id).andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CopyObjectRequest.class); + then(s3Client).should(atLeastOnce()).copyObject(captor.capture()); + CopyObjectRequest publicCopy = captor.getAllValues().stream() + .filter(request -> request.destinationKey().startsWith("missions/org-submission/")) + .findFirst() + .orElseThrow(); + // 원본은 확정본이고 지우지 않는다 — 심사 상세와 콘솔 상세가 계속 읽는다. + assertThat(publicCopy.sourceKey()).startsWith("event-submissions/original/"); + assertThat(승인_미션(id).getImageUrl()).contains("missions/org-submission/"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("이미 승인된 신청의 재승인은 13450 이고 미션이 두 번 생기지 않는다") + void 이미_승인된_신청의_재승인은_13450이다() throws Exception { + long id = 축제를_신청한다(); + 승인한다(id).andExpect(status().isOk()); + Long missionId = 저장된_신청(id).getPublishedMissionId(); + + 승인한다(id) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13450)); + + assertThat(저장된_신청(id).getPublishedMissionId()).isEqualTo(missionId); + assertThat(missionRepository.findBySource("ORG_SUBMISSION").stream() + .filter(mission -> mission.getSourceKey().equals(저장된_신청(id).getSubmissionNo())) + .toList()).hasSize(1); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("종료일이 지난 신청의 승인은 13451 이고 미션도 승인 번호도 생기지 않는다") + void 종료일이_지난_신청의_승인은_13451이다() throws Exception { + long id = 축제를_신청한다(); + // 접수 검증이 과거 행사를 막으므로 심사 지연을 SQL 로 재현한다 — 가드가 겨냥한 상황이 이것이다. + entityManager.flush(); + jdbcTemplate.update("UPDATE event_submissions SET starts_on = ?, ends_on = ? WHERE id = ?", + LocalDate.of(2020, 11, 6), LocalDate.of(2020, 11, 7), id); + entityManager.clear(); + + 승인한다(id) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13451)); + + EventSubmission untouched = 저장된_신청(id); + assertThat(untouched.getStatus().name()).isEqualTo("IN_REVIEW"); + assertThat(untouched.getApprovalNo()).isNull(); + assertThat(untouched.getPublishedMissionId()).isNull(); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("없는 신청의 승인은 13430 이다") + void 없는_신청의_승인은_13430이다() throws Exception { + 승인한다(99_999_999L) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(13430)); + } + } + + @Nested + @DisplayName("반려") + class Reject { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("반려는 항목 코드와 서술이 이력 반려 행으로 남고 상태가 반려됨이 된다") + void 반려는_항목_코드와_서술이_이력_반려_행으로_남고_상태가_반려됨이_된다() throws Exception { + long id = 축제를_신청한다(); + + 반려한다(id, rejectBody("[\"AREA\", \"INFO\"]", "신청 영역이 행사 실제 범위보다 넓습니다")) + .andExpect(status().isOk()); + + assertThat(저장된_신청(id).getStatus().name()).isEqualTo("REJECTED"); + // 행사 운영자 콘솔 상세가 코드 변경 없이 그대로 읽는다 (MSG-498 이 이력 최신 행을 본다). + mockMvc.perform(get(ORG_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("신청 영역이 행사 실제 범위보다 넓습니다")); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("항목 코드가 비었거나 허용 밖이거나 중복이면 13454 이고 상태는 그대로다") + void 항목_코드가_비었거나_허용_밖이면_13454다() throws Exception { + long id = 축제를_신청한다(); + + for (String reasonCodes : List.of("[]", "[\"UNKNOWN\"]", "[\"AREA\", \"AREA\"]")) { + 반려한다(id, rejectBody(reasonCodes, "사유")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(13454)); + } + assertThat(저장된_신청(id).getStatus().name()).isEqualTo("IN_REVIEW"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("심사 중이 아닌 신청의 반려는 13450 이다") + void 심사_중이_아닌_신청의_반려는_13450이다() throws Exception { + long id = 축제를_신청한다(); + 승인한다(id).andExpect(status().isOk()); + + 반려한다(id, rejectBody("[\"INFO\"]", "다시 봐야 합니다")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13450)); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("사유 본문이 비면 공통 400 이다") + void 사유_본문이_비면_공통_400이다() throws Exception { + long id = 축제를_신청한다(); + + 반려한다(id, rejectBody("[\"INFO\"]", " ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(400)); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java new file mode 100644 index 00000000..3b96ccd3 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java @@ -0,0 +1,231 @@ +package com.msg.fillmap.event.submission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +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.transaction.support.TransactionTemplate; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectResponse; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridPoint; +import com.msg.fillmap.grid.dto.ViewportBounds; +import com.msg.fillmap.mission.dto.MissionResponseDto; +import com.msg.fillmap.mission.entity.Mission; +import com.msg.fillmap.mission.entity.MissionType; +import com.msg.fillmap.mission.repository.MissionRepository; +import com.msg.fillmap.mission.service.MissionQueryService; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 승인의 커밋 경계 두 가지 (MSG-500 §API 3, 실 DB): 같은 신청의 동시 승인과, 커밋 후 미션 스냅숏 무효화다. + * + *

둘 다 진짜 커밋이 필요해 클래스 수준 {@code @Transactional} 롤백 격리를 쓸 수 없다 — 동시 승인은 + * 두 트랜잭션이 실제로 경합해야 하고, 스냅숏 무효화는 afterCommit 훅이라 커밋이 없으면 아예 실행되지 + * 않는다(OrgAccountApprovalConcurrencyTest 와 같은 이유). 대신 만든 행을 직접 지워 공유 로컬 DB 를 + * 되돌린다. + * + *

신청은 접수 API 가 아니라 SQL 로 심는다 — 여기서 필요한 것은 "심사 중인 신청 행"뿐이고, 접수 경로를 + * 타면 기간 검증 때문에 오늘을 포함하는 기간을 만들기가 번거롭다(스냅숏 검증은 지금 활성인 미션이어야 + * 성립한다). + */ +@SpringBootTest +@DisplayName("행사 등재 신청 동시 승인과 커밋 후 스냅숏 (MSG-500, 실 DB)") +class EventSubmissionApprovalConcurrencyTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + /** 광안리 3행 7열 — 대표 격자가 정중앙으로 결정되는 홀수 직사각형이다 (접수 픽스처와 같은 영역). */ + private static final int MIN_GRID_Y = 16859; + private static final int MAX_GRID_Y = 16861; + private static final int MIN_GRID_X = 11509; + private static final int MAX_GRID_X = 11515; + private static final String CENTER_GRID_ID = "16860_11512"; + + @Autowired + private AdminEventSubmissionService adminEventSubmissionService; + + @Autowired + private MissionQueryService missionQueryService; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private MissionRepository missionRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TransactionTemplate transactionTemplate; + + @MockitoBean + private S3Client s3Client; + + private Long organizerId; + private Long submissionId; + private String submissionNo; + + @BeforeEach + void setUp() { + given(s3Client.copyObject(any(CopyObjectRequest.class))).willReturn(CopyObjectResponse.builder().build()); + organizerId = transactionTemplate.execute(status -> { + User user = User.createLocalUser("approve-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", UserRole.ORG); + return userRepository.saveAndFlush(user).getId(); + }); + submissionNo = "FM-2026-" + UUID.randomUUID().toString().substring(0, 8); + submissionId = 심사_중_신청을_심는다(); + } + + @AfterEach + void tearDown() { + // 신청을 먼저 지운다 — published_mission_id FK 가 살아 있으면 미션 삭제가 제약 위반이다. + // 위치·사각형·이력은 신청 삭제에 ON DELETE CASCADE 로 따라간다. + jdbcTemplate.update("DELETE FROM event_submissions WHERE id = ?", submissionId); + jdbcTemplate.update("DELETE FROM missions WHERE source = 'ORG_SUBMISSION' AND source_key = ?", submissionNo); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", organizerId); + // 이 클래스가 지운 미션이 스냅숏에 남지 않게 비운다 — 다음 조회가 DB 를 다시 읽는다. + missionQueryService.invalidateSnapshot(); + } + + /** 오늘을 포함하는 기간의 심사 중 신청 1건 — 승인하면 지금 활성인 미션이 된다. */ + private long 심사_중_신청을_심는다() { + LocalDate today = LocalDate.now(KST); + jdbcTemplate.update(""" + INSERT INTO event_submissions + (submission_no, user_id, type, status, title, organizer_name, starts_on, ends_on, + program_description, description, image_key, created_at, updated_at) + VALUES (?, ?, 'FESTIVAL', 'IN_REVIEW', '광안리 불꽃축제', '부산문화관광축제조직위원회', ?, ?, + '멀티불꽃쇼', '광안리 일원에서 열리는 부산 대표 불꽃 축제', + 'event-submissions/original/1/a.jpg', ?, ?) + """, submissionNo, organizerId, today.minusDays(1), today.plusDays(1), + LocalDateTime.now(ZoneOffset.UTC), LocalDateTime.now(ZoneOffset.UTC)); + Long id = jdbcTemplate.queryForObject( + "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); + + jdbcTemplate.update(""" + INSERT INTO event_submission_locations (event_submission_id, display_order, representative_grid_id) + VALUES (?, 1, ?) + """, id, CENTER_GRID_ID); + Long locationId = jdbcTemplate.queryForObject( + "SELECT id FROM event_submission_locations WHERE event_submission_id = ?", Long.class, id); + jdbcTemplate.update(""" + INSERT INTO event_submission_location_rects + (event_submission_location_id, min_grid_y, max_grid_y, min_grid_x, max_grid_x) + VALUES (?, ?, ?, ?, ?) + """, locationId, MIN_GRID_Y, MAX_GRID_Y, MIN_GRID_X, MAX_GRID_X); + return id; + } + + private List 승인_미션들() { + return missionRepository.findBySource("ORG_SUBMISSION").stream() + .filter(mission -> submissionNo.equals(mission.getSourceKey())) + .toList(); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("같은 신청을 동시에 두 번 승인하면 한 번만 반영되고 늦은 쪽은 13450 이다") + void 같은_신청을_동시에_두_번_승인하면_한_번만_반영된다() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + try { + Future first = executor.submit(() -> 승인한다(barrier)); + Future second = executor.submit(() -> 승인한다(barrier)); + + List failures = Stream.of(결과를_받는다(first), 결과를_받는다(second)) + .filter(Objects::nonNull) + .toList(); + + assertThat(failures).hasSize(1).first().isInstanceOf(ApiException.class); + assertThat(((ApiException) failures.getFirst()).getErrorCode()) + .isEqualTo(EventErrorCode.SUBMISSION_STATUS_NOT_REVIEWABLE); + // 미션이 두 번 생기지 않는다 — 조건부 UPDATE 가 1차 방어이고 (source, source_key) 부분 유니크가 백스톱이다. + assertThat(승인_미션들()).hasSize(1); + assertThat(submissionRepository.findById(submissionId).orElseThrow().getApprovalNo()).isNotNull(); + } finally { + executor.shutdownNow(); + } + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인 커밋 후 스냅숏이 무효화되어 TTL 전에도 목록에 실린다") + void 승인_커밋_후_미션_스냅숏이_무효화되어_TTL_전에도_목록에_실린다() { + GridPoint center = GridEncoder.center(CENTER_GRID_ID); + ViewportBounds viewport = new ViewportBounds( + center.lat() - 0.01, center.lon() - 0.01, center.lat() + 0.01, center.lon() + 0.01); + // 캐시를 먼저 적재한다 — 무효화가 없으면 이 스냅숏이 최대 1시간 그대로 쓰인다. + List before = missionQueryService.getMissionsInViewport(viewport, MissionType.EVENT); + + long missionId = 승인_미션들().isEmpty() + ? approveAndReturnMissionId() + : 승인_미션들().getFirst().getId(); + + List after = missionQueryService.getMissionsInViewport(viewport, MissionType.EVENT); + assertThat(before).extracting(MissionResponseDto::missionId).doesNotContain(missionId); + assertThat(after).extracting(MissionResponseDto::missionId).contains(missionId); + } + + private long approveAndReturnMissionId() { + adminEventSubmissionService.approve(submissionId); + return submissionRepository.findById(submissionId).orElseThrow().getPublishedMissionId(); + } + + private EventSubmissionApproveResponseDto 승인한다(CyclicBarrier barrier) throws Exception { + barrier.await(); + return adminEventSubmissionService.approve(submissionId); + } + + /** 성공이면 null, 실패면 그 예외 — 어느 쪽이 이겼는지는 스케줄에 달려 있어 결과로 가른다. */ + private Throwable 결과를_받는다(Future future) throws InterruptedException { + try { + future.get(); + return null; + } catch (ExecutionException e) { + return e.getCause(); + } + } +} From 78762f827dd7c1583e14e2793fc6672da675cde2 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:02:12 +0900 Subject: [PATCH 11/35] =?UTF-8?q?MSG-500=20feat:=20=EB=AF=B8=EC=85=98=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=EC=9D=84=20=EB=8B=A8=EC=9D=BC=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EA=B0=B1=EC=8B=A0=EA=B3=BC=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=ED=9B=84=20=EB=AC=B4=ED=9A=A8=ED=99=94=EB=A1=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hidden_at IS NULL 술어로 첫 중지 시각을 보존하고, 활성·스탬프 판정·격자 역조회에 숨김 필터를 건다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../mission/repository/MissionRepository.java | 23 +++++++++++++++++-- .../service/MissionRegistrationService.java | 10 ++++++++ .../impl/MissionRegistrationServiceImpl.java | 11 +++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/msg/fillmap/mission/repository/MissionRepository.java b/src/main/java/com/msg/fillmap/mission/repository/MissionRepository.java index b2444f8e..7cdb38ca 100644 --- a/src/main/java/com/msg/fillmap/mission/repository/MissionRepository.java +++ b/src/main/java/com/msg/fillmap/mission/repository/MissionRepository.java @@ -23,10 +23,12 @@ public interface MissionRepository extends JpaRepository { * start 전 미래 이벤트는 노출하지 않는다. 양쪽 NULL(코스·지속)은 항상 활성. * ORDER BY m.id 는 표시 정렬이 아니라 결정성 확보다 (MSG-398 D12) — 없으면 스냅샷이 갱신될 때마다 * 목록 카드가 이유 없이 뒤바뀐다. findBySource·findCompleted 가 같은 이유로 id 정렬을 걸어 두었다. + * hidden_at 술어(MSG-500 D-3)는 노출 중지된 승인 미션을 뺀다 — 시드 미션은 전부 NULL 이라 동작 불변이다. */ @Query(""" SELECT m FROM Mission m - WHERE (m.startAt IS NULL OR m.startAt <= :now) + WHERE m.hiddenAt IS NULL + AND (m.startAt IS NULL OR m.startAt <= :now) AND (m.endAt IS NULL OR :now <= m.endAt) ORDER BY m.id """) @@ -81,6 +83,7 @@ List findProgress( FROM mission_grids mg JOIN missions m ON m.id = mg.mission_id WHERE mg.grid_id = :gridId + AND m.hidden_at IS NULL AND (m.start_at IS NULL OR m.start_at <= statement_timestamp() AT TIME ZONE 'UTC') AND (m.end_at IS NULL OR statement_timestamp() AT TIME ZONE 'UTC' <= m.end_at) AND NOT EXISTS (SELECT 1 FROM user_missions um @@ -110,6 +113,7 @@ AND NOT EXISTS (SELECT 1 FROM event_videos ev WHERE ev.video_id = v.id) AND (m.start_at IS NULL OR v.recorded_at >= m.start_at) AND (m.end_at IS NULL OR v.recorded_at <= m.end_at) WHERE m.id IN (:candidateIds) + AND m.hidden_at IS NULL GROUP BY m.id, m.title, m.type, m.target_count HAVING COUNT(DISTINCT v.grid_id) >= m.target_count ORDER BY m.id @@ -128,10 +132,11 @@ List findCompleted( *

* 기간 조건이 없다 — 끝난 축제도 나와야 그 격자를 눌러 무슨 축제였는지 되짚을 수 있다(FR-15). * 어느 미션의 대표 격자도 아닌 값과 격자 포맷이 아닌 문자열은 빈 목록이다(예외 아님). + * 노출 중지된 미션은 빠진다 (MSG-500 D-3) — 목록에서 사라진 미션이 격자 선택으로는 계속 보이면 안 된다. * 표시 정렬(진행 중 → 시작 전 → 종료)은 서비스가 잡는다 — 서버 시각과 견주는 순위라 SQL 에 굳히지 * 않는다. id 정렬은 그 앞의 결정성 확보다(findBySource 와 같은 이유). */ - List findByRepresentativeGridIdOrderById(String representativeGridId); + List findByRepresentativeGridIdAndHiddenAtIsNullOrderById(String representativeGridId); /** * 시더 dedupe DB 대조용 (MSG-224 D3·D7) — 해당 러너 산출물만 기간과 함께 로드해 메모리 대조한다. @@ -165,4 +170,18 @@ AND m.end_at < statement_timestamp() AT TIME ZONE 'UTC' AND NOT EXISTS (SELECT 1 FROM user_missions um WHERE um.mission_id = m.id) """, nativeQuery = true) int deleteEndedBySourceWithoutStamps(@Param("source") String source); + + /** + * 노출 중지 (MSG-500 D-3) — 관리자가 승인 행사를 내릴 때 그 산출물 미션을 숨긴다. 삭제가 아닌 이유는 + * user_missions FK 가 스탬프 걸린 미션의 하드삭제를 막고, 이미 준 스탬프를 뺏는 것이 비회수 원칙과 + * 충돌하기 때문이다. + *

+ * 엔티티를 로드해 더티 체킹으로 바꾸지 않고 단일 컬럼 UPDATE 인 것이 계약이다 — 이 프로젝트는 + * {@code @DynamicUpdate} 를 쓰지 않아 관리 엔티티의 flush 가 전 컬럼 UPDATE 를 내보내고, 그러면 중지가 + * 같은 시각의 다른 갱신(시더 메타데이터 백필·대표 격자 재산출)을 낡은 스냅숏으로 되덮는다. + * {@code hidden_at IS NULL} 술어는 재중지를 0행으로 만들어 첫 중지 시각을 보존한다. + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE Mission m SET m.hiddenAt = :now WHERE m.id = :missionId AND m.hiddenAt IS NULL") + int hide(@Param("missionId") long missionId, @Param("now") LocalDateTime now); } diff --git a/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java b/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java index b88022c5..1cc8af86 100644 --- a/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java +++ b/src/main/java/com/msg/fillmap/mission/service/MissionRegistrationService.java @@ -1,6 +1,7 @@ package com.msg.fillmap.mission.service; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import com.msg.fillmap.mission.entity.MissionType; @@ -24,6 +25,15 @@ public interface MissionRegistrationService { */ long register(MissionRegistration registration); + /** + * 노출 중지 (MSG-500 D-3) — 승인 행사가 내려갈 때 그 산출물 미션을 숨긴다. 호출자의 트랜잭션 안에서 + * 실행되고, 스냅숏 무효화는 register 와 대칭으로 커밋 이후다(무효화가 없으면 중지된 미션이 최대 1시간 + * 계속 그려진다). 이미 숨겨진 미션은 아무 일도 하지 않는다 — 첫 중지 시각이 보존된다. + * + * @param now 중지 시각(UTC) — 시각의 출처는 심사 트랜잭션이라 호출자가 정한다 + */ + void hide(long missionId, LocalDateTime now); + /** * 등재 입력 (신청에서 읽은 값 그대로). 기간은 KST 날짜 라벨이다 — UTC 순간으로 올리는 규칙은 * 축제 시더와 한 벌이어야 해서 구현이 갖는다. imageKey 는 버킷 상대 키이고 공개 주소 조립도 구현 몫이다 diff --git a/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java b/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java index 3dd478c7..d49ecb46 100644 --- a/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java +++ b/src/main/java/com/msg/fillmap/mission/service/impl/MissionRegistrationServiceImpl.java @@ -1,5 +1,7 @@ package com.msg.fillmap.mission.service.impl; +import java.time.LocalDateTime; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -72,6 +74,15 @@ public long register(MissionRegistration registration) { return mission.getId(); } + @Override + @Transactional + public void hide(long missionId, LocalDateTime now) { + // 0행 = 이미 숨겨진 미션. 그래도 무효화는 건다 — 값이 없어도 비용이 다음 조회 한 번의 재계산뿐이고, + // 실패로 보고할 사건이 아니다(중지의 목표 상태는 이미 성립해 있다). + missionRepository.hide(missionId, now); + invalidateSnapshotAfterCommit(); + } + /** * 스냅숏 무효화 예약 (D-12) — 커밋 후다. 커밋 전에 비우면 재계산이 아직 안 보이는 이 미션을 놓친 * 스냅숏을 도로 채워, 무효화를 하고도 최대 1시간 안 실리는 결과가 그대로 남는다. From 4617f8a282225fdd71eada3ec36b5ddf3e43d89a Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:02:12 +0900 Subject: [PATCH 12/35] =?UTF-8?q?MSG-500=20feat:=20=EC=A4=91=EC=A7=80?= =?UTF-8?q?=EB=90=9C=20=EB=AF=B8=EC=85=98=EC=9D=84=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=C2=B7=EC=97=85=EB=A1=9C=EB=93=9C=C2=B7=EC=98=81=EC=83=81=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EC=97=90=EC=84=9C=20=EB=A7=89=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피드는 빈 목록이 아니라 미션 상세와 같은 404(12404)로 수렴한다. 진행도·방문 스팟·뱃지 지표에는 필터를 걸지 않는다(완료 기록 비회수). Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../service/impl/MissionQueryServiceImpl.java | 5 ++++- .../service/impl/MissionVideoServiceImpl.java | 3 +++ .../video/repository/VideoRepository.java | 17 +++++++++++++++++ .../fillmap/video/service/VideoServiceImpl.java | 6 ++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java b/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java index 5d1aa37d..8ddbe4ae 100644 --- a/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java +++ b/src/main/java/com/msg/fillmap/mission/service/impl/MissionQueryServiceImpl.java @@ -301,7 +301,10 @@ public List getMyProgress(long userId, List mi @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public MissionDetailResponseDto getMissionDetail(long missionId, Long userId) { // 기간 판정은 하지 않는다 — 기간이 끝난 미션도 행이 남아 있으면 상세를 연다(§API 명세). + // 노출 중지된 미션은 없는 미션과 같은 404 다 (MSG-500 D-3) — 목록에서 사라진 미션이 상세로는 + // 열리면 "있는데 안 보이는" 상태가 되어 은닉이 갈라진다. Mission mission = missionRepository.findById(missionId) + .filter(found -> found.getHiddenAt() == null) .orElseThrow(() -> new ApiException(MissionErrorCode.MISSION_NOT_FOUND)); List grids = missionGridRepository.findByMissionIds(List.of(missionId)); // findById 직후라 진행도 행은 사실상 하나다. READ_COMMITTED 는 문장 사이 스냅샷이 갱신되므로 @@ -327,7 +330,7 @@ public MissionDetailResponseDto getMissionDetail(long missionId, Long userId) { */ @Override public List getMissionsByGrid(String gridId) { - List missions = missionRepository.findByRepresentativeGridIdOrderById(gridId); + List missions = missionRepository.findByRepresentativeGridIdAndHiddenAtIsNullOrderById(gridId); if (missions.isEmpty()) { return List.of(); } diff --git a/src/main/java/com/msg/fillmap/mission/service/impl/MissionVideoServiceImpl.java b/src/main/java/com/msg/fillmap/mission/service/impl/MissionVideoServiceImpl.java index fdfab68b..2d300050 100644 --- a/src/main/java/com/msg/fillmap/mission/service/impl/MissionVideoServiceImpl.java +++ b/src/main/java/com/msg/fillmap/mission/service/impl/MissionVideoServiceImpl.java @@ -142,6 +142,9 @@ private ConfirmedVideo confirm(long userId, String gridId, MissionVideoUploadReq private Mission requireUploadable(Optional found, LocalDateTime recordedAt) { LocalDateTime now = LocalDateTime.now(clock); return found + // 노출 중지된 미션은 업로드 대상이 아니다 (MSG-500 D-3) — 목록에서 빠진 미션에 계속 올릴 수 있으면 + // 중지가 반쪽이 된다. 없는 미션과 같은 12409 로 수렴한다(존재 오라클을 열지 않는 기존 규칙). + .filter(mission -> mission.getHiddenAt() == null) .filter(mission -> UPLOADABLE_TYPES.contains(mission.getType())) .filter(mission -> mission.getRepresentativeGridId() != null) .filter(mission -> within(mission, now)) diff --git a/src/main/java/com/msg/fillmap/video/repository/VideoRepository.java b/src/main/java/com/msg/fillmap/video/repository/VideoRepository.java index 72e34021..b2d6f323 100644 --- a/src/main/java/com/msg/fillmap/video/repository/VideoRepository.java +++ b/src/main/java/com/msg/fillmap/video/repository/VideoRepository.java @@ -147,6 +147,7 @@ List

+ * missions 를 video 리포지토리에서 읽는 것은 용도가 영상 목록이기 때문이다 — 엔티티를 띄우지 않는 + * 스칼라 조회이고, 같은 결의 선례가 {@code UserRepository.findAllS3KeysByUserId}(videos 를 읽는 user + * 리포지토리) 다. 없는 미션은 false — 그 경우의 빈 페이지 계약(MSG-390)은 그대로다. + */ + @Query(value = "SELECT EXISTS (SELECT 1 FROM missions WHERE id = :missionId AND hidden_at IS NOT NULL)", + nativeQuery = true) + boolean isMissionHidden(@Param("missionId") long missionId); + /** * 격자 전역 시간대 분포 (MSG-372). findGlobalVideos 와 같은 후보 집합을 세어 업로드 시각의 KST 시로 * 접는다 — WHERE 게이트 줄은 findGlobalCover·findGlobalVideos 와 글자 단위로 같은 문자열이라 diff --git a/src/main/java/com/msg/fillmap/video/service/VideoServiceImpl.java b/src/main/java/com/msg/fillmap/video/service/VideoServiceImpl.java index 5f5b579c..a4330d91 100644 --- a/src/main/java/com/msg/fillmap/video/service/VideoServiceImpl.java +++ b/src/main/java/com/msg/fillmap/video/service/VideoServiceImpl.java @@ -54,6 +54,7 @@ import com.msg.fillmap.grid.GridEncoder.GridPoint; import com.msg.fillmap.hotzone.service.HotScoreCommandService; import com.msg.fillmap.mission.dto.MissionAwardResult; +import com.msg.fillmap.mission.exception.MissionErrorCode; import com.msg.fillmap.mission.service.MissionAwardService; import com.msg.fillmap.region.service.RegionStatsCommandService; import com.msg.fillmap.streak.service.StreakCommandService; @@ -579,6 +580,11 @@ public GridVideoPageResponseDto getGridGlobalVideos(String gridId, String cursor @Override @Transactional(readOnly = true) public GridVideoPageResponseDto getMissionVideos(long missionId, String cursor, int size) { + // 노출 중지된 미션은 미션 상세와 같은 404 다 (MSG-500 D-3) — 빈 200 을 주면 목록에서 사라진 미션이 + // "존재하지만 비어 있다"로 읽혀, 알던 missionId 로 존재를 확인하는 경로가 남는다. + if (videoRepository.isMissionHidden(missionId)) { + throw new ApiException(MissionErrorCode.MISSION_NOT_FOUND); + } int pageSize = size < 1 ? GLOBAL_PAGE_DEFAULT_SIZE : Math.min(size, GLOBAL_PAGE_MAX_SIZE); List

{@code status} 는 저장값이 아니라 조회 시점 KST 오늘과 기간으로 만든 파생값이고, 목록 필터와 같은 + * 식에서 나온다. {@code unpublishedAt} 이 있으면 중지된 행사이며 파생 탭에는 그대로 남는다 — + * 목록에서 지우면 관리자가 무엇을 왜 내렸는지 다시 볼 자리가 사라진다. + */ +@Schema(description = "승인 행사 목록 항목", + requiredProperties = {"submissionId", "approvalNo", "submissionNo", "type", "title", "organizerName", "orgName", + "startsOn", "endsOn", "status", "unpublished", "unpublishedAt", "unpublishReason"}) +public record AdminApprovedEventItemResponseDto( + @Schema(description = "승인 행사 식별자 (= 신청 id)", example = "7") + Long submissionId, + + @Schema(description = "승인 번호", example = "APR-2026-0001") + String approvalNo, + + @Schema(description = "신청 번호", example = "FM-2026-0007") + String submissionNo, + + @Schema(description = "등록 유형", example = "FESTIVAL") + EventSubmissionType type, + + @Schema(description = "축제명 / 팝업명", example = "부산불꽃축제") + String title, + + @Schema(description = "주최 기관 — 신청 폼에 적힌 값") + String organizerName, + + @Schema(description = "기관명 — 신청 계정에 등록된 값", nullable = true) + String orgName, + + @Schema(description = "행사 시작일", example = "2026-11-07") + LocalDate startsOn, + + @Schema(description = "행사 종료일", example = "2026-11-09") + LocalDate endsOn, + + @Schema(description = "파생 상태 (UPCOMING 예정 · EXPOSED 노출 중 · ENDED 종료)", example = "EXPOSED") + String status, + + @Schema(description = "노출 중지 여부", example = "false") + boolean unpublished, + + @Schema(description = "노출 중지 시각 (UTC) — 중지되지 않았으면 null", nullable = true) + LocalDateTime unpublishedAt, + + @Schema(description = "노출 중지 사유 — 중지되지 않았으면 null", nullable = true) + String unpublishReason +) { + + /** + * 리포지토리 생성자 프로젝션이 부르는 형태 — 중지 여부는 중지 시각에서 파생하므로 쿼리가 따로 계산하지 + * 않는다. 별도 생성자로 두는 것은 {@code unpublished} 가 진짜 필드여야 하기 때문이다: 파생 + * 메서드로 두면 Jackson 이 레코드 컴포넌트만 직렬화해 응답에서 키가 통째로 빠진다(스키마 가드가 잡는다). + */ + public AdminApprovedEventItemResponseDto(Long submissionId, String approvalNo, String submissionNo, + EventSubmissionType type, String title, String organizerName, String orgName, LocalDate startsOn, + LocalDate endsOn, String status, LocalDateTime unpublishedAt, String unpublishReason) { + this(submissionId, approvalNo, submissionNo, type, title, organizerName, orgName, startsOn, endsOn, + status, unpublishedAt != null, unpublishedAt, unpublishReason); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminApprovedEventListResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminApprovedEventListResponseDto.java new file mode 100644 index 00000000..1d5e2d4d --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminApprovedEventListResponseDto.java @@ -0,0 +1,43 @@ +package com.msg.fillmap.event.submission.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.springframework.data.domain.Page; + +/** + * 승인 행사 목록 (MSG-500 §API 5). 심사 큐와 같은 오프셋 페이징이고, 건수 3종은 탭과 무관한 전체 집계다 + * (화면 탭 뱃지 재료). 건수의 기준 날짜는 목록과 같은 KST 오늘이다. + */ +@Schema(description = "승인 행사 목록 — 탭 기준 한 페이지와 탭별 전체 건수.", + requiredProperties = {"exposedCount", "upcomingCount", "endedCount", "totalElements", "page", "size", "events"}) +public record AdminApprovedEventListResponseDto( + @Schema(description = "노출 중 건수 (탭과 무관한 전체 집계)", example = "4") + long exposedCount, + + @Schema(description = "예정 건수 (탭과 무관한 전체 집계)", example = "2") + long upcomingCount, + + @Schema(description = "종료 건수 (탭과 무관한 전체 집계)", example = "9") + long endedCount, + + @Schema(description = "탭에 해당하는 전체 행사 수", example = "4") + long totalElements, + + @Schema(description = "현재 페이지 번호 (0부터)", example = "0") + int page, + + @Schema(description = "페이지 크기", example = "20") + int size, + + @Schema(description = "이 페이지의 행사 목록. 정렬은 시작일 최신순 고정") + List events +) { + + public static AdminApprovedEventListResponseDto of(Page page, + long exposedCount, long upcomingCount, long endedCount) { + return new AdminApprovedEventListResponseDto(exposedCount, upcomingCount, endedCount, + page.getTotalElements(), page.getNumber(), page.getSize(), page.getContent()); + } +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventUnpublishRequestDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventUnpublishRequestDto.java new file mode 100644 index 00000000..ff891557 --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventUnpublishRequestDto.java @@ -0,0 +1,17 @@ +package com.msg.fillmap.event.submission.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; + +/** + * 노출 중지 요청 (MSG-500 §API 6, FR-20). 사유가 필수인 것은 그 값이 행사 운영자에게 나가는 통지 본문 + * 이자, 재발송 API 를 만들지 않는 대신 남기는 수기 재통지의 재료이기 때문이다. + */ +@Schema(description = "행사 노출 중지 요청", requiredProperties = {"reason"}) +public record AdminEventUnpublishRequestDto( + @Schema(description = "중지 사유 — 행사 운영자에게 그대로 발송된다", + example = "행사가 취소되어 노출을 중지합니다") + @NotBlank String reason +) { +} diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventUnpublishResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventUnpublishResponseDto.java new file mode 100644 index 00000000..29740cfe --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventUnpublishResponseDto.java @@ -0,0 +1,22 @@ +package com.msg.fillmap.event.submission.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 노출 중지 결과 (MSG-500 §API 6). {@code emailSent} 가 false 여도 중지는 유지된다 — 발송 실패가 중지를 + * 뒤집지 않고, 저장된 사유가 수기 재통지의 재료다(재발송 API 없음, MSG-499 발급 발송 선례와 같은 결). + */ +@Schema(description = "행사 노출 중지 결과", requiredProperties = {"submissionId", "unpublishedAt", "emailSent"}) +public record AdminEventUnpublishResponseDto( + @Schema(description = "중지한 승인 행사 식별자 (= 신청 id)", example = "7") + Long submissionId, + + @Schema(description = "중지 시각 (UTC)", example = "2026-08-30T02:11:00Z") + LocalDateTime unpublishedAt, + + @Schema(description = "사유 통지 메일 발송 성공 여부 — false 여도 중지는 유지된다", example = "true") + boolean emailSent +) { +} 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 index eaf56760..53db2798 100644 --- a/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java +++ b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java @@ -1,5 +1,6 @@ package com.msg.fillmap.event.submission.repository; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -12,6 +13,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.msg.fillmap.event.submission.dto.AdminApprovedEventItemResponseDto; import com.msg.fillmap.event.submission.dto.AdminEventSubmissionItemResponseDto; import com.msg.fillmap.event.submission.entity.EventSubmission; import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; @@ -114,6 +116,70 @@ int approveInReview(@Param("id") Long id, @Param("approvalNo") String approvalNo """) int rejectInReview(@Param("id") Long id, @Param("now") LocalDateTime now); + /** + * 노출 중지 (MSG-500 D-3) — 승인됐고 아직 중지되지 않은 행에만 걸린다. 조건부 UPDATE 한 문장이라 + * 동시 중지 두 건이 둘 다 성공해 사유가 덮이는 경합이 없고, 0행이면 호출자가 재조회로 가른다: + * 없거나 미승인이면 13430, 이미 중지면 13453 이다. + *

+ * {@code flushAutomatically} 를 걸지 않은 것은 이 전이 앞에 엔티티 쓰기가 없기 때문이다(읽기만 한다). + * 나중에 앞쪽에 엔티티 수정이 생기면 그 변경이 flush 되지 않은 채 이 UPDATE 가 먼저 나가 순서가 + * 뒤집히므로, 그때는 플래그가 필요하다. + */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE EventSubmission s + SET s.unpublishedAt = :now, s.unpublishReason = :reason, s.updatedAt = :now + WHERE s.id = :id + AND s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED + AND s.unpublishedAt IS NULL + """) + int unpublishApproved(@Param("id") Long id, @Param("reason") String reason, @Param("now") LocalDateTime now); + + /** + * 승인 행사 목록 (MSG-500 §API 5, D-11) — 원천은 신청 테이블의 APPROVED 행이고 안정 식별자는 신청 id 다. + * 탭 상태는 저장하지 않고 KST 오늘과 기간으로 파생한다: 시작 전이면 UPCOMING, 기간 안이면 EXPOSED, + * 종료 후면 ENDED 다(경계일 양끝 포함). 그래서 today 가 파라미터이고, 같은 CASE 가 항목의 status 필드도 + * 만든다 — 필터와 표시가 한 식에서 나와야 둘이 어긋나지 않는다. + *

+ * 중지된 행사도 파생 탭에 그대로 남는다(D-11) — 목록에서 지우지 않고 unpublished 필드로 구분한다. + * 기관명은 심사 큐와 같은 이유로 users 세타 조인이다. + */ + @Query(value = """ + SELECT new com.msg.fillmap.event.submission.dto.AdminApprovedEventItemResponseDto( + s.id, s.approvalNo, s.submissionNo, s.type, s.title, s.organizerName, u.orgName, + s.startsOn, s.endsOn, + CASE WHEN s.startsOn > :today THEN 'UPCOMING' + WHEN s.endsOn < :today THEN 'ENDED' + ELSE 'EXPOSED' END, + s.unpublishedAt, s.unpublishReason) + FROM EventSubmission s, User u + WHERE u.id = s.userId + AND s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED + AND (CASE WHEN s.startsOn > :today THEN 'UPCOMING' + WHEN s.endsOn < :today THEN 'ENDED' + ELSE 'EXPOSED' END) = :tab + ORDER BY s.startsOn DESC, s.id DESC + """, + countQuery = """ + SELECT COUNT(s) FROM EventSubmission s + WHERE s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED + AND (CASE WHEN s.startsOn > :today THEN 'UPCOMING' + WHEN s.endsOn < :today THEN 'ENDED' + ELSE 'EXPOSED' END) = :tab + """) + Page findApprovedPageByTab(@Param("tab") String tab, + @Param("today") LocalDate today, Pageable pageable); + + /** 탭 뱃지용 건수 — 목록과 같은 파생식을 쓴다(식이 갈리면 뱃지 숫자와 목록 건수가 어긋난다). */ + @Query(""" + SELECT COUNT(s) FROM EventSubmission s + WHERE s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED + AND (CASE WHEN s.startsOn > :today THEN 'UPCOMING' + WHEN s.endsOn < :today THEN 'ENDED' + ELSE 'EXPOSED' END) = :tab + """) + long countApprovedByTab(@Param("tab") String tab, @Param("today") LocalDate today); + /** * 신청 번호의 순번 (D-4). 연도별 리셋이 없는 전역 증가값이라 리셋 기계 없이 UNIQUE 가 보장된다. * PostgreSQL 시퀀스 함수라 native 다 (JPA 표준에 동등 표현이 없다). diff --git a/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java b/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java new file mode 100644 index 00000000..6d3ffe7d --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java @@ -0,0 +1,181 @@ +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.Locale; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import lombok.extern.slf4j.Slf4j; + +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.dto.AdminApprovedEventListResponseDto; +import com.msg.fillmap.event.submission.dto.AdminEventUnpublishRequestDto; +import com.msg.fillmap.event.submission.dto.AdminEventUnpublishResponseDto; +import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.global.mail.MailSender; +import com.msg.fillmap.mission.service.MissionRegistrationService; +import com.msg.fillmap.user.exception.UserErrorCode; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 승인 행사의 조회와 노출 관리 (MSG-500 §API 5·6). 심사(신청)와 갈라 둔 것은 다루는 대상이 다르기 + * 때문이다 — 여기서 보는 것은 이미 승인돼 지도에 실린 행사이고, 원천 테이블만 신청과 같다(D-11). + * + *

탭 상태는 저장하지 않는다. 조회 시점 KST 오늘과 행사 기간으로 파생하므로 자정이 지나면 같은 + * 행이 다른 탭으로 옮겨간다 — 저장했다면 매일 도는 배치가 필요했을 상태다. + * + *

중지 통지는 커밋 뒤다(MSG-499 발급 발송 선례). 안에 두면 SES 왕복이 DB 커넥션을 붙들고, + * 발송 성공 뒤 커밋이 실패하면 "중지됐다고 통지했는데 아직 노출 중"인 역방향 구멍이 생긴다. + */ +@Slf4j +@Service +public class AdminApprovedEventService { + + private static final int MIN_PAGE_SIZE = 1; + private static final int MAX_PAGE_SIZE = 100; + + /** 탭 판정의 "오늘"은 관리자가 보는 날짜라 KST 다 (승인 번호 연도 라벨과 같은 성격). */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final String MAIL_SUBJECT = "[필맵] 등재 행사 노출 중지 안내"; + private static final String MAIL_BODY_FORMAT = """ + 승인된 행사의 지도 노출이 중지되었습니다. + + 행사명: %s + 승인 번호: %s + 중지 사유: %s + + 문의가 있으시면 이 메일에 회신해 주세요."""; + + private final EventSubmissionRepository submissionRepository; + private final MissionRegistrationService missionRegistrationService; + private final UserRepository userRepository; + private final MailSender mailSender; + private final TransactionTemplate transactionTemplate; + private final Clock clock; + + /** 프로덕션 생성자 — clock 을 systemUTC 로 고정해 전체 생성자로 위임한다 (심사 서비스와 같은 형태). */ + @Autowired + public AdminApprovedEventService(EventSubmissionRepository submissionRepository, + MissionRegistrationService missionRegistrationService, UserRepository userRepository, + MailSender mailSender, TransactionTemplate transactionTemplate) { + this(submissionRepository, missionRegistrationService, userRepository, mailSender, transactionTemplate, + Clock.systemUTC()); + } + + public AdminApprovedEventService(EventSubmissionRepository submissionRepository, + MissionRegistrationService missionRegistrationService, UserRepository userRepository, + MailSender mailSender, TransactionTemplate transactionTemplate, Clock clock) { + this.submissionRepository = submissionRepository; + this.missionRegistrationService = missionRegistrationService; + this.userRepository = userRepository; + this.mailSender = mailSender; + this.transactionTemplate = transactionTemplate; + this.clock = clock; + } + + /** + * 승인 행사 목록 (§API 5, FR-25). 탭 필터와 건수 3종이 같은 파생식을 쓴다 — 식이 갈리면 뱃지 + * 숫자와 목록 건수가 어긋난다. 거부 판정은 심사 큐와 같은 순서다: 탭 파싱 실패(13455) → 페이지 범위(13456). + */ + @Transactional(readOnly = true) + public AdminApprovedEventListResponseDto getEvents(String status, int page, int size) { + String tab = parseTab(status); + if (page < 0 || size < MIN_PAGE_SIZE || size > MAX_PAGE_SIZE + || (long) page * size > Integer.MAX_VALUE) { + throw new ApiException(EventErrorCode.INVALID_PAGE_RANGE); + } + LocalDate today = LocalDate.now(clock.withZone(KST)); + return AdminApprovedEventListResponseDto.of( + submissionRepository.findApprovedPageByTab(tab, today, PageRequest.of(page, size)), + submissionRepository.countApprovedByTab(Tab.EXPOSED.name(), today), + submissionRepository.countApprovedByTab(Tab.UPCOMING.name(), today), + submissionRepository.countApprovedByTab(Tab.ENDED.name(), today)); + } + + /** + * 노출 중지 (§API 6, FR-20). 조건부 UPDATE 하나가 "승인됐고 아직 중지되지 않은 행"에만 걸려 동시 중지의 + * 늦은 쪽이 사유를 덮지 못하게 하고, 같은 트랜잭션에서 승인 산출물을 숨긴다. + * + *

미션 숨김은 소유 도메인 서비스를 거친다 — 스냅숏 무효화가 그쪽 책임이라 여기서 missions 를 직접 + * 건드리면 지도 목록이 최대 1시간 중지된 미션을 계속 그린다. + * + *

산출물 링크가 없는 승인 행사(참여형)는 지금 만들어질 수 없다 — 승인 분기가 미션 하나뿐이다. + * 참여형 위치 숨김과 부모 회차 노출 영역 재계산은 그 분기와 함께 온다(MSG-502 이후). + */ + public AdminEventUnpublishResponseDto unpublish(Long submissionId, AdminEventUnpublishRequestDto request) { + Unpublished unpublished = transactionTemplate.execute(status -> { + LocalDateTime now = LocalDateTime.now(clock); + if (submissionRepository.unpublishApproved(submissionId, request.reason(), now) == 0) { + throw unpublishFailure(submissionId); + } + EventSubmission submission = submissionRepository.findById(submissionId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + if (submission.getPublishedMissionId() != null) { + missionRegistrationService.hide(submission.getPublishedMissionId(), now); + } + String email = userRepository.findById(submission.getUserId()) + .orElseThrow(() -> new ApiException(UserErrorCode.USER_NOT_FOUND)) + .getEmail(); + return new Unpublished(email, submission.getTitle(), submission.getApprovalNo(), now); + }); + return new AdminEventUnpublishResponseDto(submissionId, unpublished.at(), + notify(unpublished, request.reason())); + } + + /** + * 사유 통지 — 실패는 삼키고 false 로 알린다 (MSG-499 발급 발송 선례). 중지는 이미 커밋됐고, 저장된 사유가 + * 수기 재통지의 재료라 발송 실패가 중지를 되돌릴 이유가 되지 않는다. 재발송 API 는 만들지 않는다. + */ + private boolean notify(Unpublished unpublished, String reason) { + try { + mailSender.send(unpublished.email(), MAIL_SUBJECT, + MAIL_BODY_FORMAT.formatted(unpublished.title(), unpublished.approvalNo(), reason)); + return true; + } catch (RuntimeException e) { + log.error("행사 노출 중지 통지 발송 실패 — 중지는 유지된다: to={}", unpublished.email(), e); + return false; + } + } + + /** 조건부 UPDATE 가 0행일 때의 이유 — 없거나 미승인이면 13430, 이미 중지면 13453 이다. */ + private ApiException unpublishFailure(Long submissionId) { + EventSubmission submission = submissionRepository.findById(submissionId) + .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); + if (submission.getStatus() != EventSubmissionStatus.APPROVED) { + // 승인되지 않은 신청은 노출 행사가 아니다 — 관리자에게도 "그런 행사는 없다"가 맞는 답이다. + return new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND); + } + return new ApiException(EventErrorCode.EVENT_ALREADY_UNPUBLISHED); + } + + /** 클라이언트 문자열 → 탭 (심사 큐 parseStatus 와 같은 규칙). 조용한 기본값 폴백 없이 13455 로 거른다. */ + private String parseTab(String status) { + try { + return Tab.valueOf(status.toUpperCase(Locale.ROOT)).name(); + } catch (IllegalArgumentException e) { + throw new ApiException(EventErrorCode.INVALID_SUBMISSION_STATUS_FILTER); + } + } + + /** 파생 탭 3종 — 저장 컬럼이 아니라 조회 시점 계산값이라 enum 이 이 서비스 안에 산다. */ + private enum Tab { + EXPOSED, + UPCOMING, + ENDED + } + + /** 커밋 뒤 발송에 필요한 값만 트랜잭션 밖으로 들고 나온다 — 엔티티를 밖으로 흘리지 않는다. */ + private record Unpublished(String email, String title, String approvalNo, LocalDateTime at) { + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java new file mode 100644 index 00000000..1d3fcfc6 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java @@ -0,0 +1,241 @@ +package com.msg.fillmap.event.submission.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +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.jdbc.core.JdbcTemplate; +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 org.springframework.transaction.support.TransactionTemplate; + +import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.event.submission.dto.AdminApprovedEventItemResponseDto; +import com.msg.fillmap.event.submission.dto.AdminApprovedEventListResponseDto; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.event.submission.service.AdminApprovedEventService; +import com.msg.fillmap.global.mail.MailSender; +import com.msg.fillmap.mission.service.MissionRegistrationService; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 승인 행사 목록의 파생 탭 (MSG-500 §API 5, 실 DB). 탭이 저장값이 아니라 KST 오늘과 기간의 파생이라, + * 검증하려면 "오늘"을 고정해야 한다 — 그래서 이 클래스는 고정 클럭을 꽂은 서비스를 직접 만들어 쓰고, + * 컨트롤러 경로(파라미터 거절·인가)만 실제 빈으로 확인한다. + * + *

고정 시각이 {@code 2026-11-07T15:30Z}(= KST 11-08 00:30)인 것이 이 파일의 핵심이다. UTC 날짜로 + * 판정하면 오늘이 11-07 이 되어 아래 네 행의 탭이 통째로 한 칸씩 밀린다 — 그 어긋남을 잡는 것이 목적이다. + * + *

건수 단언은 증분이다: 탭 건수가 전역 집계라 공유 로컬 DB 의 기존 승인 행이 그대로 더해진다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("승인 행사 목록 파생 탭 (MSG-500, 실 DB)") +class AdminApprovedEventListTest { + + private static final String URL = "/api/admin/events"; + + /** UTC 2026-11-07T15:30Z = KST 2026-11-08T00:30 — 두 시간대의 날짜가 갈리는 순간이다. */ + private static final Clock KST_NEW_DAY = Clock.fixed(Instant.parse("2026-11-07T15:30:00Z"), ZoneOffset.UTC); + private static final LocalDate KST_TODAY = LocalDate.of(2026, 11, 8); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private EntityManager entityManager; + + private AdminApprovedEventService service; + private User organizer; + private User admin; + + @BeforeEach + void setUp() { + organizer = saveUser(UserRole.ORG); + admin = saveUser(UserRole.ADMIN); + // 목록은 미션도 메일도 건드리지 않는다 — 중지 경로 의존이라 목으로 채운다. + service = new AdminApprovedEventService(submissionRepository, mock(MissionRegistrationService.class), + userRepository, mock(MailSender.class), transactionTemplate, KST_NEW_DAY); + } + + private User saveUser(UserRole role) { + User user = User.createLocalUser("tab-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", role); + ReflectionTestUtils.setField(user, "orgName", "부산광역시 부산진구청"); + return userRepository.saveAndFlush(user); + } + + /** 승인된 신청 1건 — 승인 번호는 CHECK 가 요구하므로 상태와 함께 넣는다. */ + private long 승인된_행사를_심는다(LocalDate startsOn, LocalDate endsOn) { + String submissionNo = "FM-2026-" + UUID.randomUUID().toString().substring(0, 8); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + jdbcTemplate.update(""" + INSERT INTO event_submissions + (submission_no, user_id, type, status, approval_no, title, organizer_name, starts_on, ends_on, + program_description, description, image_key, created_at, updated_at) + VALUES (?, ?, 'FESTIVAL', 'APPROVED', ?, '광안리 불꽃축제', '부산문화관광축제조직위원회', ?, ?, + '멀티불꽃쇼', '광안리 일원에서 열리는 부산 대표 불꽃 축제', + 'event-submissions/original/1/a.jpg', ?, ?) + """, submissionNo, organizer.getId(), "APR-2026-" + UUID.randomUUID().toString().substring(0, 8), + startsOn, endsOn, now, now); + return jdbcTemplate.queryForObject( + "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); + } + + private void 중지_기록을_남긴다(long submissionId, String reason) { + jdbcTemplate.update( + "UPDATE event_submissions SET unpublished_at = ?, unpublish_reason = ? WHERE id = ?", + LocalDateTime.now(ZoneOffset.UTC), reason, submissionId); + } + + private AdminApprovedEventListResponseDto 목록(String tab) { + entityManager.flush(); + entityManager.clear(); + return service.getEvents(tab, 0, 100); + } + + @Nested + @DisplayName("파생 탭") + class DerivedTab { + + // 검증: FR-EVENT-18 + @Test + @DisplayName("탭 상태는 KST 오늘과 기간으로 파생되고 경계일은 양끝 포함이다") + void 승인_행사_목록의_탭_상태는_KST_오늘과_기간으로_파생된다() { + long 시작일_당일 = 승인된_행사를_심는다(KST_TODAY, KST_TODAY.plusDays(2)); + long 종료일_당일 = 승인된_행사를_심는다(KST_TODAY.minusDays(3), KST_TODAY); + long 예정 = 승인된_행사를_심는다(KST_TODAY.plusDays(1), KST_TODAY.plusDays(2)); + long 종료 = 승인된_행사를_심는다(KST_TODAY.minusDays(3), KST_TODAY.minusDays(1)); + + // 시작일 당일도 종료일 당일도 노출 중이다 — UTC 오늘(11-07)로 판정하면 둘 다 다른 탭으로 밀린다. + assertThat(목록("EXPOSED").events()).extracting(AdminApprovedEventItemResponseDto::submissionId) + .contains(시작일_당일, 종료일_당일) + .doesNotContain(예정, 종료); + assertThat(목록("UPCOMING").events()).extracting(AdminApprovedEventItemResponseDto::submissionId) + .contains(예정) + .doesNotContain(시작일_당일, 종료일_당일, 종료); + assertThat(목록("ENDED").events()).extracting(AdminApprovedEventItemResponseDto::submissionId) + .contains(종료) + .doesNotContain(시작일_당일, 종료일_당일, 예정); + // 항목의 status 도 같은 식에서 나온다 — 필터와 표시가 갈리면 탭에 다른 상태가 섞여 보인다. + assertThat(목록("EXPOSED").events()).allMatch(event -> "EXPOSED".equals(event.status())); + } + + // 검증: FR-EVENT-18 + @Test + @DisplayName("탭 건수 3종은 탭 필터와 무관한 전체 집계다") + void 탭_건수는_필터와_무관한_전체_집계다() { + AdminApprovedEventListResponseDto 처음 = 목록("EXPOSED"); + + 승인된_행사를_심는다(KST_TODAY, KST_TODAY); + 승인된_행사를_심는다(KST_TODAY.plusDays(1), KST_TODAY.plusDays(2)); + 승인된_행사를_심는다(KST_TODAY.minusDays(3), KST_TODAY.minusDays(1)); + + AdminApprovedEventListResponseDto 나중 = 목록("EXPOSED"); + assertThat(나중.exposedCount()).isEqualTo(처음.exposedCount() + 1); + assertThat(나중.upcomingCount()).isEqualTo(처음.upcomingCount() + 1); + assertThat(나중.endedCount()).isEqualTo(처음.endedCount() + 1); + } + + // 검증: FR-EVENT-18 + @Test + @DisplayName("중지된 행사는 파생 탭에 남고 unpublished 가 참이다") + void 중지된_행사는_파생_탭에_남고_unpublished가_참이다() { + long 중지됨 = 승인된_행사를_심는다(KST_TODAY.minusDays(1), KST_TODAY.plusDays(1)); + 중지_기록을_남긴다(중지됨, "행사가 취소되어 노출을 중지합니다"); + + assertThat(목록("EXPOSED").events()) + .filteredOn(event -> event.submissionId().equals(중지됨)) + .singleElement() + .satisfies(event -> { + assertThat(event.unpublished()).isTrue(); + assertThat(event.unpublishedAt()).isNotNull(); + assertThat(event.unpublishReason()).isEqualTo("행사가 취소되어 노출을 중지합니다"); + assertThat(event.orgName()).isEqualTo("부산광역시 부산진구청"); + assertThat(event.approvalNo()).startsWith("APR-2026-"); + }); + } + } + + @Nested + @DisplayName("파라미터와 인가") + class Guards { + + // 검증: FR-EVENT-18 + @Test + @DisplayName("지원하지 않는 탭은 13455, 페이지 범위 밖은 13456 이다") + void 파라미터_거절은_심사_큐와_같은_코드다() throws Exception { + mockMvc.perform(get(URL + "?status=UNKNOWN").header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(13455)); + mockMvc.perform(get(URL + "?size=101").header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(13456)); + } + + // 검증: FR-EVENT-18 + @Test + @DisplayName("관리자가 아니면 승인 행사 목록에 접근할 수 없다") + void 관리자가_아니면_접근할_수_없다() throws Exception { + mockMvc.perform(get(URL).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(URL)).andExpect(status().isUnauthorized()); + } + + // 검증: FR-EVENT-18 + @Test + @DisplayName("기본 탭은 노출 중이다") + void 기본_탭은_노출_중이다() throws Exception { + mockMvc.perform(get(URL).header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.events[?(@.status != 'EXPOSED')]").doesNotExist()); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java new file mode 100644 index 00000000..3161ef83 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java @@ -0,0 +1,315 @@ +package com.msg.fillmap.event.submission.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +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.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +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.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.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 software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectResponse; + +import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.event.submission.EventSubmissionFixtures; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.event.submission.service.AdminEventSubmissionService; +import com.msg.fillmap.global.mail.MailSender; +import com.msg.fillmap.mission.entity.Mission; +import com.msg.fillmap.mission.repository.MissionProgressProjection; +import com.msg.fillmap.mission.repository.MissionRepository; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 승인 행사 노출 중지 (MSG-500 §API 6, 실 DB). 검증의 무게는 API 응답이 아니라 중지 뒤에 무엇이 + * 사라지는가에 있다 — 지도 칩 목록·격자 역조회·미션 상세·영상 피드·스탬프 후보 판정·미션 경유 업로드가 + * 전부 다른 쿼리라, 한 곳만 빠뜨리면 "목록에서는 사라졌는데 알던 id 로는 그대로 열리는" 반쪽 중지가 된다. + * + *

신청은 SQL 로 심는다 — 산출물 미션이 지금 활성이어야 "사라진다"를 관찰할 수 있어서 기간이 + * 오늘을 포함해야 한다(접수 폼의 고정 날짜로는 성립하지 않는다). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("승인 행사 노출 중지 (MSG-500, 실 DB)") +class AdminEventUnpublishTest { + + private static final String UNPUBLISH_URL = "/api/admin/events/%d/unpublish"; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final int MIN_GRID_Y = 16859; + private static final int MAX_GRID_Y = 16861; + private static final int MIN_GRID_X = 11509; + private static final int MAX_GRID_X = 11515; + private static final String CENTER_GRID_ID = "16860_11512"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AdminEventSubmissionService adminEventSubmissionService; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private MissionRepository missionRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private EntityManager entityManager; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private MailSender mailSender; + + private User organizer; + private User admin; + private long submissionId; + private long missionId; + + @BeforeEach + void setUp() { + given(s3Client.copyObject(any(CopyObjectRequest.class))).willReturn(CopyObjectResponse.builder().build()); + organizer = saveUser(UserRole.ORG); + admin = saveUser(UserRole.ADMIN); + + LocalDate today = LocalDate.now(KST); + submissionId = EventSubmissionFixtures.seedInReviewSubmission(jdbcTemplate, organizer.getId(), + "FM-2026-" + UUID.randomUUID().toString().substring(0, 8), today.minusDays(1), today.plusDays(1), + MIN_GRID_Y, MAX_GRID_Y, MIN_GRID_X, MAX_GRID_X, CENTER_GRID_ID); + adminEventSubmissionService.approve(submissionId); + missionId = submissionRepository.findById(submissionId).orElseThrow().getPublishedMissionId(); + } + + private User saveUser(UserRole role) { + User user = User.createLocalUser("unpublish-" + 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 ResultActions 중지한다(long targetSubmissionId, String reason) throws Exception { + entityManager.flush(); + return mockMvc.perform(post(UNPUBLISH_URL.formatted(targetSubmissionId)) + .header(HttpHeaders.AUTHORIZATION, bearer(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "%s"}""".formatted(reason))); + } + + private ResultActions 중지한다() throws Exception { + return 중지한다(submissionId, "행사가 취소되어 노출을 중지합니다"); + } + + private boolean 활성_목록에_있다() { + entityManager.flush(); + entityManager.clear(); + return missionRepository.findActive(LocalDateTime.now(java.time.ZoneOffset.UTC)).stream() + .anyMatch(mission -> mission.getId() == missionId); + } + + @Nested + @DisplayName("사용자 대면 노출") + class UserFacing { + + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지하면 미션이 활성 조회와 스탬프 후보 판정에서 사라진다") + void 중지하면_미션이_활성_조회와_스탬프_판정에서_사라진다() throws Exception { + assertThat(활성_목록에_있다()).isTrue(); + assertThat(missionRepository.findAwardCandidateIds(CENTER_GRID_ID, organizer.getId())) + .contains(missionId); + + 중지한다().andExpect(status().isOk()); + + assertThat(활성_목록에_있다()).isFalse(); + // 스탬프 후보에서 빠진다 — 중지된 미션이 계속 지급되면 "사라진 미션의 뱃지"가 생긴다. + assertThat(missionRepository.findAwardCandidateIds(CENTER_GRID_ID, organizer.getId())) + .doesNotContain(missionId); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지된 미션은 격자 선택 조회에서도 빠진다") + void 중지된_미션은_격자_선택_조회에서도_빠진다() throws Exception { + mockMvc.perform(get("/api/grids/" + CENTER_GRID_ID + "/missions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.missionId == %d)]".formatted(missionId)).exists()); + + 중지한다().andExpect(status().isOk()); + + entityManager.flush(); + entityManager.clear(); + mockMvc.perform(get("/api/grids/" + CENTER_GRID_ID + "/missions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.missionId == %d)]".formatted(missionId)).doesNotExist()); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지된 미션의 상세와 영상 피드는 없는 미션과 같은 404 다") + void 중지된_미션의_상세와_영상_피드는_404다() throws Exception { + mockMvc.perform(get("/api/missions/" + missionId)).andExpect(status().isOk()); + mockMvc.perform(get("/api/missions/" + missionId + "/videos")).andExpect(status().isOk()); + + 중지한다().andExpect(status().isOk()); + + entityManager.flush(); + entityManager.clear(); + mockMvc.perform(get("/api/missions/" + missionId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(12404)); + // 빈 200 이 아니라 404 다 — 빈 페이지면 "존재하지만 비어 있다"로 읽혀 은닉이 갈라진다. + mockMvc.perform(get("/api/missions/" + missionId + "/videos")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(12404)); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지해도 진행도 조회는 남는다 — 기록은 회수하지 않는다") + void 이미_완료한_사용자의_진행도는_중지_후에도_남는다() throws Exception { + 중지한다().andExpect(status().isOk()); + + entityManager.flush(); + entityManager.clear(); + List progress = + missionRepository.findProgress(organizer.getId(), List.of(missionId)); + // 진행도에는 숨김 술어를 걸지 않는다 — 종료 미션도 진행도가 나오는 규칙(D11)과 같은 이유다. + assertThat(progress).hasSize(1); + assertThat(progress.getFirst().getMissionId()).isEqualTo(missionId); + } + } + + @Nested + @DisplayName("중지 처리") + class Unpublishing { + + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지 사유가 신청 계정의 공식 이메일로 발송된다") + void 중지_사유가_공식_이메일로_발송된다() throws Exception { + 중지한다() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.emailSent").value(true)) + .andExpect(jsonPath("$.data.unpublishedAt").exists()); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + then(mailSender).should().send(org.mockito.ArgumentMatchers.eq(organizer.getEmail()), anyString(), + body.capture()); + assertThat(body.getValue()).contains("행사가 취소되어 노출을 중지합니다"); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("발송이 실패해도 중지는 유지되고 emailSent 가 거짓이다") + void 발송이_실패해도_중지는_유지된다() throws Exception { + willThrow(new IllegalStateException("SES 장애")).given(mailSender) + .send(anyString(), anyString(), anyString()); + + 중지한다() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.emailSent").value(false)); + + entityManager.clear(); + assertThat(submissionRepository.findById(submissionId).orElseThrow().getUnpublishedAt()).isNotNull(); + assertThat(활성_목록에_있다()).isFalse(); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("이미 중지된 행사의 재중지는 13453 이고 첫 중지 사유가 유지된다") + void 이미_중지된_행사의_재중지는_13453이다() throws Exception { + 중지한다().andExpect(status().isOk()); + + 중지한다(submissionId, "두 번째 사유") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13453)); + + entityManager.clear(); + assertThat(submissionRepository.findById(submissionId).orElseThrow().getUnpublishReason()) + .isEqualTo("행사가 취소되어 노출을 중지합니다"); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("승인되지 않은 신청과 없는 id 의 중지는 둘 다 13430 이다") + void 미승인_신청_id의_중지는_13430이다() throws Exception { + LocalDate today = LocalDate.now(KST); + long inReviewId = EventSubmissionFixtures.seedInReviewSubmission(jdbcTemplate, organizer.getId(), + "FM-2026-" + UUID.randomUUID().toString().substring(0, 8), today, today.plusDays(1), + MIN_GRID_Y, MAX_GRID_Y, MIN_GRID_X, MAX_GRID_X, CENTER_GRID_ID); + + 중지한다(inReviewId, "사유") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(13430)); + 중지한다(99_999_999L, "사유") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(13430)); + } + + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지된 뒤에도 미션 행은 남는다 — 삭제가 아니라 숨김이다") + void 중지는_삭제가_아니라_숨김이다() throws Exception { + 중지한다().andExpect(status().isOk()); + + entityManager.flush(); + entityManager.clear(); + Mission hidden = missionRepository.findById(missionId).orElseThrow(); + assertThat(hidden.getHiddenAt()).isNotNull(); + assertThat(hidden.getTitle()).isEqualTo("광안리 불꽃축제"); + } + } +} From 2fe4243e7eb946f6834017310c644e3af34b83e0 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:02:13 +0900 Subject: [PATCH 14/35] =?UTF-8?q?MSG-500=20test:=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EC=83=9D=EC=84=B1=EC=9D=B4=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20=EC=A0=84=EC=9D=B4?= =?UTF-8?q?=EC=99=80=20=EC=9D=B4=EB=A0=A5=EC=9D=B4=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=EB=90=A8=EC=9D=84=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랜잭션 격리 클래스에선 롤백을 관찰할 수 없어 비트랜잭션 클래스에 편입했다. 시드 헬퍼를 픽스처로 추출. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../submission/EventSubmissionFixtures.java | 45 +++++++++++++++ ...ventSubmissionApprovalConcurrencyTest.java | 57 ++++++++++--------- 2 files changed, 76 insertions(+), 26 deletions(-) diff --git a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java index cf4fd2d9..e8068581 100644 --- a/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java +++ b/src/test/java/com/msg/fillmap/event/submission/EventSubmissionFixtures.java @@ -1,7 +1,12 @@ package com.msg.fillmap.event.submission; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + /** * 행사 등재 신청 요청 본문 조립기 (MSG-498 테스트 공용). 본문을 문자열로 만드는 이유는 "필드를 아예 빼면 * 어떻게 되나"(재제출의 이미지 키 생략)를 검증해야 하는데 DTO 로 만들면 그 상태를 표현할 수 없기 때문이다. @@ -27,6 +32,46 @@ public static String location(String... rects) { {"areaRects": [%s]}""".formatted(String.join(", ", rects)); } + /** + * 심사 중 신청 1건을 SQL 로 심고 id 를 돌려준다 (MSG-500 테스트 공용). 접수 API 를 타지 않는 이유는 + * 기간 때문이다 — 승인 산출물이 지금 활성인 미션이어야 하는 검증(노출·중지)은 오늘을 포함하는 + * 기간이 필요한데, 접수 폼은 날짜를 문자열로 고정해 두는 편이 읽기 좋기 때문이다. + * 접수 이력(IN_REVIEW) 한 행도 함께 심는다 — 승인·반려가 이력을 쌓는지를 건수로 보려면 출발점이 + * 실제 접수와 같아야 한다. + */ + public static long seedInReviewSubmission(JdbcTemplate jdbcTemplate, long userId, String submissionNo, + LocalDate startsOn, LocalDate endsOn, int minGridY, int maxGridY, int minGridX, int maxGridX, + String representativeGridId) { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + jdbcTemplate.update(""" + INSERT INTO event_submissions + (submission_no, user_id, type, status, title, organizer_name, starts_on, ends_on, + program_description, description, image_key, created_at, updated_at) + VALUES (?, ?, 'FESTIVAL', 'IN_REVIEW', '광안리 불꽃축제', '부산문화관광축제조직위원회', ?, ?, + '멀티불꽃쇼', '광안리 일원에서 열리는 부산 대표 불꽃 축제', + 'event-submissions/original/1/a.jpg', ?, ?) + """, submissionNo, userId, startsOn, endsOn, now, now); + Long submissionId = jdbcTemplate.queryForObject( + "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); + + jdbcTemplate.update(""" + INSERT INTO event_submission_locations (event_submission_id, display_order, representative_grid_id) + VALUES (?, 1, ?) + """, submissionId, representativeGridId); + Long locationId = jdbcTemplate.queryForObject( + "SELECT id FROM event_submission_locations WHERE event_submission_id = ?", Long.class, submissionId); + jdbcTemplate.update(""" + INSERT INTO event_submission_location_rects + (event_submission_location_id, min_grid_y, max_grid_y, min_grid_x, max_grid_x) + VALUES (?, ?, ?, ?, ?) + """, locationId, minGridY, maxGridY, minGridX, maxGridX); + jdbcTemplate.update(""" + INSERT INTO event_submission_status_history (event_submission_id, status, created_at) + VALUES (?, 'IN_REVIEW', ?) + """, submissionId, now); + return submissionId; + } + public static String pendingKey(long userId) { return "event-submissions/pending/%d/%s.jpg".formatted(userId, UUID.randomUUID()); } diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java index 3b96ccd3..1f6558cf 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java @@ -1,13 +1,12 @@ 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 java.time.LocalDate; -import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZoneOffset; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -30,12 +29,17 @@ import org.springframework.test.util.ReflectionTestUtils; 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 com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.submission.EventSubmissionFixtures; import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; +import com.msg.fillmap.event.submission.entity.EventSubmission; +import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; import com.msg.fillmap.global.exception.ApiException; import com.msg.fillmap.grid.GridEncoder; @@ -133,30 +137,8 @@ void tearDown() { /** 오늘을 포함하는 기간의 심사 중 신청 1건 — 승인하면 지금 활성인 미션이 된다. */ private long 심사_중_신청을_심는다() { LocalDate today = LocalDate.now(KST); - jdbcTemplate.update(""" - INSERT INTO event_submissions - (submission_no, user_id, type, status, title, organizer_name, starts_on, ends_on, - program_description, description, image_key, created_at, updated_at) - VALUES (?, ?, 'FESTIVAL', 'IN_REVIEW', '광안리 불꽃축제', '부산문화관광축제조직위원회', ?, ?, - '멀티불꽃쇼', '광안리 일원에서 열리는 부산 대표 불꽃 축제', - 'event-submissions/original/1/a.jpg', ?, ?) - """, submissionNo, organizerId, today.minusDays(1), today.plusDays(1), - LocalDateTime.now(ZoneOffset.UTC), LocalDateTime.now(ZoneOffset.UTC)); - Long id = jdbcTemplate.queryForObject( - "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); - - jdbcTemplate.update(""" - INSERT INTO event_submission_locations (event_submission_id, display_order, representative_grid_id) - VALUES (?, 1, ?) - """, id, CENTER_GRID_ID); - Long locationId = jdbcTemplate.queryForObject( - "SELECT id FROM event_submission_locations WHERE event_submission_id = ?", Long.class, id); - jdbcTemplate.update(""" - INSERT INTO event_submission_location_rects - (event_submission_location_id, min_grid_y, max_grid_y, min_grid_x, max_grid_x) - VALUES (?, ?, ?, ?, ?) - """, locationId, MIN_GRID_Y, MAX_GRID_Y, MIN_GRID_X, MAX_GRID_X); - return id; + return EventSubmissionFixtures.seedInReviewSubmission(jdbcTemplate, organizerId, submissionNo, + today.minusDays(1), today.plusDays(1), MIN_GRID_Y, MAX_GRID_Y, MIN_GRID_X, MAX_GRID_X, CENTER_GRID_ID); } private List 승인_미션들() { @@ -190,6 +172,29 @@ INSERT INTO event_submission_locations (event_submission_id, display_order, repr } } + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인 산출물 생성이 실패하면 전이와 이력까지 함께 롤백된다 — 절반만 반영되지 않는다") + void 승인_산출물_생성이_실패하면_전이와_이력이_함께_롤백된다() { + // 전이 뒤에 오는 단계(공개 이미지 복사)를 실패시킨다 — 상태는 이미 APPROVED 로 바뀐 시점이라, + // 롤백이 없으면 "승인됐는데 미션이 없는" 절반이 그대로 커밋된다. + given(s3Client.copyObject(any(CopyObjectRequest.class))) + .willThrow(SdkClientException.create("S3 복사 실패")); + + assertThatThrownBy(() -> adminEventSubmissionService.approve(submissionId)) + .isInstanceOf(SdkException.class); + + EventSubmission rolledBack = submissionRepository.findById(submissionId).orElseThrow(); + assertThat(rolledBack.getStatus()).isEqualTo(EventSubmissionStatus.IN_REVIEW); + assertThat(rolledBack.getApprovalNo()).isNull(); + assertThat(rolledBack.getPublishedMissionId()).isNull(); + assertThat(승인_미션들()).isEmpty(); + // 이력도 접수 한 행 그대로다 — 승인 행이 남으면 콘솔이 승인됐다 취소된 것처럼 그린다. + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM event_submission_status_history WHERE event_submission_id = ?", + Long.class, submissionId)).isEqualTo(1L); + } + // 검증: FR-EVENT-15 @Test @DisplayName("승인 커밋 후 스냅숏이 무효화되어 TTL 전에도 목록에 실린다") From af0b5197569c54ed9b772d14ac0824f32ed9d7a0 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:28:15 +0900 Subject: [PATCH 15/35] =?UTF-8?q?MSG-500=20test:=20=EC=A4=91=EC=A7=80=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=ED=9B=84=20=EC=8A=A4=EB=83=85=EC=88=8F=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=EB=A1=9C=20TTL=20=EC=A0=84=EC=97=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20=EB=B9=A0=EC=A7=90?= =?UTF-8?q?=EC=9D=84=20=EA=B2=80=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hide의 무효화 한 줄을 지우면 반드시 실패하는 형태다. 승인 쪽 기존 검증과 짝을 이뤄 D-12가 양방향으로 고정됐다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- ...ventSubmissionApprovalConcurrencyTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java index 1f6558cf..ddfb262b 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java @@ -37,11 +37,13 @@ import com.msg.fillmap.event.exception.EventErrorCode; import com.msg.fillmap.event.submission.EventSubmissionFixtures; +import com.msg.fillmap.event.submission.dto.AdminEventUnpublishRequestDto; import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; import com.msg.fillmap.event.submission.entity.EventSubmission; import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.global.mail.MailSender; import com.msg.fillmap.grid.GridEncoder; import com.msg.fillmap.grid.GridEncoder.GridPoint; import com.msg.fillmap.grid.dto.ViewportBounds; @@ -82,6 +84,9 @@ class EventSubmissionApprovalConcurrencyTest { @Autowired private AdminEventSubmissionService adminEventSubmissionService; + @Autowired + private AdminApprovedEventService adminApprovedEventService; + @Autowired private MissionQueryService missionQueryService; @@ -106,6 +111,10 @@ class EventSubmissionApprovalConcurrencyTest { @MockitoBean private S3Client s3Client; + /** 중지 통지 발송을 갈아 끼운다 — 이 클래스가 보는 것은 발송이 아니라 커밋 후 스냅숏이다. */ + @MockitoBean + private MailSender mailSender; + private Long organizerId; private Long submissionId; private String submissionNo; @@ -214,6 +223,25 @@ void tearDown() { assertThat(after).extracting(MissionResponseDto::missionId).contains(missionId); } + // 검증: FR-EVENT-17 + @Test + @DisplayName("중지 커밋 후 스냅숏이 무효화되어 TTL 전에도 목록에서 빠진다") + void 중지_커밋_후_미션_스냅숏이_무효화되어_TTL_전에도_목록에서_빠진다() { + GridPoint center = GridEncoder.center(CENTER_GRID_ID); + ViewportBounds viewport = new ViewportBounds( + center.lat() - 0.01, center.lon() - 0.01, center.lat() + 0.01, center.lon() + 0.01); + long missionId = approveAndReturnMissionId(); + // 승인 미션이 실린 스냅숏을 적재한다 — 무효화가 없으면 중지 뒤에도 최대 1시간 이 스냅숏이 쓰인다. + List before = missionQueryService.getMissionsInViewport(viewport, MissionType.EVENT); + + adminApprovedEventService.unpublish(submissionId, + new AdminEventUnpublishRequestDto("행사가 취소되어 노출을 중지합니다")); + + List after = missionQueryService.getMissionsInViewport(viewport, MissionType.EVENT); + assertThat(before).extracting(MissionResponseDto::missionId).contains(missionId); + assertThat(after).extracting(MissionResponseDto::missionId).doesNotContain(missionId); + } + private long approveAndReturnMissionId() { adminEventSubmissionService.approve(submissionId); return submissionRepository.findById(submissionId).orElseThrow().getPublishedMissionId(); From a4d2b9419eccb8522b7e9b10cc5eda5cd38e06df Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:28:15 +0900 Subject: [PATCH 16/35] =?UTF-8?q?MSG-500=20refactor:=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=ED=96=89=EC=82=AC=20=ED=83=AD=20=ED=8C=8C=EC=83=9D=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C=20=EC=A0=91?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../repository/EventSubmissionRepository.java | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) 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 index 53db2798..fbc1d6db 100644 --- a/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java +++ b/src/main/java/com/msg/fillmap/event/submission/repository/EventSubmissionRepository.java @@ -20,6 +20,16 @@ public interface EventSubmissionRepository extends JpaRepository { + /** + * 승인 행사의 파생 탭 식 (MSG-500 D-11) — 목록 필터·항목 표시·탭 건수가 같은 한 식을 써야 한다. + * 네 자리에 같은 CASE 를 손으로 적으면 한 곳만 고쳐지는 순간 뱃지 숫자와 목록 내용이 조용히 갈린다. + * 컴파일 상수라 {@code @Query} 안에서 문자열 연결로 쓸 수 있다. + */ + String TAB_CASE = """ + CASE WHEN s.startsOn > :today THEN 'UPCOMING' + WHEN s.endsOn < :today THEN 'ENDED' + ELSE 'EXPOSED' END"""; + /** * 소유 조회 (FR-14). 조회를 항상 id + userId 쌍으로 하는 것이 존재 은닉의 구현이다 — 없는 신청과 남의 * 신청이 같은 빈 결과가 되어 응답이 갈릴 코드 경로 자체가 없다. @@ -148,24 +158,20 @@ int approveInReview(@Param("id") Long id, @Param("approvalNo") String approvalNo SELECT new com.msg.fillmap.event.submission.dto.AdminApprovedEventItemResponseDto( s.id, s.approvalNo, s.submissionNo, s.type, s.title, s.organizerName, u.orgName, s.startsOn, s.endsOn, - CASE WHEN s.startsOn > :today THEN 'UPCOMING' - WHEN s.endsOn < :today THEN 'ENDED' - ELSE 'EXPOSED' END, - s.unpublishedAt, s.unpublishReason) + """ + TAB_CASE + """ + , s.unpublishedAt, s.unpublishReason) FROM EventSubmission s, User u WHERE u.id = s.userId AND s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED - AND (CASE WHEN s.startsOn > :today THEN 'UPCOMING' - WHEN s.endsOn < :today THEN 'ENDED' - ELSE 'EXPOSED' END) = :tab + AND (""" + TAB_CASE + """ + ) = :tab ORDER BY s.startsOn DESC, s.id DESC """, countQuery = """ SELECT COUNT(s) FROM EventSubmission s WHERE s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED - AND (CASE WHEN s.startsOn > :today THEN 'UPCOMING' - WHEN s.endsOn < :today THEN 'ENDED' - ELSE 'EXPOSED' END) = :tab + AND (""" + TAB_CASE + """ + ) = :tab """) Page findApprovedPageByTab(@Param("tab") String tab, @Param("today") LocalDate today, Pageable pageable); @@ -174,9 +180,8 @@ Page findApprovedPageByTab(@Param("tab") Stri @Query(""" SELECT COUNT(s) FROM EventSubmission s WHERE s.status = com.msg.fillmap.event.submission.entity.EventSubmissionStatus.APPROVED - AND (CASE WHEN s.startsOn > :today THEN 'UPCOMING' - WHEN s.endsOn < :today THEN 'ENDED' - ELSE 'EXPOSED' END) = :tab + AND (""" + TAB_CASE + """ + ) = :tab """) long countApprovedByTab(@Param("tab") String tab, @Param("today") LocalDate today); From 8216945dc6faad5147dd567e43f00064c2f07cb3 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:28:15 +0900 Subject: [PATCH 17/35] =?UTF-8?q?MSG-500=20feat:=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94=20=EB=B3=80=EA=B2=BD=20=EC=8B=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=203=EC=A2=85=EC=9D=84?= =?UTF-8?q?=20=EC=8B=A0=EC=84=A4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../java/com/msg/fillmap/user/exception/UserErrorCode.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/msg/fillmap/user/exception/UserErrorCode.java b/src/main/java/com/msg/fillmap/user/exception/UserErrorCode.java index 3848cd3a..cc1ae7fd 100644 --- a/src/main/java/com/msg/fillmap/user/exception/UserErrorCode.java +++ b/src/main/java/com/msg/fillmap/user/exception/UserErrorCode.java @@ -33,6 +33,12 @@ public enum UserErrorCode implements ErrorCodeIfs { INVALID_ORG_REQUEST_STATUS(1424, HttpStatus.BAD_REQUEST, "지원하지 않는 상태 필터입니다"), INVALID_PAGE_RANGE(1425, HttpStatus.BAD_REQUEST, "페이지 번호 또는 크기가 유효하지 않습니다"), ORG_ACCOUNT_REQUEST_MODIFIED(1426, HttpStatus.CONFLICT, "검토한 이후 요청 내용이 변경되었습니다"), + // 아이디 변경 요청 심사 (MSG-500 §API 7). 발급 요청용 1421·1422·1426 을 재사용하지 않는 이유는 + // 그 메시지가 "발급 요청"을 지목해 이 요청에 어긋나기 때문이다 — 코드는 셋 다 같은 판정의 쌍둥이다. + // 이메일 중복은 1409, 없는 사용자는 1404, 상태·페이지 검증은 1424·1425 를 그대로 쓴다(같은 도메인). + EMAIL_CHANGE_REQUEST_NOT_FOUND(1427, HttpStatus.NOT_FOUND, "존재하지 않는 아이디 변경 요청입니다"), + EMAIL_CHANGE_REQUEST_ALREADY_PROCESSED(1428, HttpStatus.CONFLICT, "이미 처리된 아이디 변경 요청입니다"), + EMAIL_CHANGE_REQUEST_MODIFIED(1429, HttpStatus.CONFLICT, "검토한 이후 요청 내용이 변경되었습니다"), ; private final Integer errorCode; From f56e517c863c5d98dc4e942ba456789f58eda3a4 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:28:15 +0900 Subject: [PATCH 18/35] =?UTF-8?q?MSG-500=20feat:=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94=20=EB=B3=80=EA=B2=BD=20=EC=8B=AC=EC=82=AC=EC=9D=98=20?= =?UTF-8?q?=EC=9B=90=EC=9E=90=20=EC=A0=84=EC=9D=B4=EC=99=80=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EB=8B=A8=EC=9D=BC=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전이 술어에 접수 시각 일치를 포함해 재제출 경합을 막고, 이메일 교체는 단일 컬럼 UPDATE라 동시 갱신을 되덮지 않는다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../OrgEmailChangeRequestRepository.java | 57 +++++++++++++++++++ .../user/repository/UserRepository.java | 16 ++++++ 2 files changed, 73 insertions(+) diff --git a/src/main/java/com/msg/fillmap/user/repository/OrgEmailChangeRequestRepository.java b/src/main/java/com/msg/fillmap/user/repository/OrgEmailChangeRequestRepository.java index 86be49dc..41cfeb65 100644 --- a/src/main/java/com/msg/fillmap/user/repository/OrgEmailChangeRequestRepository.java +++ b/src/main/java/com/msg/fillmap/user/repository/OrgEmailChangeRequestRepository.java @@ -3,12 +3,16 @@ import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; 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.user.dto.AdminEmailChangeRequestItemResponseDto; import com.msg.fillmap.user.entity.OrgEmailChangeRequest; +import com.msg.fillmap.user.entity.OrgEmailChangeStatus; public interface OrgEmailChangeRequestRepository extends JpaRepository { @@ -32,4 +36,57 @@ int upsertPending(@Param("userId") Long userId, @Param("email") String email, /** 관리자 큐(MSG-499)의 발판이자 접수 결과 검증축 — 사용자당 PENDING 은 부분 유니크 인덱스가 1건으로 강제한다. */ List findAllByUserId(Long userId); + + /** + * 관리자 큐 (MSG-500 §API 7) — 상태 필터 기준 접수 최신순이다. 계정의 기관명·현재 이메일이 심사 재료라 + * users 세타 조인 + 생성자 프로젝션이다(요청 엔티티가 userId 를 연관 없이 보관하는 기존 형태를 그대로 둔다). + */ + @Query(value = """ + SELECT new com.msg.fillmap.user.dto.AdminEmailChangeRequestItemResponseDto( + r.id, r.userId, u.orgName, u.email, r.requestedEmail, r.status, + r.createdAt, r.processedAt, r.rejectReason) + FROM OrgEmailChangeRequest r, User u + WHERE u.id = r.userId AND r.status = :status + ORDER BY r.createdAt DESC, r.id DESC + """, + countQuery = "SELECT COUNT(r) FROM OrgEmailChangeRequest r WHERE r.status = :status") + Page findAdminPageByStatus( + @Param("status") OrgEmailChangeStatus status, Pageable pageable); + + /** 탭 뱃지용 상태별 전체 건수 — 필터와 무관하다 (MSG-499 요청 큐 선례). */ + long countByStatus(OrgEmailChangeStatus status); + + /** + * 승인 전이 (MSG-500 D-13 검토 시점 가드). 술어에 접수 시각까지 넣어 상태와 내용을 원자로 비교한다 — + * 접수 UPSERT 가 재제출을 같은 PENDING 행의 제자리 갱신으로 처리하므로, 상태만 보면 관리자가 큐를 띄운 뒤 + * 재제출된 본 적 없는 이메일을 승인하게 된다. 영향 행이 0이면 호출자가 재조회로 가른다: + * 없으면 1427, PENDING 이 아니면 1428, PENDING 인데 시각이 다르면 1429 다. + *

+ * {@code flushAutomatically} 는 걸지 않는다 — 이 앞에 엔티티 수정이 없고(읽기뿐), 뒤따르는 + * users.email 교체도 벌크 UPDATE 라 두 문장의 순서가 코드 순서 그대로다. 앞쪽에 엔티티 수정이 생기면 + * 그 변경이 flush 되지 않은 채 이 UPDATE 가 먼저 나가므로 그때는 플래그가 필요하다. + */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE OrgEmailChangeRequest r + SET r.status = com.msg.fillmap.user.entity.OrgEmailChangeStatus.APPROVED, r.processedAt = :now + WHERE r.id = :id + AND r.status = com.msg.fillmap.user.entity.OrgEmailChangeStatus.PENDING + AND r.createdAt = :requestedAt + """) + int approvePending(@Param("id") Long id, @Param("requestedAt") LocalDateTime requestedAt, + @Param("now") LocalDateTime now); + + /** 반려 전이 — 승인과 같은 검토 시점 가드다(재제출로 내용이 바뀐 요청을 낡은 사유로 반려하지 않는다). */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE OrgEmailChangeRequest r + SET r.status = com.msg.fillmap.user.entity.OrgEmailChangeStatus.REJECTED, + r.processedAt = :now, r.rejectReason = :reason + WHERE r.id = :id + AND r.status = com.msg.fillmap.user.entity.OrgEmailChangeStatus.PENDING + AND r.createdAt = :requestedAt + """) + int rejectPending(@Param("id") Long id, @Param("requestedAt") LocalDateTime requestedAt, + @Param("reason") String reason, @Param("now") LocalDateTime now); } diff --git a/src/main/java/com/msg/fillmap/user/repository/UserRepository.java b/src/main/java/com/msg/fillmap/user/repository/UserRepository.java index 3c6e06eb..a61f673e 100644 --- a/src/main/java/com/msg/fillmap/user/repository/UserRepository.java +++ b/src/main/java/com/msg/fillmap/user/repository/UserRepository.java @@ -177,4 +177,20 @@ Page findAllByRoleAndProviderOrderByCreatedAtDesc(UserRole role, AuthProvi /** 이메일 완전 일치 필터 — 결과 유무가 곧 직접 발급 성공 여부다(위 목록과 같은 ORG·LOCAL 조건). */ Page findAllByRoleAndProviderAndEmailOrderByCreatedAtDesc(UserRole role, AuthProvider provider, String email, Pageable pageable); + + /** + * 로그인 아이디(이메일) 교체 (MSG-500 D-13) — 관리자가 아이디 변경 요청을 승인할 때만 부른다. + * + *

엔티티 더티 체킹이 아니라 단일 컬럼 UPDATE 인 것이 계약이다. 이 프로젝트는 + * {@code @DynamicUpdate} 를 쓰지 않아 관리 엔티티의 flush 가 전 컬럼 UPDATE 를 내보낸다 — 그 경로로 + * 교체하면 이메일 승인과 본인의 비밀번호·담당자 수정이 겹칠 때 낡은 승인 스냅숏이 다른 컬럼을 되돌린다. + * + *

동시에, 이 문장은 유니크 위반을 실행 즉시 드러낸다. 승인 서비스의 선검사는 경합에 지므로 + * (서로 다른 두 요청이 같은 이메일을 노리면 둘 다 통과한다) uq_users_email 위반이 잡을 자리가 필요한데, + * 벌크 UPDATE 는 커밋까지 미뤄지지 않고 여기서 DataIntegrityViolationException 으로 터진다. + * 반환은 영향 행 수 — 0 이면 그 사이 탈퇴한 사용자라 호출자가 1404 로 바꾼다. + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE User u SET u.email = :email WHERE u.id = :userId") + int updateEmail(@Param("userId") Long userId, @Param("email") String email); } From c7d253f9709d9bc0e328721c0dd3d5b2cd87a995 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:28:16 +0900 Subject: [PATCH 19/35] =?UTF-8?q?MSG-500=20feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=95=84=EC=9D=B4=EB=94=94=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EA=B2=80=ED=86=A0?= =?UTF-8?q?=ED=95=B4=20=EC=8A=B9=EC=9D=B8=ED=95=98=EA=B1=B0=EB=82=98=20?= =?UTF-8?q?=EB=B0=98=EB=A0=A4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인은 전이와 이메일 교체 한 트랜잭션이고, 통지는 새 이메일로 커밋 후 발송한다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../controller/AdminOrgAccountController.java | 68 +++++- ...dminEmailChangeRequestItemResponseDto.java | 48 +++++ ...dminEmailChangeRequestListResponseDto.java | 44 ++++ .../dto/EmailChangeApproveRequestDto.java | 19 ++ .../dto/EmailChangeApproveResponseDto.java | 21 ++ .../user/dto/EmailChangeRejectRequestDto.java | 23 ++ .../AdminEmailChangeRequestService.java | 198 ++++++++++++++++++ 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestItemResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestListResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveRequestDto.java create mode 100644 src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveResponseDto.java create mode 100644 src/main/java/com/msg/fillmap/user/dto/EmailChangeRejectRequestDto.java create mode 100644 src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java diff --git a/src/main/java/com/msg/fillmap/user/controller/AdminOrgAccountController.java b/src/main/java/com/msg/fillmap/user/controller/AdminOrgAccountController.java index 3e3c87c1..ffd6d234 100644 --- a/src/main/java/com/msg/fillmap/user/controller/AdminOrgAccountController.java +++ b/src/main/java/com/msg/fillmap/user/controller/AdminOrgAccountController.java @@ -17,14 +17,19 @@ import lombok.RequiredArgsConstructor; import com.msg.fillmap.response.SuccessResponse; +import com.msg.fillmap.user.dto.AdminEmailChangeRequestListResponseDto; import com.msg.fillmap.user.dto.AdminOrgAccountListResponseDto; import com.msg.fillmap.user.dto.AdminOrgAccountRequestDetailResponseDto; import com.msg.fillmap.user.dto.AdminOrgAccountRequestListResponseDto; +import com.msg.fillmap.user.dto.EmailChangeApproveRequestDto; +import com.msg.fillmap.user.dto.EmailChangeApproveResponseDto; +import com.msg.fillmap.user.dto.EmailChangeRejectRequestDto; import com.msg.fillmap.user.dto.OrgAccountCreateRequestDto; import com.msg.fillmap.user.dto.OrgAccountIssueResponseDto; import com.msg.fillmap.user.dto.OrgAccountRequestApproveRequestDto; import com.msg.fillmap.user.dto.OrgAccountRequestRejectRequestDto; import com.msg.fillmap.user.dto.OrgAccountResendResponseDto; +import com.msg.fillmap.user.service.AdminEmailChangeRequestService; import com.msg.fillmap.user.service.OrgAccountIssueService; import com.msg.fillmap.user.service.OrgAccountRequestService; @@ -36,7 +41,8 @@ */ @Tag( name = "관리자 행사 운영자 계정 (Admin Org Account)", - description = "계정 발급 요청 검토와 계정 발급·초기 비밀번호 재발송 API (MSG-499). ADMIN 권한 필수." + description = "계정 발급 요청 검토와 계정 발급·초기 비밀번호 재발송 API (MSG-499), " + + "아이디 변경 요청 심사 (MSG-500). ADMIN 권한 필수." ) @RestController @RequestMapping("/api/admin") @@ -45,6 +51,8 @@ public class AdminOrgAccountController { private final OrgAccountRequestService orgAccountRequestService; private final OrgAccountIssueService orgAccountIssueService; + // 아이디 변경 요청 심사 (MSG-500) — 계정 축의 한 화면이라 컨트롤러를 새로 만들지 않는다. + private final AdminEmailChangeRequestService adminEmailChangeRequestService; @Operation( summary = "계정 발급 요청 목록 조회", @@ -162,4 +170,62 @@ public SuccessResponse getAccounts( ) { return SuccessResponse.of(orgAccountIssueService.getAccounts(page, size, email)); } + + @Operation( + summary = "아이디 변경 요청 목록 조회", + description = "행사 운영자가 낸 아이디(공식 이메일) 변경 요청을 상태 필터 기준으로 접수 최신순 조회한다. " + + "기본은 대기(PENDING) 요청이다. 항목에 현재 아이디와 바꾸려는 이메일이 나란히 실려 그대로 대조할 " + + "수 있고, 상태별 건수 3종이 필터와 무관하게 함께 온다.\n\n" + + "응답의 createdAt 은 승인·반려 요청에 되돌려 보내야 하는 검토 기준 시각이다 — 재요청은 같은 대기 " + + "행을 덮어쓰므로, 이 값으로 걸러야 본 적 없는 이메일을 승인하는 사고가 없다.\n\n" + + "지원하지 않는 status 는 400(1424), page 음수나 size 범위(1~100) 밖은 400(1425) 이다." + ) + @GetMapping("/email-change-requests") + public SuccessResponse getEmailChangeRequests( + @Parameter(description = "처리 상태 필터 (PENDING, APPROVED, REJECTED — 대소문자 무관)", example = "PENDING") + @RequestParam(defaultValue = "PENDING") String status, + + @Parameter(description = "페이지 번호 (0부터)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기 (1~100)", example = "20") + @RequestParam(defaultValue = "20") int size + ) { + return SuccessResponse.of(adminEmailChangeRequestService.getRequests(status, page, size)); + } + + @Operation( + summary = "아이디 변경 요청 승인", + description = "요청한 이메일로 로그인 아이디를 교체하고 새 이메일로 변경 완료를 통지한다. 요청 전이와 " + + "이메일 교체는 한 트랜잭션이라 함께 성공하거나 함께 실패한다. 비밀번호와 세션은 그대로이며 다음 " + + "로그인부터 새 아이디를 쓴다.\n\n" + + "발급·반려 통보와 달리 메일을 보내는 이유는 로그인 수단 자체가 바뀌는 사건이라서다 — 알리지 않으면 " + + "행사 운영자가 계정 접근을 잃는다. 발송이 실패해도 교체는 유지되며 emailSent 가 false 로 온다.\n\n" + + "없는 요청은 404(1427), 이미 처리된 요청은 409(1428), 검토 이후 재요청으로 내용이 바뀌었으면 " + + "409(1429), 요청한 이메일이 이미 다른 계정에 있으면 409(1409) 다." + ) + @PostMapping("/email-change-requests/{requestId}/approve") + public SuccessResponse approveEmailChange( + @Parameter(description = "승인할 요청 id", example = "3") @PathVariable Long requestId, + @Valid @RequestBody EmailChangeApproveRequestDto request + ) { + return SuccessResponse.of(adminEmailChangeRequestService.approve(requestId, request)); + } + + @Operation( + summary = "아이디 변경 요청 반려", + description = "요청을 반려하고 사유를 저장한다. 아이디는 바뀌지 않고 메일도 발송되지 않는다 — 반려 " + + "통보는 수기이고 저장된 사유가 그 재료다. 처리 후에는 같은 계정이 다시 접수할 수 있다.\n\n" + + "검토 기준 시각을 승인과 똑같이 요구하는 것은, 검토한 내용과 다른 요청을 그 사유로 반려하는 어긋남을 " + + "막기 위해서다.\n\n" + + "없는 요청은 404(1427), 이미 처리된 요청은 409(1428), 검토 이후 내용이 바뀌었으면 409(1429) 다." + ) + @PostMapping("/email-change-requests/{requestId}/reject") + public SuccessResponse rejectEmailChange( + @Parameter(description = "반려할 요청 id", example = "3") @PathVariable Long requestId, + @Valid @RequestBody EmailChangeRejectRequestDto request + ) { + adminEmailChangeRequestService.reject(requestId, request); + return new SuccessResponse<>(null); + } } diff --git a/src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestItemResponseDto.java b/src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestItemResponseDto.java new file mode 100644 index 00000000..36f9da68 --- /dev/null +++ b/src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestItemResponseDto.java @@ -0,0 +1,48 @@ +package com.msg.fillmap.user.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +import com.msg.fillmap.user.entity.OrgEmailChangeStatus; + +/** + * 아이디 변경 요청 큐의 한 줄 (MSG-500 §API 7). 현재 아이디({@code email})와 바꾸려는 값 + * ({@code requestedEmail})을 나란히 주는 것이 심사의 전부다 — 관리자는 둘과 기관명을 대조해 판단한다. + * + *

{@code createdAt} 은 승인·반려 요청에 그대로 되돌려 보내야 하는 검토 기준 시각이다. 접수가 + * 같은 대기 행을 제자리 갱신하므로(재제출), 이 값이 어긋나면 관리자가 본 적 없는 이메일을 승인하게 된다. + */ +@Schema(description = "아이디 변경 요청 큐 항목", + requiredProperties = {"id", "userId", "orgName", "email", "requestedEmail", "status", "createdAt", + "processedAt", "rejectReason"}) +public record AdminEmailChangeRequestItemResponseDto( + @Schema(description = "요청 id", example = "3") + Long id, + + @Schema(description = "요청한 계정 id", example = "42") + Long userId, + + @Schema(description = "기관명", example = "부산광역시 부산진구청", nullable = true) + String orgName, + + @Schema(description = "현재 아이디(로그인 이메일)", example = "event@busanjin.go.kr") + String email, + + @Schema(description = "바꾸려는 이메일", example = "festival@busanjin.go.kr") + String requestedEmail, + + @Schema(description = "처리 상태", example = "PENDING") + OrgEmailChangeStatus status, + + @Schema(description = "마지막 접수 시각 (UTC) — 승인·반려 요청에 되돌려 보내는 검토 기준 시각", + example = "2026-08-28T02:00:00Z") + LocalDateTime createdAt, + + @Schema(description = "처리 시각 (UTC) — 대기 중이면 null", nullable = true) + LocalDateTime processedAt, + + @Schema(description = "반려 사유 — 반려된 요청에만 있다", nullable = true) + String rejectReason +) { +} diff --git a/src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestListResponseDto.java b/src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestListResponseDto.java new file mode 100644 index 00000000..56753d7f --- /dev/null +++ b/src/main/java/com/msg/fillmap/user/dto/AdminEmailChangeRequestListResponseDto.java @@ -0,0 +1,44 @@ +package com.msg.fillmap.user.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.springframework.data.domain.Page; + +/** + * 아이디 변경 요청 큐 (MSG-500 §API 7). 계정 발급 요청 큐(MSG-499)와 같은 구조다 — 관리자 전용 소량 + * 데이터라 오프셋 페이징이고, 건수 3종은 상태 필터와 무관한 전체 집계로 탭 뱃지의 재료다. + */ +@Schema(description = "아이디 변경 요청 목록 — 상태 필터 기준 한 페이지와 상태별 전체 건수.", + requiredProperties = {"pendingCount", "approvedCount", "rejectedCount", "totalElements", "page", "size", + "requests"}) +public record AdminEmailChangeRequestListResponseDto( + @Schema(description = "대기 건수 (필터와 무관한 전체 집계)", example = "2") + long pendingCount, + + @Schema(description = "승인 건수 (필터와 무관한 전체 집계)", example = "7") + long approvedCount, + + @Schema(description = "반려 건수 (필터와 무관한 전체 집계)", example = "1") + long rejectedCount, + + @Schema(description = "필터에 해당하는 전체 요청 수", example = "2") + long totalElements, + + @Schema(description = "현재 페이지 번호 (0부터)", example = "0") + int page, + + @Schema(description = "페이지 크기", example = "20") + int size, + + @Schema(description = "이 페이지의 요청 목록. 정렬은 마지막 접수 최신순 고정") + List requests +) { + + public static AdminEmailChangeRequestListResponseDto of(Page page, + long pendingCount, long approvedCount, long rejectedCount) { + return new AdminEmailChangeRequestListResponseDto(pendingCount, approvedCount, rejectedCount, + page.getTotalElements(), page.getNumber(), page.getSize(), page.getContent()); + } +} diff --git a/src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveRequestDto.java b/src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveRequestDto.java new file mode 100644 index 00000000..200284fe --- /dev/null +++ b/src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveRequestDto.java @@ -0,0 +1,19 @@ +package com.msg.fillmap.user.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotNull; + +/** + * 아이디 변경 요청 승인 (MSG-500 §API 7). 본문이 검토 기준 시각 하나인 것은 낙관 가드이기 때문이다 — + * 목록·상세에서 본 {@code createdAt} 을 그대로 돌려보내면 전이 UPDATE 술어가 그 값과 원자로 비교해, + * 검토 이후 재제출된 요청을 승인하는 사고를 막는다 (MSG-499 발급 요청 승인과 같은 패턴). + */ +@Schema(description = "아이디 변경 요청 승인", requiredProperties = {"requestedAt"}) +public record EmailChangeApproveRequestDto( + @Schema(description = "검토한 요청의 접수 시각 (목록의 createdAt 을 그대로)", example = "2026-08-28T02:00:00Z") + @NotNull LocalDateTime requestedAt +) { +} diff --git a/src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveResponseDto.java b/src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveResponseDto.java new file mode 100644 index 00000000..56e6ef7f --- /dev/null +++ b/src/main/java/com/msg/fillmap/user/dto/EmailChangeApproveResponseDto.java @@ -0,0 +1,21 @@ +package com.msg.fillmap.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 아이디 변경 승인 결과 (MSG-500 §API 7). {@code emailSent} 가 false 여도 아이디는 이미 바뀌었다 — + * 발송 실패가 교체를 되돌리지 않는다. 이 흐름만 메일을 보내는 이유는 로그인 수단 자체가 바뀌는 + * 사건이라, 알리지 않으면 행사 운영자가 계정 접근을 잃기 때문이다(발급·반려 통보는 수기). + */ +@Schema(description = "아이디 변경 승인 결과", requiredProperties = {"requestId", "email", "emailSent"}) +public record EmailChangeApproveResponseDto( + @Schema(description = "승인한 요청 id", example = "3") + Long requestId, + + @Schema(description = "교체된 새 아이디(로그인 이메일)", example = "festival@busanjin.go.kr") + String email, + + @Schema(description = "새 이메일로 보낸 통지 성공 여부 — false 여도 교체는 유지된다", example = "true") + boolean emailSent +) { +} diff --git a/src/main/java/com/msg/fillmap/user/dto/EmailChangeRejectRequestDto.java b/src/main/java/com/msg/fillmap/user/dto/EmailChangeRejectRequestDto.java new file mode 100644 index 00000000..20dbe4f0 --- /dev/null +++ b/src/main/java/com/msg/fillmap/user/dto/EmailChangeRejectRequestDto.java @@ -0,0 +1,23 @@ +package com.msg.fillmap.user.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 아이디 변경 요청 반려 (MSG-500 §API 7). 검토 기준 시각을 승인과 똑같이 요구하는 이유는, 검토한 내용과 + * 다른 요청을 그 사유로 반려하는 어긋남을 막기 위해서다(재제출로 이메일이 바뀐 뒤의 낡은 사유). + * 사유는 필수이고 메일은 나가지 않는다 — 반려 통보는 수기이고 저장된 사유가 그 재료다. + */ +@Schema(description = "아이디 변경 요청 반려", requiredProperties = {"requestedAt", "reason"}) +public record EmailChangeRejectRequestDto( + @Schema(description = "검토한 요청의 접수 시각 (목록의 createdAt 을 그대로)", example = "2026-08-28T02:00:00Z") + @NotNull LocalDateTime requestedAt, + + @Schema(description = "반려 사유", example = "기관 도메인이 아닌 이메일이라 반려합니다") + @NotBlank String reason +) { +} diff --git a/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java b/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java new file mode 100644 index 00000000..ebd618ca --- /dev/null +++ b/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java @@ -0,0 +1,198 @@ +package com.msg.fillmap.user.service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Locale; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import lombok.extern.slf4j.Slf4j; + +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.global.mail.MailSender; +import com.msg.fillmap.user.dto.AdminEmailChangeRequestListResponseDto; +import com.msg.fillmap.user.dto.EmailChangeApproveRequestDto; +import com.msg.fillmap.user.dto.EmailChangeApproveResponseDto; +import com.msg.fillmap.user.dto.EmailChangeRejectRequestDto; +import com.msg.fillmap.user.entity.OrgEmailChangeRequest; +import com.msg.fillmap.user.entity.OrgEmailChangeStatus; +import com.msg.fillmap.user.exception.UserErrorCode; +import com.msg.fillmap.user.repository.OrgEmailChangeRequestRepository; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 아이디(공식 이메일) 변경 요청 심사 (MSG-500 §API 7, D-13). MSG-497 이 접수까지 만들어 둔 요청이 어느 + * 화면에도 안 걸려 있던 것을 이 서비스가 닫는다 — 계정 관련 심사라 event 가 아니라 user 도메인이고, + * 에러 대역도 1xxx 다. + * + *

승인은 두 갱신이 한 트랜잭션이다: 요청을 PENDING 에서 APPROVED 로 전이하고 같은 트랜잭션에서 + * {@code users.email} 을 교체한다. 둘 다 벌크 UPDATE 라 순서가 코드 순서 그대로이고, 엔티티를 + * 로드해 더티 체킹으로 바꾸지 않는다(전 컬럼 UPDATE 가 다른 갱신을 되덮는 것을 막는다 — D-13). + * + *

통지 메일은 커밋 뒤 새 이메일로 보낸다. 안에 두면 SES 왕복이 커넥션을 붙들고, 발송 성공 뒤 + * 커밋이 실패하면 "바뀌었다고 통지했는데 아직 옛 아이디"인 역방향 구멍이 생긴다. + */ +@Slf4j +@Service +public class AdminEmailChangeRequestService { + + private static final int MIN_PAGE_SIZE = 1; + private static final int MAX_PAGE_SIZE = 100; + + private static final String MAIL_SUBJECT = "[필맵] 로그인 아이디 변경 완료 안내"; + private static final String MAIL_BODY_FORMAT = """ + 요청하신 로그인 아이디(이메일) 변경이 승인되었습니다. + + 새 아이디: %s + + 다음 로그인부터 이 주소를 사용해 주세요. 비밀번호는 그대로입니다."""; + + private final OrgEmailChangeRequestRepository requestRepository; + private final UserRepository userRepository; + private final MailSender mailSender; + private final TransactionTemplate transactionTemplate; + private final Clock clock; + + /** 프로덕션 생성자 — clock 을 UTC 로 고정해 전체 생성자로 위임한다 (OrgAccountRequestService 선례). */ + @Autowired + public AdminEmailChangeRequestService(OrgEmailChangeRequestRepository requestRepository, + UserRepository userRepository, MailSender mailSender, TransactionTemplate transactionTemplate) { + this(requestRepository, userRepository, mailSender, transactionTemplate, Clock.systemUTC()); + } + + public AdminEmailChangeRequestService(OrgEmailChangeRequestRepository requestRepository, + UserRepository userRepository, MailSender mailSender, TransactionTemplate transactionTemplate, Clock clock) { + this.requestRepository = requestRepository; + this.userRepository = userRepository; + this.mailSender = mailSender; + this.transactionTemplate = transactionTemplate; + this.clock = clock; + } + + /** + * 요청 큐 (§API 7). 상태 필터 기준 접수 최신순이고 건수 3종은 필터와 무관한 전체 집계다. 거부 판정은 + * 발급 요청 큐와 같은 코드다 — 같은 도메인·같은 판정이라 상수를 새로 만들지 않는다(1424 → 1425 순서). + */ + @Transactional(readOnly = true) + public AdminEmailChangeRequestListResponseDto getRequests(String status, int page, int size) { + OrgEmailChangeStatus filter = parseStatus(status); + // PageRequest.of 에 그냥 넘기면 IllegalArgumentException 이 catch-all 핸들러에서 500 이 된다 + // (AdminReportServiceImpl 선례). 오프셋이 int 를 넘는 극단 양수도 같다. + if (page < 0 || size < MIN_PAGE_SIZE || size > MAX_PAGE_SIZE + || (long) page * size > Integer.MAX_VALUE) { + throw new ApiException(UserErrorCode.INVALID_PAGE_RANGE); + } + return AdminEmailChangeRequestListResponseDto.of( + requestRepository.findAdminPageByStatus(filter, PageRequest.of(page, size)), + requestRepository.countByStatus(OrgEmailChangeStatus.PENDING), + requestRepository.countByStatus(OrgEmailChangeStatus.APPROVED), + requestRepository.countByStatus(OrgEmailChangeStatus.REJECTED)); + } + + /** + * 승인 (§API 7, D-13). 실행 순서가 계약이다. + *

+ * ① 요청을 읽어 상태와 검토 시각을 먼저 본다(빠른 거절, 값도 여기서 챙긴다). ② 새 이메일이 다른 계정에 + * 있는지 선검사한다 — 읽히는 1409 를 주기 위한 것이고 경합에는 진다(서로 다른 두 요청이 같은 + * 이메일을 노리면 둘 다 통과한다). ③ 조건부 UPDATE 로 전이한다. 술어에 접수 시각이 들어 있어 검토 이후 + * 재제출된 요청은 여기서 걸린다. ④ 0행이면 재조회로 가른다(1427·1428·1429). ⑤ 같은 트랜잭션에서 이메일을 + * 교체한다 — 벌크 UPDATE 라 uq_users_email 위반이 실행 즉시 뜨므로 그 자리에서 1409 로 번역한다 + * (커밋 시점까지 미뤄지면 잡을 자리가 없다). ⑥ 커밋 후 새 이메일로 통지한다. + */ + public EmailChangeApproveResponseDto approve(Long requestId, EmailChangeApproveRequestDto request) { + Approved approved = transactionTemplate.execute(status -> { + OrgEmailChangeRequest target = findReviewable(requestId, request.requestedAt()); + String newEmail = target.getRequestedEmail(); + if (userRepository.existsByEmail(newEmail)) { + throw new ApiException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + + LocalDateTime now = LocalDateTime.now(clock); + if (requestRepository.approvePending(requestId, request.requestedAt(), now) == 0) { + throw transitionFailure(requestId, request.requestedAt()); + } + replaceEmail(target.getUserId(), newEmail); + return new Approved(target.getUserId(), newEmail); + }); + return new EmailChangeApproveResponseDto(requestId, approved.email(), notify(approved.email())); + } + + /** + * 반려 (§API 7). 승인과 같은 검토 시점 가드를 쓰고 사유를 요청 행에 남긴다. 이메일은 그대로다. + * 메일을 보내지 않는다 — 반려 통보는 수기이고 저장된 사유가 그 재료다(MSG-499 반려 선례). + */ + @Transactional + public void reject(Long requestId, EmailChangeRejectRequestDto request) { + findReviewable(requestId, request.requestedAt()); + if (requestRepository.rejectPending(requestId, request.requestedAt(), request.reason(), + LocalDateTime.now(clock)) == 0) { + throw transitionFailure(requestId, request.requestedAt()); + } + } + + /** + * 이메일 교체 (D-13) — 단일 컬럼 UPDATE 다. 영향 행 0은 그 사이 탈퇴한 계정이고, 유니크 위반은 같은 + * 이메일을 노린 다른 승인이 먼저 커밋한 경우라 선검사와 같은 1409 로 수렴시킨다. + */ + private void replaceEmail(Long userId, String newEmail) { + try { + if (userRepository.updateEmail(userId, newEmail) == 0) { + throw new ApiException(UserErrorCode.USER_NOT_FOUND); + } + } catch (DataIntegrityViolationException e) { + throw new ApiException(UserErrorCode.EMAIL_ALREADY_EXISTS, e); + } + } + + /** 통지 — 실패는 삼키고 false 로 알린다. 아이디는 이미 바뀌었고 발송 실패가 그것을 되돌릴 이유가 아니다. */ + private boolean notify(String newEmail) { + try { + mailSender.send(newEmail, MAIL_SUBJECT, MAIL_BODY_FORMAT.formatted(newEmail)); + return true; + } catch (RuntimeException e) { + log.error("아이디 변경 통지 발송 실패 — 교체는 유지된다: to={}", newEmail, e); + return false; + } + } + + /** 심사 대상 조회 — 없으면 1427, 대기가 아니면 1428, 검토 이후 재제출됐으면 1429 (전이 전 빠른 거절). */ + private OrgEmailChangeRequest findReviewable(Long requestId, LocalDateTime reviewedAt) { + OrgEmailChangeRequest request = requestRepository.findById(requestId) + .orElseThrow(() -> new ApiException(UserErrorCode.EMAIL_CHANGE_REQUEST_NOT_FOUND)); + if (request.getStatus() != OrgEmailChangeStatus.PENDING) { + throw new ApiException(UserErrorCode.EMAIL_CHANGE_REQUEST_ALREADY_PROCESSED); + } + if (!request.getCreatedAt().equals(reviewedAt)) { + throw new ApiException(UserErrorCode.EMAIL_CHANGE_REQUEST_MODIFIED); + } + return request; + } + + /** 조건부 UPDATE 가 0행일 때의 이유 — 빠른 거절 뒤에 끼어든 동시 처리·재제출을 여기서 다시 가른다. */ + private ApiException transitionFailure(Long requestId, LocalDateTime reviewedAt) { + OrgEmailChangeRequest request = requestRepository.findById(requestId) + .orElseThrow(() -> new ApiException(UserErrorCode.EMAIL_CHANGE_REQUEST_NOT_FOUND)); + if (request.getStatus() != OrgEmailChangeStatus.PENDING) { + return new ApiException(UserErrorCode.EMAIL_CHANGE_REQUEST_ALREADY_PROCESSED); + } + return new ApiException(UserErrorCode.EMAIL_CHANGE_REQUEST_MODIFIED); + } + + /** 클라이언트 문자열 → 상태 (발급 요청 큐 parseStatus 선례). 조용한 기본값 폴백 없이 1424 로 거른다. */ + private OrgEmailChangeStatus parseStatus(String status) { + try { + return OrgEmailChangeStatus.valueOf(status.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new ApiException(UserErrorCode.INVALID_ORG_REQUEST_STATUS); + } + } + + /** 커밋 뒤 발송에 필요한 값만 트랜잭션 밖으로 들고 나온다 — 엔티티를 밖으로 흘리지 않는다. */ + private record Approved(Long userId, String email) { + } +} From 6eb097f39a181101e68ea71e14461eccf71e9581 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:28:16 +0900 Subject: [PATCH 20/35] =?UTF-8?q?MSG-500=20test:=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94=20=EB=B3=80=EA=B2=BD=20=EC=8B=AC=EC=82=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=2017=EA=B1=B4=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검토 시점 가드와 동시 승인 경합, 커밋된 비밀번호 변경 생존까지 계약 단위로 덮는다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../AdminEmailChangeReviewTest.java | 394 ++++++++++++++++++ .../EmailChangeApprovalConcurrencyTest.java | 184 ++++++++ 2 files changed, 578 insertions(+) create mode 100644 src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java create mode 100644 src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java diff --git a/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java b/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java new file mode 100644 index 00000000..57ae6186 --- /dev/null +++ b/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java @@ -0,0 +1,394 @@ +package com.msg.fillmap.user.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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.time.temporal.ChronoUnit; +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.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.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.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.global.mail.MailSender; +import com.msg.fillmap.user.entity.OrgEmailChangeRequest; +import com.msg.fillmap.user.entity.OrgEmailChangeStatus; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.repository.OrgEmailChangeRequestRepository; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 아이디 변경 요청 심사 (MSG-500 §API 7, 실 DB). 검증의 핵심은 검토 시점 가드다 — 접수가 같은 대기 + * 행을 제자리 갱신하므로(재요청), 상태만 보고 처리하면 관리자가 본 적 없는 이메일을 승인한다. 그 성질은 + * UPSERT·부분 유니크·조건부 UPDATE 가 함께 만드는 것이라 실제 DB 없이는 재현되지 않는다. + * + *

{@code @Transactional} 롤백 격리로 계정·요청을 남기지 않는다. 동시 승인 경합과 컬럼 보존은 진짜 커밋이 + * 필요해 {@code EmailChangeApprovalConcurrencyTest} 가 따로 본다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("아이디 변경 요청 심사 (MSG-500, 실 DB)") +class AdminEmailChangeReviewTest { + + private static final String QUEUE_URL = "/api/admin/email-change-requests"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private OrgEmailChangeRequestRepository requestRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private EntityManager entityManager; + + @MockitoBean + private MailSender mailSender; + + private User organizer; + private User admin; + private String requestedEmail; + private long requestId; + + @BeforeEach + void setUp() { + organizer = saveUser(UserRole.ORG); + admin = saveUser(UserRole.ADMIN); + requestedEmail = "changed-" + UUID.randomUUID() + "@fillmap.dev"; + requestId = 접수한다(requestedEmail); + } + + private User saveUser(UserRole role) { + User user = User.createLocalUser("before-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", role); + ReflectionTestUtils.setField(user, "orgName", "부산광역시 부산진구청"); + return userRepository.saveAndFlush(user); + } + + /** 행사 운영자 접수 경로(MSG-497)를 그대로 탄다 — 재접수가 같은 행을 덮어쓰는 성질이 이 테스트의 전제다. */ + private long 접수한다(String email) { + entityManager.flush(); + requestRepository.upsertPending(organizer.getId(), email, + LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MICROS)); + entityManager.clear(); + return 저장된_요청().getId(); + } + + private OrgEmailChangeRequest 저장된_요청() { + entityManager.flush(); + entityManager.clear(); + return requestRepository.findAllByUserId(organizer.getId()).stream() + .max((left, right) -> left.getId().compareTo(right.getId())) + .orElseThrow(); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + + /** 저장된 접수 시각을 그대로 되돌려 보내는 형태 — 이 값이 검토 기준 시각이다(UTC 표기). */ + private String reviewedAt() { + return 저장된_요청().getCreatedAt() + "Z"; + } + + private ResultActions 승인한다(long targetRequestId, String requestedAt) throws Exception { + entityManager.flush(); + return mockMvc.perform(post(QUEUE_URL + "/" + targetRequestId + "/approve") + .header(HttpHeaders.AUTHORIZATION, bearer(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"requestedAt": "%s"}""".formatted(requestedAt))); + } + + private ResultActions 승인한다() throws Exception { + return 승인한다(requestId, reviewedAt()); + } + + private ResultActions 반려한다(long targetRequestId, String requestedAt, String reason) throws Exception { + entityManager.flush(); + return mockMvc.perform(post(QUEUE_URL + "/" + targetRequestId + "/reject") + .header(HttpHeaders.AUTHORIZATION, bearer(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"requestedAt": "%s", "reason": "%s"}""".formatted(requestedAt, reason))); + } + + private User 저장된_계정() { + entityManager.flush(); + entityManager.clear(); + return userRepository.findById(organizer.getId()).orElseThrow(); + } + + @Nested + @DisplayName("승인") + class Approve { + + // 검증: FR-USER-16 + @Test + @DisplayName("승인하면 요청이 승인됨이 되고 로그인 이메일이 요청값으로 바뀐다") + void 승인하면_요청이_승인됨이_되고_로그인_이메일이_요청값으로_바뀐다() throws Exception { + 승인한다() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.email").value(requestedEmail)) + .andExpect(jsonPath("$.data.emailSent").value(true)); + + // 두 갱신이 한 트랜잭션이다 — 요청만 승인되고 이메일이 그대로인 절반이 남지 않는다. + OrgEmailChangeRequest processed = 저장된_요청(); + assertThat(processed.getStatus()).isEqualTo(OrgEmailChangeStatus.APPROVED); + assertThat(processed.getProcessedAt()).isNotNull(); + assertThat(processed.getRejectReason()).isNull(); + assertThat(저장된_계정().getEmail()).isEqualTo(requestedEmail); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("승인 통지는 새 이메일로 가고 발송이 실패해도 승인은 유지된다") + void 승인_통지_메일이_새_이메일로_발송되고_실패해도_승인은_유지된다() throws Exception { + 승인한다().andExpect(status().isOk()); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + // 옛 주소로 보내면 계정 접근을 잃은 사람에게 안내가 가지 않는다 — 수신자가 새 이메일이어야 한다. + then(mailSender).should().send(eq(requestedEmail), anyString(), body.capture()); + assertThat(body.getValue()).contains(requestedEmail); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("발송이 실패해도 이메일 교체는 커밋된 채 남고 emailSent 가 거짓이다") + void 발송이_실패해도_승인은_유지된다() throws Exception { + willThrow(new IllegalStateException("SES 장애")).given(mailSender) + .send(anyString(), anyString(), anyString()); + + 승인한다() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.emailSent").value(false)); + + assertThat(저장된_계정().getEmail()).isEqualTo(requestedEmail); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("요청 이메일이 이미 다른 계정에 있으면 1409 이고 아이디는 그대로다") + void 요청_이메일이_이미_다른_계정에_있으면_1409로_거부된다() throws Exception { + String taken = "taken-" + UUID.randomUUID() + "@fillmap.dev"; + User other = saveUser(UserRole.ORG); + ReflectionTestUtils.setField(other, "email", taken); + userRepository.saveAndFlush(other); + long conflictRequestId = 접수한다(taken); + + 승인한다(conflictRequestId, reviewedAt()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(1409)); + + assertThat(저장된_계정().getEmail()).isNotEqualTo(taken); + assertThat(저장된_요청().getStatus()).isEqualTo(OrgEmailChangeStatus.PENDING); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("이미 처리된 요청의 재처리는 1428 이다") + void 이미_처리된_요청의_재처리는_1428이다() throws Exception { + String reviewedAt = reviewedAt(); + 승인한다(requestId, reviewedAt).andExpect(status().isOk()); + + 승인한다(requestId, reviewedAt) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(1428)); + 반려한다(requestId, reviewedAt, "뒤늦은 반려") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(1428)); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("없는 요청은 1427 이다") + void 없는_요청은_1427이다() throws Exception { + 승인한다(99_999_999L, reviewedAt()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.developCode").value(1427)); + } + } + + @Nested + @DisplayName("검토 시점 가드") + class ReviewedAtGuard { + + // 검증: FR-USER-16 + @Test + @DisplayName("검토 후 재접수된 요청의 승인과 반려는 1429 로 거부된다") + void 검토_후_재제출된_요청의_승인과_반려는_1429로_거부된다() throws Exception { + String 검토한_시각 = reviewedAt(); + // 관리자가 큐를 띄운 뒤 행사 운영자가 다른 이메일로 다시 접수한다 — 같은 행이 제자리 갱신된다. + String 새_요청_이메일 = "resubmitted-" + UUID.randomUUID() + "@fillmap.dev"; + 접수한다(새_요청_이메일); + + 승인한다(requestId, 검토한_시각) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(1429)); + 반려한다(requestId, 검토한_시각, "낡은 사유") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(1429)); + + // 본 적 없는 이메일이 승인되지 않았다 — 가드가 없으면 여기서 바뀐다. + assertThat(저장된_계정().getEmail()).isNotEqualTo(새_요청_이메일); + assertThat(저장된_요청().getStatus()).isEqualTo(OrgEmailChangeStatus.PENDING); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("다시 읽은 접수 시각으로는 승인된다 — 가드는 재검토를 요구할 뿐 막지 않는다") + void 다시_읽은_접수_시각으로는_승인된다() throws Exception { + String 새_요청_이메일 = "resubmitted-" + UUID.randomUUID() + "@fillmap.dev"; + 접수한다(새_요청_이메일); + + 승인한다(requestId, reviewedAt()).andExpect(status().isOk()); + + assertThat(저장된_계정().getEmail()).isEqualTo(새_요청_이메일); + } + } + + @Nested + @DisplayName("반려") + class Reject { + + // 검증: FR-USER-16 + @Test + @DisplayName("반려는 사유가 저장되고 이메일은 바뀌지 않으며 메일도 나가지 않는다") + void 반려는_사유가_저장되고_이메일은_바뀌지_않는다() throws Exception { + String before = 저장된_계정().getEmail(); + + 반려한다(requestId, reviewedAt(), "기관 도메인이 아닌 이메일이라 반려합니다") + .andExpect(status().isOk()); + + OrgEmailChangeRequest rejected = 저장된_요청(); + assertThat(rejected.getStatus()).isEqualTo(OrgEmailChangeStatus.REJECTED); + assertThat(rejected.getRejectReason()).isEqualTo("기관 도메인이 아닌 이메일이라 반려합니다"); + assertThat(rejected.getProcessedAt()).isNotNull(); + assertThat(저장된_계정().getEmail()).isEqualTo(before); + // 반려 통보는 수기다 — 저장된 사유가 그 재료이고 자동 발송은 없다. + then(mailSender).should(never()).send(anyString(), anyString(), anyString()); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("처리 후 같은 계정이 다시 접수할 수 있다 — 부분 유니크가 풀린다") + void 처리_후_같은_계정이_다시_접수할_수_있다() throws Exception { + 반려한다(requestId, reviewedAt(), "사유").andExpect(status().isOk()); + + long 재접수 = 접수한다("again-" + UUID.randomUUID() + "@fillmap.dev"); + + // 새 행이 생긴다 — 처리된 행은 PENDING 이 아니라 부분 유니크 인덱스에 걸리지 않는다. + assertThat(재접수).isNotEqualTo(requestId); + assertThat(requestRepository.findAllByUserId(organizer.getId())).hasSize(2); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("사유가 비면 공통 400 이다") + void 사유가_비면_공통_400이다() throws Exception { + 반려한다(requestId, reviewedAt(), " ") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(400)); + } + } + + @Nested + @DisplayName("요청 큐") + class Queue { + + // 검증: FR-USER-16 + @Test + @DisplayName("큐 항목은 현재 아이디와 바꾸려는 이메일을 나란히 준다") + void 큐_항목은_현재_아이디와_요청_이메일을_함께_준다() throws Exception { + mockMvc.perform(get(QUEUE_URL + "?size=100").header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.requests[?(@.id == %d)].email".formatted(requestId)) + .value(organizer.getEmail())) + .andExpect(jsonPath("$.data.requests[?(@.id == %d)].requestedEmail".formatted(requestId)) + .value(requestedEmail)) + .andExpect(jsonPath("$.data.requests[?(@.id == %d)].orgName".formatted(requestId)) + .value("부산광역시 부산진구청")) + .andExpect(jsonPath("$.data.requests[?(@.id == %d)].status".formatted(requestId)) + .value("PENDING")); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("건수 3종은 상태 필터와 무관한 전체 집계다") + void 건수는_상태_필터와_무관한_전체_집계다() throws Exception { + long 처음_승인 = 건수("approvedCount"); + + 승인한다().andExpect(status().isOk()); + + // 필터가 대기여도 승인 건수가 함께 늘어난다 — 탭 뱃지 재료라 필터를 타지 않는다. + assertThat(건수("approvedCount")).isEqualTo(처음_승인 + 1); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("파라미터 거절은 발급 요청 큐와 같은 코드다 — 1424·1425") + void 파라미터_거절은_발급_요청_큐와_같은_코드다() throws Exception { + mockMvc.perform(get(QUEUE_URL + "?status=UNKNOWN").header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(1424)); + mockMvc.perform(get(QUEUE_URL + "?size=101").header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.developCode").value(1425)); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("관리자가 아니면 요청 큐에 접근할 수 없다") + void 관리자가_아니면_요청_큐에_접근할_수_없다() throws Exception { + mockMvc.perform(get(QUEUE_URL).header(HttpHeaders.AUTHORIZATION, bearer(organizer))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(QUEUE_URL)).andExpect(status().isUnauthorized()); + } + + private long 건수(String field) throws Exception { + entityManager.flush(); + entityManager.clear(); + String response = mockMvc.perform(get(QUEUE_URL).header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return ((Number) com.jayway.jsonpath.JsonPath.read(response, "$.data." + field)).longValue(); + } + } +} diff --git a/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java new file mode 100644 index 00000000..bb590767 --- /dev/null +++ b/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java @@ -0,0 +1,184 @@ +package com.msg.fillmap.user.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; + +import com.msg.fillmap.global.exception.ApiException; +import com.msg.fillmap.global.mail.MailSender; +import com.msg.fillmap.user.dto.EmailChangeApproveRequestDto; +import com.msg.fillmap.user.dto.EmailChangeApproveResponseDto; +import com.msg.fillmap.user.entity.OrgEmailChangeRequest; +import com.msg.fillmap.user.entity.User; +import com.msg.fillmap.user.entity.UserRole; +import com.msg.fillmap.user.exception.UserErrorCode; +import com.msg.fillmap.user.repository.OrgEmailChangeRequestRepository; +import com.msg.fillmap.user.repository.UserRepository; + +/** + * 아이디 변경 승인의 커밋 경계 (MSG-500 D-13, 실 DB). 두 가지를 본다: 같은 이메일을 노린 동시 승인이 + * 읽히는 1409 로 끝나는지(유니크 위반 500 이 아니라), 그리고 승인이 email 컬럼만 갱신해 다른 + * 트랜잭션이 방금 커밋한 값을 되돌리지 않는지다. + * + *

진짜 커밋이 필요해 클래스 수준 {@code @Transactional} 롤백 격리를 쓸 수 없다 — 동시 승인은 두 + * 트랜잭션이 실제로 경합해야 하고, 컬럼 보존은 "다른 트랜잭션이 먼저 커밋한 상태"가 전제다 + * (OrgAccountApprovalConcurrencyTest 와 같은 이유). 대신 만든 행을 직접 지워 공유 로컬 DB 를 되돌린다 — + * 요청 행은 FK ON DELETE CASCADE 로 계정과 함께 사라진다. + */ +@SpringBootTest +@DisplayName("아이디 변경 승인 동시성과 컬럼 보존 (실 DB)") +class EmailChangeApprovalConcurrencyTest { + + @Autowired + private AdminEmailChangeRequestService adminEmailChangeRequestService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private OrgEmailChangeRequestRepository requestRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TransactionTemplate transactionTemplate; + + /** 통지 발송을 갈아 끼운다 — 이 클래스가 보는 것은 발송이 아니라 커밋 경계다. */ + @MockitoBean + private MailSender mailSender; + + private final List 정리할_계정 = new ArrayList<>(); + + @AfterEach + void tearDown() { + transactionTemplate.executeWithoutResult(status -> + 정리할_계정.forEach(userId -> jdbcTemplate.update("DELETE FROM users WHERE id = ?", userId))); + 정리할_계정.clear(); + } + + private long 계정을_만든다() { + Long userId = transactionTemplate.execute(status -> { + User user = User.createLocalUser("concurrent-" + UUID.randomUUID() + "@fillmap.dev", + "{noop}Initial1234", "김담당"); + ReflectionTestUtils.setField(user, "role", UserRole.ORG); + return userRepository.saveAndFlush(user).getId(); + }); + 정리할_계정.add(userId); + return userId; + } + + private OrgEmailChangeRequest 접수한다(long userId, String email) { + transactionTemplate.executeWithoutResult(status -> requestRepository.upsertPending(userId, email, + LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MICROS))); + return requestRepository.findAllByUserId(userId).getFirst(); + } + + // 검증: FR-USER-16 + @Test + @DisplayName("같은 이메일을 노리는 두 요청의 동시 승인은 늦은 쪽이 1409 다 — 유니크 위반 500 이 아니다") + void 같은_이메일을_노리는_두_요청의_동시_승인은_늦은_쪽이_1409다() throws Exception { + String 노리는_이메일 = "contested-" + UUID.randomUUID() + "@fillmap.dev"; + OrgEmailChangeRequest first = 접수한다(계정을_만든다(), 노리는_이메일); + OrgEmailChangeRequest second = 접수한다(계정을_만든다(), 노리는_이메일); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + try { + Future left = executor.submit(() -> 승인한다(barrier, first)); + Future right = executor.submit(() -> 승인한다(barrier, second)); + + List failures = Stream.of(결과를_받는다(left), 결과를_받는다(right)) + .filter(Objects::nonNull) + .toList(); + + // 선검사는 경합에 진다 — 둘 다 통과한 뒤 늦은 쪽이 uq_users_email 위반을 만나고, 벌크 UPDATE 라 + // 그 위반이 실행 즉시 떠서 1409 로 번역된다(커밋 시점까지 미뤄지면 잡을 자리가 없다). + // 늦은 쪽의 실패 출처(선검사 vs 유니크 위반 번역)는 스케줄에 달려 비결정이다 — 번역 경로를 + // 결정적으로 태우려면 프로덕션에 테스트 후크가 필요해 하지 않는다. 계약만 단언한다: + // 1409 하나 · 그 이메일 보유 계정 1건 · 500 아님. + assertThat(failures).hasSize(1).first().isInstanceOf(ApiException.class); + assertThat(((ApiException) failures.getFirst()).getErrorCode()) + .isEqualTo(UserErrorCode.EMAIL_ALREADY_EXISTS); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE email = ?", Long.class, 노리는_이메일)).isEqualTo(1L); + } finally { + executor.shutdownNow(); + } + } + + /** + * 승인이 전 컬럼 UPDATE 였다면, 승인 트랜잭션이 들고 있던 스냅숏이 그 사이 커밋된 비밀번호를 되돌린다. + * 단일 컬럼 UPDATE 면 이메일만 바뀌고 나머지는 다른 트랜잭션이 마지막으로 커밋한 값 그대로다. + *

+ * 이 테스트가 pin 하는 것은 "승인이 다른 컬럼을 되돌리지 않는다"까지다. 스테일 스냅숏 경합은 재현 + * 대상이 아니고(승인이 엔티티를 아예 로드하지 않아 밖에서 그 상태를 만들 수 없다), 단일 컬럼 계약 + * 자체는 {@code UserRepository.updateEmail} javadoc 이 정본이다. + */ + // 검증: FR-USER-16 + @Test + @DisplayName("승인은 email 컬럼만 갱신해 그 사이 커밋된 비밀번호 변경을 되돌리지 않는다") + void 승인은_email_컬럼만_갱신해_동시_비밀번호_변경을_되돌리지_않는다() { + long userId = 계정을_만든다(); + String 새_이메일 = "column-" + UUID.randomUUID() + "@fillmap.dev"; + OrgEmailChangeRequest request = 접수한다(userId, 새_이메일); + String 접수_후_비밀번호 = 비밀번호(userId); + + // 접수와 승인 사이에 본인이 비밀번호를 바꿔 커밋한다. + transactionTemplate.executeWithoutResult(status -> jdbcTemplate.update( + "UPDATE users SET password_hash = '{noop}ChangedByOwner' WHERE id = ?", userId)); + + adminEmailChangeRequestService.approve(request.getId(), + new EmailChangeApproveRequestDto(request.getCreatedAt())); + + assertThat(jdbcTemplate.queryForObject( + "SELECT email FROM users WHERE id = ?", String.class, userId)).isEqualTo(새_이메일); + assertThat(비밀번호(userId)).isEqualTo("{noop}ChangedByOwner").isNotEqualTo(접수_후_비밀번호); + // 담당자 이름·기관명 같은 나머지 컬럼도 그대로다 — 승인이 건드리는 컬럼은 email 하나다. + assertThat(jdbcTemplate.queryForObject( + "SELECT nickname FROM users WHERE id = ?", String.class, userId)).isEqualTo("김담당"); + } + + private String 비밀번호(long userId) { + return jdbcTemplate.queryForObject("SELECT password_hash FROM users WHERE id = ?", String.class, userId); + } + + private EmailChangeApproveResponseDto 승인한다(CyclicBarrier barrier, OrgEmailChangeRequest request) + throws Exception { + barrier.await(); + return adminEmailChangeRequestService.approve(request.getId(), + new EmailChangeApproveRequestDto(request.getCreatedAt())); + } + + /** 성공이면 null, 실패면 그 예외 — 어느 쪽이 이겼는지는 스케줄에 달려 있어 결과로 가른다. */ + private Throwable 결과를_받는다(Future future) throws InterruptedException { + try { + future.get(); + return null; + } catch (ExecutionException e) { + return e.getCause(); + } + } +} From b76b64494f037a15278428077af4e82ffcac83e6 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:40:36 +0900 Subject: [PATCH 21/35] =?UTF-8?q?MSG-500=20fix:=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94=20=EB=B3=80=EA=B2=BD=20=EC=8A=B9=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=96=89=EC=9D=84=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=EC=9E=A0=EA=B0=80=20=EA=B3=84=EC=A0=95=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EC=99=80=20=EC=9E=A0=EA=B8=88=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인(요청 행, users 행)과 탈퇴(users 삭제, CASCADE 요청 행)의 AB-BA 교차를 닫는다. 문장 순서 교체안은 1429/1409 우선순위가 흔들려 기각. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../user/service/AdminEmailChangeRequestService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java b/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java index ebd618ca..ff7de885 100644 --- a/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java +++ b/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java @@ -97,7 +97,9 @@ public AdminEmailChangeRequestListResponseDto getRequests(String status, int pag /** * 승인 (§API 7, D-13). 실행 순서가 계약이다. *

- * ① 요청을 읽어 상태와 검토 시각을 먼저 본다(빠른 거절, 값도 여기서 챙긴다). ② 새 이메일이 다른 계정에 + * ① 요청을 읽어 상태와 검토 시각을 먼저 본다(빠른 거절, 값도 여기서 챙긴다). ①-1 대상 계정 행을 + * 비관 잠금으로 잡는다 — 계정 삭제와 잠금 순서를 맞추기 위한 것이고 근거는 아래 + * {@code findWithLockById} 호출부 주석에 있다. ② 새 이메일이 다른 계정에 * 있는지 선검사한다 — 읽히는 1409 를 주기 위한 것이고 경합에는 진다(서로 다른 두 요청이 같은 * 이메일을 노리면 둘 다 통과한다). ③ 조건부 UPDATE 로 전이한다. 술어에 접수 시각이 들어 있어 검토 이후 * 재제출된 요청은 여기서 걸린다. ④ 0행이면 재조회로 가른다(1427·1428·1429). ⑤ 같은 트랜잭션에서 이메일을 @@ -108,6 +110,12 @@ public EmailChangeApproveResponseDto approve(Long requestId, EmailChangeApproveR Approved approved = transactionTemplate.execute(status -> { OrgEmailChangeRequest target = findReviewable(requestId, request.requestedAt()); String newEmail = target.getRequestedEmail(); + // 잠금 순서를 users → 요청 행으로 통일한다. 계정 삭제(UserService)는 users 행을 지우고 FK + // ON DELETE CASCADE(V46)가 그 사용자의 요청 행을 잠그므로, 여기서 요청 행을 먼저 잠그면 + // 반대 순서가 되어 동시 실행이 AB-BA 데드락(한쪽 500)으로 끝난다. 읽기만 하고 버리는 + // 조회지만 잡는 잠금이 목적이고, 그 사이 탈퇴한 계정은 여기서 1404 로 갈린다. + userRepository.findWithLockById(target.getUserId()) + .orElseThrow(() -> new ApiException(UserErrorCode.USER_NOT_FOUND)); if (userRepository.existsByEmail(newEmail)) { throw new ApiException(UserErrorCode.EMAIL_ALREADY_EXISTS); } From b79e03b956249d1e93276d533611b4a2f4606f9d Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:40:36 +0900 Subject: [PATCH 22/35] =?UTF-8?q?MSG-500=20docs:=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=EA=B3=BC=20=EC=9E=91=EC=97=85=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EB=A5=BC=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업 로그에 생성 SQL 실측, 예외 흐름 16행 표, afterCommit·잠금 순서·시퀀스 갭 등 런타임 동작과 MSG-502 인계 2건을 기록했다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .claude/docs/status.md | 3 + docs/spec/MSG-500.md | 209 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/.claude/docs/status.md b/.claude/docs/status.md index 80a1bc94..2aba1134 100644 --- a/.claude/docs/status.md +++ b/.claude/docs/status.md @@ -61,6 +61,7 @@ - MSG-373: 프로필 이미지 등록·변경·제거 + 가입일 노출 — presign 발급 `POST /me/profile-image/presigned-url`(jpg·jpeg·png·webp만, heic 1415 거부 — D-4 재확정, 5MB 초과 1413, 서명에 크기·타입 고정), 확정 `PUT /me/profile-image`(prefix 소유권→확장자 화이트리스트 재검증→HeadObject 실존→실측 크기 순, `profiles/pending/{userId}/`→`profiles/original/{userId}/{새 uuid}` 서버측 복사, `users.profile_image_url`에 **완성 공개 URL** 더티 체킹 저장 — 친구 축 3종 프로젝션 무수정 자동 반영이 §D-1의 강제), 제거 `DELETE /me/profile-image`(null 복원, 멱등), `UserProfileResponseDto` +2(profileImageUrl·createdAt — 조회·닉네임 수정·이미지 API가 공유), 이전 객체는 afterCommit best-effort 삭제, 계정 삭제 키 수집에 `findProfileImageUrlById` 역산 키 추가. 영상과 달리 advisory lock·이중 확정 차단 없음(§D-3 의도), 마이그레이션 0. **ops 선행 2건**(deploy.md): original 프리픽스 공개 정책 + pending 라이프사이클 - MSG-433: 회원가입 약관 동의 저장 — **V37** users 신규 컬럼 5개(철회 없는 필수 3항목 만14세·서비스약관·개인정보는 `*_consented_at` 시각 컬럼이 동의 여부(NULL=미동의)와 시각을 겸함, 마케팅은 `marketing_consent`+`_changed_at` 쌍. 위치기반은 기존 `location_consent` 쌍 재사용 — 신규 컬럼 0, FR-USER-15 단일 값), API 3종 `GET /api/users/me/consents`(항목별 상태+`requiredCompleted` 서버 계산 — 게이트 판별 재료)·`PUT /me/consents`(멱등 제출 — 필수 4항목 `@NotNull @AssertTrue` Bean Validation으로 서비스 진입 전 400 전량 거절, 원자 UPDATE 한 문장: 필수 3항목 COALESCE 최초 시각 보존·위치/마케팅 IS DISTINCT FROM CASE)·`PUT /me/marketing-consent`(location-consent 미러), DTO 3종(`ConsentStatusResponseDto`·`ConsentSubmitRequestDto`·`MarketingConsentUpdateRequestDto`). **위치 동의는 철회 불가**(2026-08-19 팀 합의, FR-USER-14 개정) — 기존 `PUT /me/location-consent`는 켜기 전용이 되어 `consented=false`는 `LOCATION_CONSENT_IRREVOCABLE`(**1400**, BAD_REQUEST) 거절, 리포지토리 갱신 쿼리도 `consentToLocation`(TRUE 상수화)으로 교체, 철회가 없으니 requiredCompleted 게이트 재등장 문제도 근본 소멸. **동의 컬럼 7종은 `@Column(updatable=false)`** — 더티 체킹 전 컬럼 UPDATE가 낡은 스냅숏으로 동의를 되덮는 우회 차단(Codex P1, 가드 제거 시 실패하는 회귀 테스트 동반). auth 패키지·프로필 DTO·로그인 응답 무수정 — 게이트 판별은 전용 조회(MSG-402 D-2 미동봉 방향 유지). FE(MSG-422·407) 공유: 프로필의 위치 끄기 UI 제거(디자인 4개 페이지에서 기제거) - MSG-499: 행사 운영자 계정 발급 — **V48** `org_account_requests`(발급 요청 큐: org_name·contact_name·contact_phone·email·event_name·content·status(PENDING|ISSUED|REJECTED CHECK)·reject_reason·issued_user_id FK·created_at(최초 접수, 보존)·updated_at(마지막 접수 겸 심사의 낙관적 검증 토큰)·processed_at + **부분 유니크** `uq_org_account_requests_pending(email) WHERE status='PENDING'` + `idx_org_account_requests_status_updated`) · `users.org_name` 추가. `entity/{OrgAccountRequest,OrgAccountRequestStatus}`(연관관계 없이 issuedUserId Long, 상태 전이 `issue`·`reject` 더티 체킹), `User.createOrgUser`(LOCAL·ORG·passwordMustChange **true를 세우는 유일한 경로**, nickname=담당자명)·`resetInitialPassword`(해시만 교체, 플래그 유지 — `changePassword`는 플래그를 내려 재발송에 못 씀). API 8종: 공개 접수 `POST /api/org-account-requests`(**permitAll·비로그인**, native ON CONFLICT UPSERT 한 문장이라 재접수가 대기 1행으로 수렴·created_at 보존, `PUBLIC_AUTH_PATHS` 무등록 — MSG-443 규칙) + 관리자 7종 `/api/admin/{org-account-requests(목록·상세·approve·reject),organizations(직접 발급·{userId}/resend-password·목록)}`. `service/OrgAccountRequestService`(접수·큐·상세·승인·반려, 심사 진입부는 `findWithLockById` 잠금 → PENDING 확인 → **updated_at 에코 대조**(불일치 1426, 익명 재접수·변조가 검토 내용을 바꾼 경우))·`OrgAccountIssueService`(발급 코어 — SecureRandom 16자(혼동 문자 제외, 영문·숫자 각 1자 이상)·bcrypt 저장·**커밋 후 발송**(TransactionTemplate 명시 경계)·발송 실패 격리 `emailSent:false`, 직접 발급·재발송·목록). 재발송 = 재발급(새 비밀번호 교체, 이전 값 즉시 무효). **평문은 메일 본문 한 번 외에 응답·로그·DB 어디에도 없다** — 실패 로그는 예외 타입만 남긴다(예외를 로거에 넘기면 본문을 담은 메시지가 스택으로 샌다, Codex 1R). 발급 메일의 발급 시각은 KST 마이크로초까지(같은 분 두 번 재발송의 구분이 복구 계약). `UserErrorCode` 1421~1426. 신규 테스트 48건 +- MSG-500: 아이디(공식 이메일) 변경 요청 심사 — MSG-497이 접수까지 만들고 어느 화면에도 안 걸려 있던 요청을 닫는다. **V51** `org_email_change_requests` +2컬럼(`processed_at`·`reject_reason`) + CHECK 2개(`(status='PENDING') = (processed_at IS NULL)` · `(status='REJECTED') = (reject_reason IS NOT NULL)` — 둘 다 **양방향 동치식**, 반쪽 OR 식은 반려 행의 사유 NULL을 통과시킨다). API 3종은 기존 `AdminOrgAccountController`에 얹는다(계정 축의 한 화면이라 컨트롤러 신설 없음): `GET /api/admin/email-change-requests`(상태 필터·건수 3종 전체 집계·users 세타 조인으로 현재 아이디와 요청 이메일 병기 — 심사는 둘의 대조가 전부) · `POST .../{id}/approve` · `POST .../{id}/reject`. `service/AdminEmailChangeRequestService`(신설) — **검토 시점 가드가 핵심**: 접수 UPSERT가 재요청을 같은 PENDING 행의 제자리 갱신으로 처리하므로 상태만 보면 관리자가 **본 적 없는 이메일**을 승인한다. 전이 UPDATE 술어에 `created_at = :requestedAt`을 넣어 원자 비교하고 어긋나면 1429, 0행이면 재조회로 1427(부재)·1428(처리됨)·1429를 가른다. 승인은 전이와 `users.email` 교체가 한 트랜잭션이고 **둘 다 벌크 UPDATE**다 — `UserRepository.updateEmail`은 email 한 컬럼만 세팅(이 프로젝트는 `@DynamicUpdate` 미사용이라 더티 체킹은 전 컬럼 UPDATE가 되어 동시 비밀번호·담당자 수정을 낡은 스냅숏으로 되덮는다). 이메일 선점은 선검사로 1409를 주되 **선검사는 경합에 진다** — 벌크 UPDATE라 `uq_users_email` 위반이 실행 즉시 `DataIntegrityViolationException`으로 떠서 같은 1409로 번역된다(커밋까지 미뤄지면 잡을 자리가 없다). 승인 진입부에서 **users 행을 먼저 비관 잠금**한다(`findWithLockById` 재사용) — 계정 삭제가 users 삭제 후 FK CASCADE로 요청 행을 잠그므로, 요청 행을 먼저 잠그면 반대 순서가 되어 동시 실행이 AB-BA 데드락(한쪽 500)으로 끝난다(Codex 적발). 반려는 users를 안 잡아 교차가 없다. 승인만 메일을 보낸다(**새 이메일로** — 로그인 수단이 바뀌는 사건이라 미통지 시 계정 접근을 잃는다, 커밋 후 발송·실패는 `emailSent:false`), 반려는 수기 통보 유지. 처리로 PENDING이 빠지면 부분 유니크가 풀려 같은 계정의 재접수가 자연히 다시 열린다. `UserErrorCode` 1427~1429 신설(1421·1422·1426을 재사용하지 않는 것은 메시지가 "발급 요청"을 지목해서고, 상태·페이지 검증은 같은 도메인이라 1424·1425 그대로). 테스트 17건(심사 15 + 비트랜잭션 2 — 동시 승인 1409 수렴·email 외 컬럼 보존) ### `grid` (Owner A · 구현 강정민) — 🟡 부분 - MSG-73: `GridEncoder`·`GridConstants`(순수 유틸), `entity/{UserGrid,UserGridId,Grid}`, `repository/GridRepository`, `service/GridQueryService`(+impl, read 계약 A→B), `controller/GridController`, `dto/*`, `exception/GridErrorCode`(4xxx) @@ -112,6 +113,7 @@ - MSG-450: 미션 집계에서 행사 영상 제외 — `videos` 를 직접 조인하는 미션 쿼리 여섯 곳에 `AND NOT EXISTS (SELECT 1 FROM event_videos ev WHERE ev.video_id = v.id)` 한 줄씩 추가(`MissionRepository.findProgress`·`findCompleted`, `MissionGridRepository.findVisitedGridIds` 는 조인 **ON 절**, `VideoRepository.findMissionVideos`·`findMissionVideosAfter`·`countMissionVideosByGrid` 는 WHERE 절). 진행도는 LEFT JOIN 이라 ON 절이어야 미진행 미션의 0 이 유지된다. 마이그레이션·API·DTO·에러코드·인덱스 **0** — `event_videos.video_id` 가 PK 라 행당 프로브가 최대 한 번이다(설계 근거). 로컬 EXPLAIN 으로 확인한 것은 PostgreSQL 이 NOT EXISTS 를 서브플랜이 아니라 `Nested Loop Anti Join` 노드로 재작성한다는 계획 형태뿐이고, 로컬은 `videos` 0행이라 실제 인덱스 사용과 비용은 dev 배포 후 확인 대상이다. 쿼리 1~3 의 "글자 단위 동일" 계약과 4~6 의 술어 동등 계약을 안티조인 포함으로 갱신(Javadoc 3곳 명문). 쓰기 경로의 판정 시점 가시성은 MSG-440 이 행사 업로드에서 미션 판정 훅 자체를 뺀 구조라(`confirmAndStore` 밖) 이미 성립. 신규 테스트 7건(쿼리 6건 + 진행도 안티조인의 ON 절 배치 회귀 가드 1건 — 오배치를 잡는 유일한 케이스라 돌연변이 실측으로 확인) + 공용 시드 헬퍼 `event/EventVideoFixtures`(event_series → occurrences → locations → location_grids → event_videos FK 사슬을 호출마다 새 회차로 생성) - MSG-459: 축제·팝업 미션 대표 격자 업로드(V42 `missions.representative_grid_id` — CHECK `chk_missions_rep_grid_type`(EVENT·POPUP AND target_count=1)·DEFERRABLE 복합 FK(`mission_grids`)·부분 인덱스·홀수 직사각형+2이하 변 백필(리졸버 동치를 마이그레이션 파일 실행 대조 테스트로 고정). `RepresentativeGridResolver`·`AreaCell`을 `event.seed` → `global.geo` 이동(로직 무변경, 행사·미션 공용). 시더: `MissionRepresentativeGrids` 신설(재산출 규칙 한 곳 — target_count=1이고 비었거나 새 집합 밖일 때만, null 가드), 축제는 저장 격자 재사용·팝업은 regrid/shrink 직후 재산출+경고 로그. **업로드** `POST /api/missions/{missionId}/videos`(기존 `video.controller.MissionVideoController`에 POST 추가 — 신규 클래스는 빈 이름 충돌, `mission/service/MissionVideoService`+Impl `@Transactional` 판정 7단: 값싼 검증(3424·3401 선행)→미션 조회→멱등 재시도(소유자+격자+저장 recorded_at 기간+**스탬프 보유** 4중 대조 — 행사 경로 확정 키 오인 차단)→판정 5종→`confirmAtGrid`→`awardOnUpload`→**보존 확인**(스탬프 부재 시 12409 롤백 — 종료 정각·재시딩 경합 창 폐쇄, FR-17 무조건 성립). 실패는 `12409 MISSION_UPLOAD_UNAVAILABLE` 단일 수렴(존재 은닉, D-10), 응답 뱃지는 코어+미션 판정 합류(saveVideo 동형). `VideoService.validateUploadRequest` 공개(`validatePendingKey` 추출). **역조회** `GET /api/grids/{gridId}/missions`(비로그인 GET 한정 매처, 대표 격자 컬럼 직조회 — 캐시 우회, 정렬 진행 중→시작 전 임박순→종료 최근순, `VideoRepository.countVideosByMissionIds` 배치 — 상세 videoCount와 술어 글자 동일). SRS FR-MISSION-21~26 등재·rtm 공백 0. 계약 인터페이스 시그니처 변경 0. 신규·갱신 테스트 약 60건(업로드 35·역조회 8·마이그레이션 6·시딩 델타·보존 롤백 실커밋 3). Codex 3라운드 수렴(재시도 스탬프 가드·뱃지 응답 합류 2건 적발 반영)) - MSG-492: 코스 포토스팟 표시 이름(V43 `mission_grids.name VARCHAR(100)` nullable·인덱스 없음 — **저장값은 최종 표시 문자열**이고 이름 출처는 저장하지 않는다). 화면이 스팟마다 `GET /api/regions/reverse-geocode` 를 부르던 것을 응답 필드로 대체 — 산출물이 이미 갖고 있던 명소 이름 417개를 파서가 버리고 있었고(`CourseRecord.Spot` 주석 "미적재"), 나머지는 역지오코딩이 바다에서 빈 값을 줘 "이름 없는 경유 지점"이 떴다(해파랑길 1코스 7곳 전량). **판정은 전부 적재 시점**(D-2 초안 번복 — 조회 시 계산이 메모·TTL·목록/상세 세대 불일치 연쇄를 부른 것을 Codex 2R 에서 확인) — `CourseSpotNameResolver`(빈 아님, 시더 실행당 1회 `new`) 가 ① 산출물 name(`method=tourapi` 만) → ② `ZoneNameResolver` 구역 표시명 → ③ `resolveByPoint` 행정동 **마지막 토큰** → ④ 최근접 행정동 → ⑤ null 순으로 굳혀 저장하고, `MissionQueryServiceImpl.pathShape` 는 `grid.getName()` 통과만 한다(조회 쿼리·의존 증가 0, 스팟 수 무관 문장수 가드로 고정). `"경유점 N"` 자리표시는 파서가 버린다(D-1) — 저장하면 사다리 ① 에서 걸려 폴백이 영영 안 돈다. 시더 skip 분기에 이름 갱신을 얹어 **재실행이 곧 백필**(MSG-383 형태, `name` 값만 변경 — `@DynamicUpdate` 가 없어 UPDATE 문에는 `seq` 도 실리지만 같은 트랜잭션에서 방금 읽은 값이다. 격자 집합·seq·target_count·path 불변). **Owner A 계약 1건 추가**: `RegionQueryService.resolveNearestByPoint`(신규 — `resolveByPoint` 의 `Optional.empty` = "품는 행정동 없음" 신호를 기존 소비처가 쓰고 있어 폴백을 안으로 넣지 않고 분리). 그 native 는 **2단 구조**다 — `ORDER BY 거리, region_code` 로 쓰면 GIST KNN 인덱스를 통째로 잃어(dev 실측 2026-08-27 Seq Scan 1,666ms 대 Index Scan 13ms, 128배) 안쪽에서 거리만으로 `LIMIT 8` 을 인덱스로 뽑고 바깥에서 동률만 `region_code` 로 가른다(Index Scan + Incremental Sort 20.7ms, 실제 읽은 행 2). 거리 상한 없음(D-5 — 대상이 실측 고정 집합이고 최댓값 2,189m, 두면 7칸이 이름 없이 남아 FR-1 을 스스로 깬다). **`grids.region_code` 라벨링 경로는 무접촉** — 동별 수집률의 분모라 파급이 달라 MSG-493 으로 분리(바다 격자 업로드가 어느 동에도 안 잡히는 문제). glossary "표시명" 에 코스 스팟 예외 등재(서버 조립·최근접 폴백은 이 경로만). 신규·갱신 테스트 27건(리졸버 9·파서 6·시더 5·계약 4·PostGIS 3). Codex 2라운드 수렴 — 다만 **인덱스 미사용은 Codex 도 못 잡았다**(박스에 `psql` 없어 DDL 근거만, 스스로 유보 명시). SRS FR-MISSION-19 는 앞 문장만 충족이라 `진행 중`(뒤 문장 "정식 명소가 아님을 구분" 은 D-3 으로 비목표 확정) +- MSG-500: 승인 미션 등재와 노출 숨김 — **V51** `missions.hidden_at`(NULL=노출 중, 기존 행 전부 NULL이라 시드 산출물 동작 불변). `service/MissionRegistrationService`(+Impl, 신설) — 요청 경로에서 미션을 만드는 첫 문이다(지금까지 생성자는 기동 1회 시더뿐). event 승인 서비스가 mission 리포지토리를 직접 쓰지 않고 이 계약을 거치므로 **미션의 규칙이 전부 구현 쪽에 있다**: KST 날짜 라벨 → UTC 순간 변환은 `FestivalMissionSeeder.toUtcStart/toUtcEnd`를 **public 승격해 그대로 호출**(규칙 두 벌이면 시드 미션과 승인 미션의 하루 경계가 다르게 잘린다), 대표 격자는 `MissionRepresentativeGrids.reassignIfOutside`(같은 이유로 public 승격), `source="ORG_SUBMISSION"` 상수는 등재 서비스 보유(시더 정리·dedupe가 전부 source 한정이라 승인 미션이 시더 파이프라인과 절연), `sourceKey`=신청 번호(`(source, source_key)` 부분 유니크가 이중 승인의 DB 백스톱), 공개 주소 조립은 키를 받아 `AwsProperties.publicUrl`. `MissionQueryService.invalidateSnapshot()` 신설 — TTL 1시간 전역 스냅숏이라 무효화가 없으면 승인 미션이 최대 1시간 안 실리고 중지 미션이 최대 1시간 계속 보인다. register·hide 둘 다 **커밋 후**(afterCommit) 호출하고 동기화가 없으면 즉시 실행 폴백(`EventSubmissionImageStore` 패턴). **숨김 소비**(전수 grep 후 확정): 술어 추가는 `MissionRepository.findActive`(칩·뷰포트·클러스터·지역 집계가 전부 이 스냅숏 위 메모리 필터라 여기 하나로 닫힌다)·`findAwardCandidateIds`·`findCompleted`·`findByRepresentativeGridIdAndHiddenAtIsNullOrderById`(개명) + `VideoRepository` 4곳(`findMissionVideos`·`findMissionVideosAfter`·`countMissionVideosByGrid`·`countVideosByMissionIds`), 미션 상세·미션 경유 업로드는 서비스 가드(각각 12404·12409 수렴), 영상 피드는 **빈 200이 아니라 미션 상세와 같은 404**(`VideoRepository.isMissionHidden` 스칼라 조회 — 빈 페이지면 "존재하지만 비어 있다"로 읽혀 은닉이 갈라진다. `VideoServiceImpl` 생성자에 의존을 더하면 테스트 12곳이 함께 깨져 리포지토리 스칼라 조회로 내렸다, `UserRepository.findAllS3KeysByUserId` 선례). 술어를 **일부러 안 건 곳**: `findProgress`·`findVisitedGridIds`·`countMyStampsByType` — 이미 완료한 사용자의 진행 기록과 뱃지 지표는 회수하지 않는다(종료 미션도 진행도가 나오는 D11과 같은 이유). `missions.hidden_at` 세팅은 단일 컬럼 `@Modifying` UPDATE(`hidden_at IS NULL` 술어가 재중지를 0행으로 만들어 첫 중지 시각 보존) — 더티 체킹이면 `@DynamicUpdate` 부재로 전 컬럼 UPDATE가 나가 시더 백필과 되덮기 경합이 생긴다 - **실적재 현황**: 축제·팝업·코스 **dev 적재 완료**(2026-08-14). 활성 기준 축제 222(이미지 221)·팝업 227(이미지·소개문 227)·코스 148(이미지 137)이고 local 도 같은 값이다. 재실행 멱등 확인(2회차 적재 0·갱신 0·정리 0), 외부 도메인 섞임 0, 코스 판정 격자 989 불변, 고아 스탬프 0. 팝업이 345 → 227 로 준 것은 MSG-235 가 설계한 종료 정리다(스탬프 없는 종료분만 삭제). **prod 는 전 종류 미착수** — `missions/*` 공개 읽기 정책이 dev 버킷에만 적용됐고 prod 앱이 미가동이다 ### `event` (Owner B · 구현 강정민) — 🟡 부분 @@ -125,6 +127,7 @@ - MSG-501: 행사 운영자 콘솔 승인 이벤트 목록 — `GET /api/org/events`(`controller/OrgEventController` 신규, city·name 선택 파라미터), `EventQueryService.getApprovedEvents(String, String)` 추가(+Impl — 후보는 `findByEndsAtAfter(now)` 파생 쿼리 하나로 DB에서 거르고(D-1 `ends_at > now`, `statusAt` 미경유), `visible_from` 미적용(D-2 — 콘솔은 노출 전 예정 회차도 노출), 필터·검색(대소문자 무시 부분 일치)·시도별 집계(전체 기준 — totalCount·cityCounts는 필터 무관)·정렬(startsAt→id, 건수 내림차순→이름)은 자바에서. placeLabel은 기존 `findWithOccurrenceByOccurrenceIdIn` 재사용(fetch join, displayOrder→id 정렬의 첫 행 — 승인 흐름산 name 채움 규칙은 MSG-500 접점, 스펙 명시). 쿼리 회차 1 + 위치 일괄 1 고정(빈 후보면 1회). DTO 3종 `Org*ResponseDto`(placeLabel은 required+`@Schema(nullable=true)` 병기 — ResponseSchemaNullabilityTest 규약). SecurityConfig·Flyway·developCode·계약 인터페이스 전부 무변경(/api/org/** matcher가 MSG-496에서 기확정). 테스트 25건(서비스 17·컨트롤러 5 — ORG 200 실경로 포함·리포지토리 3), 전부 `// 검증: FR-EVENT-16`. reviewer 1R(P2 용어 1건 수정)·Codex 1R 수렴(지적 0) - MSG-498: 행사 운영자 행사 등재 신청 — `event.submission` 하위 패키지 신설(V49 `event_submissions`·`_locations`·`_location_rects`·`_status_history` 4테이블 + 신청 번호 전역 시퀀스, 이력 테이블 CHECK가 REJECTED 행에만 사유 강제 — 반려 사유 저장 원천은 이력 하나 D-3). API 5개 전부 `/api/org/event-submissions`(SecurityConfig 무변경 — 496 matcher·497 mustChange 인터셉터 자동 커버, 인가 테스트는 전제 확인 4건): presign(프로필 미러링, jpg·png 10MB, webp 불허) · 제출 POST(유형 FESTIVAL·POPUP 2종, 유형 밖 필드 13439 거부, 81칸은 사각형 **합집합** 판정(전개 전 단일 사각형 long 선판정으로 OOM 차단), 격자 인덱스 상한 상수는 `RepresentativeGridResolver.GRID_INDEX_UPPER_EXCLUSIVE` 승격 공유(시더 사본 제거), 대표 격자 서버 계산, 신청 번호 `FM-{KST연도}-{4자리}` native `nextval` 채번) · 내 목록(상태별 건수는 별도 쿼리 없이 로드 목록에서 groupingBy 파생 — 스냅숏 불일치 차단, 페이지네이션 도입 시 되돌릴 지점) · 상세(존재 은닉 13430 — 모든 조회가 `findByIdAndUserId` 단일 경로, rejection은 이력 최신 행) · 재제출 PATCH(REJECTED 한정, 유형 불변 전체 교체, `@Modifying(clearAutomatically=true)` 조건부 UPDATE 술어에 userId 포함 → 0행이면 소유 조회 분기(부재 13430/비REJECTED 13434), 재로드는 UPDATE 뒤, imageS3Key 생략=유지). 이미지 확정: HEAD 실측(0바이트 13436)→original 복사(uuid 시도마다 신규 — 객체 공유 차단)→롤백 보상 삭제는 **복사 호출 앞 등록**(응답 유실 고아 차단)→커밋 후 pending 삭제(재확정 차단)·STATUS_UNKNOWN엔 미삭제. 엔티티는 다대일 양방향(자식 `@ManyToOne` 주인 + mappedBy 컬렉션 + 편의 메서드, D-12 — 단방향 왕복 실측 후 주류 관행으로 확정). 리스트 요소 null은 type-use `@NotNull`로 공통 400. errorCode 13430~13439(EventErrorCode 확장). 테스트 58건(단위 25·통합 33, 동시 재제출·복사 응답 유실·게이트 커버 포함). reviewer 3R·Codex 3R 수렴(채택 3건 반영, 멱등 키는 스펙 3회+구현 3회 기각 — 콘솔 수동 제출 수용). 심사·승인(APPROVED 쓰기)은 MSG-500 몫, pending S3 라이프사이클 규칙은 배포 전 런북 +- MSG-500: 관리자 심사·승인 반영·노출 관리 — **V51** `event_submissions` +4컬럼(`approval_no` UNIQUE·`published_mission_id` FK(NO ACTION)·`unpublished_at`·`unpublish_reason`) + CHECK 3개(`(status='APPROVED') = (approval_no IS NOT NULL)` · 중지 기록 짝 · 중지는 승인 행만) + 승인 번호 전역 시퀀스 `event_submission_approval_no_seq`. API 6종: `AdminEventSubmissionController`(`/api/admin/event-submissions` — 심사 큐·상세·승인·반려) + `AdminApprovedEventController`(`/api/admin/events` — 승인 행사 목록·노출 중지). 역할 검사는 컨트롤러에 없다(기존 `/api/admin/**` matcher, SecurityConfig 무변경). **심사 조회**: 큐는 users 세타 조인 + 생성자 프로젝션에 `size(s.locations)` 상관 서브쿼리(항목마다 위치를 다시 읽지 않는다), 건수 3종은 필터 무관 전체 집계, 상세는 신청 계정 정보 + `exposureRect`(전 위치 사각형의 경계 — 조회 시점 계산, 저장 안 함) + 이력. 위치 표현은 `EventSubmissionLocationView`로 **행사 운영자 상세와 같은 조립기**를 쓴다(심사자와 신청자가 다른 그림을 보면 반려 사유가 가리키는 대상이 어긋난다). **승인**(D-1·D-2): 상태·기간 선검사(시퀀스 헛소비 방지) → 조건부 UPDATE로 IN_REVIEW→APPROVED 전이와 승인 번호 부여를 한 문장에(동시 승인의 진짜 판정) → 0행이면 재조회로 13430/13450 분기 → 이력 APPROVED 행 → **재로드 후**(벌크 UPDATE가 영속성 컨텍스트를 우회해 앞서 읽은 엔티티는 스테일) 미션 등재. 지역축제→`MissionType.EVENT`(축제 칩)·팝업스토어→`POPUP`, 판정 격자는 전 위치 셀 **합집합**(`EventSubmissionCells` — 상세의 칸 수와 같은 산술), 대표 격자는 그 합집합에 규칙 재적용, 이미지는 확정본을 `missions/org-submission/{uuid}.{ext}`로 복사해 공개 URL 저장(원본은 심사·콘솔 상세가 계속 읽으므로 유지, 롤백 보상 삭제는 복사 호출 앞 등록). 승인 번호는 `APR-{KST연도}-{4자리}`. **반려**: 항목 코드 1개 이상(빈 배열·허용 밖·중복 전부 13454 — Jackson enum 역직렬화에 맡기면 공통 400으로 뭉개진다)과 서술을 이력 반려 행에 쌓고(D-3 저장 원천 단일), 메일 없음. **승인 행사 목록**(D-11): 원천은 APPROVED 신청 행이고 안정 식별자는 신청 id, 탭은 저장하지 않고 **KST 오늘과 기간의 파생**(`TAB_CASE` 컴파일 상수 한 벌을 필터·표시·건수 3자리가 공유 — 식이 갈리면 뱃지 숫자와 목록이 어긋난다), 중지된 행사도 탭에 남고 `unpublished`로 구분. **노출 중지**(D-3): 조건부 UPDATE(`APPROVED AND unpublished_at IS NULL`) → 같은 트랜잭션에서 `MissionRegistrationService.hide` → 커밋 후 신청 계정 이메일로 사유 발송(실패는 `emailSent:false`, 재발송 API 없음 — 저장된 사유가 수기 재통지 재료). 에러 코드 13450~13456 신설(목록 파라미터 두 코드는 판정·메시지가 MSG-499의 1424·1425와 같고 **대역만 event** — 도메인 예외는 도메인이 갖는다). 테스트 42건(심사 조회 8·승인 반려 12·중지 9·목록 6·비트랜잭션 4 — 동시 승인 13450·승인 원자성 롤백·승인/중지 커밋 후 스냅숏 무효화 + 스토어 단위 3). **참여형(이벤트) 승인 분기는 미구현** — `publish()`의 유형 switch에 `default`가 없어 MSG-502가 참여형 값을 추가하면 **컴파일이 깨지는 것이 의도된 가드**이고, 중지의 참여형 경로(위치 숨김·부모 회차 노출 영역 재계산)도 no-op이라 FR-EVENT-17은 절반만 성립한다(M6/MSG-502 대기) - 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/spec/MSG-500.md b/docs/spec/MSG-500.md index c6a17953..dbe08c4f 100644 --- a/docs/spec/MSG-500.md +++ b/docs/spec/MSG-500.md @@ -613,3 +613,212 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic [^6]: 비관적 잠금(PESSIMISTIC_WRITE): 행을 읽는 시점에 SELECT FOR UPDATE로 배타 잠금을 걸어, 같은 행을 잠그려는 다른 트랜잭션을 커밋까지 대기시키는 방식. 겹침 검사와 삽입 사이에 다른 승인이 끼어드는 경합을 트랜잭션 순서로 직렬화한다. + +## 작업 로그 + +### 2026-08-30 — 구현 완료 (Owner B 단독, M1~M5 / M6 보류) + +**완료 모듈 5개** (커밋 19건, 신규·갱신 테스트 76건 green — 전체 501 클래스 2,932건 실패 0) + +- **M1 스키마·엔티티**: V51 마이그레이션, `EventSubmission` 심사 4필드 + `linkPublishedMission`, + `Mission.hiddenAt`, `OrgEmailChangeRequest` 처리 2필드 (`EventSubmissionReviewSchemaTest` 11건) +- **M2 심사 조회**: `AdminEventSubmissionController`·`AdminEventSubmissionService`, 관리자 DTO 3종, + 위치 조립기 `EventSubmissionLocationView` 추출(운영자 상세와 공용) (`AdminEventSubmissionControllerTest` 8건) +- **M3 승인·반려**: `MissionRegistrationService`(+Impl, mission 패키지 신설), `EventSubmissionCells`, + `EventSubmissionImageStore.copyToMissionImage`, 전이 쿼리 2종, `MissionQueryService.invalidateSnapshot` + (`AdminEventSubmissionReviewTest` 12건, 스토어 3건, 캐시 1건) +- **M4 목록·노출 중지**: `AdminApprovedEventController`·`AdminApprovedEventService`, 숨김 소비 전수, + `MissionRegistrationService.hide` (`AdminEventUnpublishTest` 9건, `AdminApprovedEventListTest` 6건, + `EventSubmissionApprovalConcurrencyTest` 4건) +- **M5 아이디 변경 심사**: `AdminEmailChangeRequestService`, 관리자 API 3종(기존 `AdminOrgAccountController` + 확장), `UserErrorCode` 1427~1429 (`AdminEmailChangeReviewTest` 15건, `EmailChangeApprovalConcurrencyTest` 2건) + +**M6(이벤트 참여형 승인)은 착수하지 않았다** — MSG-502(V50·참여형 유형 값·부모 회차 컬럼)가 develop에 +머지되기 전이라 읽을 컬럼 자체가 없다. 스펙 §구현 순서 의존이 예고한 "미지원 유형 방어"를 **컴파일 가드**로 +남겼다(아래 인계 참조). + +**구현 결정 (스펙과 달라진 것)** + +- **13450 상수명을 `SUBMISSION_STATUS_NOT_REVIEWABLE`로 바꿨다** (스펙 표는 `SUBMISSION_NOT_REVIEWABLE`). + 같은 파일의 `SUBMISSION_NOT_EDITABLE`(13434)과 한 글자 차이라 호출부에서 눈으로 안 갈린다. 스펙 표도 함께 + 갱신했다(커밋 `eb41d9ee`). +- **목록 파라미터 에러 코드를 event 대역에 신설했다** — 13455 `INVALID_SUBMISSION_STATUS_FILTER`·13456 + `INVALID_PAGE_RANGE`. 스펙 §API 1의 "MSG-499와 같은 방식"을 **방식 동일·대역 상이**로 읽었다(event 컨트롤러가 + user 대역 코드를 내면 도메인 대역 규칙이 깨진다). 반대로 §API 7(아이디 변경)은 user 도메인이라 1424·1425를 + 그대로 재사용한다. +- **`MissionQueryServiceImpl`의 뷰포트·클러스터·지역 집계에는 술어를 넣지 않았다.** 스펙 §숨김의 소비는 그 + 셋을 "네이티브 쿼리"로 적었지만 코드 현실은 셋 다 `findActive` 스냅숏 위 메모리 필터라, `findActive` 한 곳에 + 술어를 걸면 셋이 함께 닫힌다. +- **미션 영상 피드의 404 판정을 `VideoRepository.isMissionHidden` 스칼라 조회로 냈다.** `VideoServiceImpl` + 생성자에 `MissionRepository`를 더하면 이 서비스를 직접 조립하는 테스트 12곳이 함께 깨진다. videos 리포지토리가 + missions를 읽는 형태의 선례는 `UserRepository.findAllS3KeysByUserId`(user 리포지토리가 videos를 읽는다)다. +- **`MissionRegistrationService.hide`는 M4에서야 추가했다** — M3 시점엔 호출자도 테스트도 없어 검증 없는 코드가 + 된다. 스펙이 register·hide를 한 인터페이스로 적은 것과 최종 형태는 같다. +- **`AdminApprovedEventItemResponseDto.unpublished`는 파생 접근자가 아니라 레코드 컴포넌트다.** 처음엔 + `unpublishedAt != null`을 계산하는 메서드였는데 `ResponseSchemaNullabilityTest`가 "실제 필드 없음"으로 잡았다 + (Jackson이 레코드 컴포넌트만 직렬화해 응답에서 키가 통째로 빠질 뻔했다). 12인자 보조 생성자를 두어 쿼리는 + 그대로 두고 파생만 생성자에서 한다. + +**리뷰 반영** (convention-reviewer 5R + Codex 스톱 게이트) + +- 승인 후 **미션 스냅숏 무효화**(D-12)를 register·hide 양쪽에 대칭으로 넣고, 커밋 후 발화를 비트랜잭션 + 테스트 2건으로 실증했다(TTL 1시간을 기다리지 않고 목록에 실리고 빠지는지). +- **숨김 소비 전수 확정** — 술어를 건 곳과 **일부러 안 건 곳**(`findProgress`·`findVisitedGridIds`· + `countMyStampsByType`)을 리뷰에서 목록으로 고정했다. 진행 기록·뱃지 지표는 회수하지 않는다. +- **승인 원자성 테스트**를 성공 기준 4번의 검증으로 편입했다(S3 복사 실패 주입 → 전이·이력·미션 전량 롤백). + `@Transactional` 롤백 격리 클래스에서는 성립하지 않아(서비스가 테스트 트랜잭션에 합류해 롤백을 관찰할 수 + 없다) 비트랜잭션 클래스에 두었다. +- **검토 시점 가드**(D-13)를 승인·반려 양쪽 전이 술어에 넣고, 재접수 후 옛 시각으로는 둘 다 1429이며 + "본 적 없는 이메일이 승인되지 않았다"를 함께 단언한다. +- **아이디 변경 승인의 잠금 순서**를 users → 요청 행으로 통일했다(Codex 적발, 아래 빈 동작 참조). +- 탭 파생식 4벌을 `TAB_CASE` 컴파일 상수 한 벌로 접었다(필터·표시·건수가 같은 식을 쓰지 않으면 뱃지 숫자와 + 목록이 조용히 갈린다). + +**커밋 19건** + +| 해시 | 메시지 | +|---|---| +| `f7bf219d` | MSG-500 feat: 심사 흔적과 숨김 컬럼을 담는 V51 스키마와 엔티티를 확장한다 | +| `ddc7402e` | MSG-500 feat: 심사·노출 중지용 에러 코드 7종을 event 대역에 신설한다 | +| `cbcf2d4f` | MSG-500 refactor: 위치 응답 조립을 공용 뷰로 추출한다 | +| `61eea77f` | MSG-500 feat: 관리자가 심사 큐와 신청 상세를 조회한다 | +| `eb41d9ee` | MSG-500 docs: 스펙 에러 코드 표를 코드 현실에 동기화한다 | +| `26d38fec` | MSG-500 docs: 노출 중지와 승인 행사 목록 요구를 SRS에 선등재한다 | +| `b548764f` | MSG-500 feat: 승인 미션 등재 계약과 커밋 후 스냅숏 무효화를 만든다 | +| `23a7ab9c` | MSG-500 feat: 승인 재료를 만든다 - 셀 산술 공용화, 이미지 공개 복사, 원자 전이 쿼리 | +| `e668c1a9` | MSG-500 feat: 관리자가 신청을 승인해 미션으로 등재하거나 반려한다 | +| `78762f82` | MSG-500 feat: 미션 숨김을 단일 컬럼 갱신과 커밋 후 무효화로 만든다 | +| `4617f8a2` | MSG-500 feat: 중지된 미션을 상세·업로드·영상 피드에서 막는다 | +| `533eb5e3` | MSG-500 feat: 관리자가 승인 행사를 상태별로 보고 노출을 중지한다 | +| `2fe4243e` | MSG-500 test: 승인 산출물 생성이 실패하면 전이와 이력이 함께 롤백됨을 검증한다 | +| `af0b5197` | MSG-500 test: 중지 커밋 후 스냅숏 무효화로 TTL 전에 목록에서 빠짐을 검증한다 | +| `a4d2b941` | MSG-500 refactor: 승인 행사 탭 파생 규칙을 컴파일 상수 한 벌로 접는다 | +| `8216945d` | MSG-500 feat: 아이디 변경 심사용 에러 코드 3종을 신설한다 | +| `f56e517c` | MSG-500 feat: 아이디 변경 심사의 원자 전이와 이메일 단일 컬럼 교체 쿼리를 만든다 | +| `c7d253f9` | MSG-500 feat: 관리자가 아이디 변경 요청을 검토해 승인하거나 반려한다 | +| `6eb097f3` | MSG-500 test: 아이디 변경 심사 검증 17건을 더한다 | + +### 런타임 동작 + +**데이터 저장 위치** (`V51__event_submission_review.sql`) + +- `event_submissions` +4컬럼 — `approval_no VARCHAR(20) UNIQUE`(승인 시 부여, `APR-{KST연도}-{4자리}`), + `published_mission_id BIGINT REFERENCES missions(id)`(**ON DELETE 미지정 = NO ACTION** — 시더 정리는 + source 한정이라 승인 미션을 건드리지 않지만 만에 하나의 삭제 시도를 DB가 막아 링크 고아를 차단), + `unpublished_at TIMESTAMP`(UTC), `unpublish_reason TEXT`. CHECK 3개: + `chk_event_sub_approval ((status='APPROVED') = (approval_no IS NOT NULL))` · + `chk_event_sub_unpublish_pair ((unpublished_at IS NULL) = (unpublish_reason IS NULL))` · + `chk_event_sub_unpublish_approved (unpublished_at IS NULL OR status='APPROVED')`. +- `CREATE SEQUENCE event_submission_approval_no_seq` — 연도별 리셋 없는 전역 시퀀스(신청 번호와 같은 구조). +- `missions.hidden_at TIMESTAMP` · `event_locations.hidden_at TIMESTAMP` — NULL = 노출 중. 기존 행 전부 NULL. +- `event_locations` +6컬럼 (`organizer_name(100)`·`description TEXT`·`starts_on DATE`·`ends_on DATE`· + `participation_method TEXT`·`image_key(255)`) — **DDL만 넣고 쓰는 코드는 M6**다. 엔티티 필드도 아직 없다 + (Hibernate `validate`는 DB에만 있는 컬럼을 문제 삼지 않는다). 시드 위치는 전부 NULL이라 기존 행사 조회 불변. +- `org_email_change_requests` +2컬럼 (`processed_at TIMESTAMP`·`reject_reason TEXT`) + CHECK 2개 — + `chk_org_email_change_processed ((status='PENDING') = (processed_at IS NULL))` · + `chk_org_email_change_reject_reason ((status='REJECTED') = (reject_reason IS NOT NULL))`. + **양방향 동치식이어야 한다** — 반쪽 OR 식은 반려 행의 사유 NULL을 통과시킨다. +- S3: 승인 미션의 대표 이미지 공개 사본이 `missions/org-submission/{uuid}.{ext}`(버킷은 영상과 같다). + 원본 확정본(`event-submissions/original/{userId}/...`)은 **지우지 않는다** — 심사 상세와 콘솔 상세가 계속 + presigned GET으로 읽는다. `missions.image_url`에는 `AwsProperties.publicUrl` 조립값이 들어간다. + 참여형 위치용 프리픽스(`event-locations/org-submission/`)는 그 분기와 함께 온다. + +**실행 쿼리** (Hibernate `org.hibernate.SQL` DEBUG 실측, 2026-08-30 — 테스트 프로파일에 임시로 로거를 켜서 +뽑고 되돌렸다) + +- 승인 전이 (`EventSubmissionRepository.approveInReview`): + `update event_submissions es1_0 set status='APPROVED', approval_no=?, updated_at=? where es1_0.id=? and es1_0.status='IN_REVIEW'` + — 상태 확인과 갱신·번호 부여가 한 문장이라 동시 승인 두 건이 둘 다 IN_REVIEW를 보고 성공하는 경합이 없다. + 반려는 같은 형태(`set status='REJECTED', updated_at=? ... and es1_0.status='IN_REVIEW'`). +- 승인 번호 채번: `SELECT nextval('event_submission_approval_no_seq')`. +- 산출물 링크 (`linkPublishedMission` 더티 체킹) — **전 컬럼 UPDATE가 나간다**: + `update event_submissions set approval_no=?, created_at=?, description=?, ends_on=?, image_key=?, operating_hours=?, organizer_name=?, program_description=?, published_mission_id=?, starts_on=?, status=?, submission_no=?, title=?, type=?, unpublish_reason=?, unpublished_at=?, updated_at=?, user_id=? where id=?` + (`@DynamicUpdate` 미사용). 여기서는 **조건부 UPDATE 직후 재로드한 신선한 스냅숏**이고 같은 행의 다른 + writer가 심사 경로뿐이라 되덮을 값이 없다. 같은 이유로 `missions`의 대표 격자 반영도 전 컬럼 UPDATE다 + (`update missions set description=?, ..., hidden_at=?, ... where id=?` — 방금 INSERT한 행). + **노출 중지는 이 경로를 쓰면 안 되는 자리**라 아래처럼 단일 컬럼으로 내렸다. +- 노출 중지 (`unpublishApproved`): + `update event_submissions es1_0 set unpublished_at=?, unpublish_reason=?, updated_at=? where es1_0.id=? and es1_0.status='APPROVED' and es1_0.unpublished_at is null` + — 0행이면 호출자가 재조회로 13430(없음·미승인)·13453(이미 중지)을 가른다. +- 미션 숨김 (`MissionRepository.hide`): + `update missions m1_0 set hidden_at=? where m1_0.id=? and m1_0.hidden_at is null` + — `hidden_at IS NULL` 술어가 재중지를 0행으로 만들어 첫 중지 시각을 보존한다. +- 승인 행사 목록 (`findApprovedPageByTab`, `TAB_CASE` 상수가 select·where·countQuery 3자리에 전개된 결과): + `select es1_0.id, es1_0.approval_no, es1_0.submission_no, es1_0.type, es1_0.title, es1_0.organizer_name, u1_0.org_name, es1_0.starts_on, es1_0.ends_on, case when es1_0.starts_on>? then 'UPCOMING' when es1_0.ends_on? then 'UPCOMING' when es1_0.ends_on Date: Sun, 30 Aug 2026 12:40:36 +0900 Subject: [PATCH 23/35] =?UTF-8?q?MSG-500=20docs:=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B2=B0=EA=B3=BC=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=B6=94=EC=A0=81=20=EB=A7=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=EC=8A=A4=EB=A5=BC=20=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-USER-16과 FR-EVENT-18은 구현됨, FR-EVENT-15와 17은 참여형 잔여로 진행 중 유지. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/rtm.md | 9 +++++---- docs/srs-changelog.md | 1 + docs/srs.md | 8 ++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/rtm.md b/docs/rtm.md index 8518acd2..1bb986c3 100644 --- a/docs/rtm.md +++ b/docs/rtm.md @@ -5,7 +5,7 @@ **병합 충돌이 나면 손으로 합치지 말고 재생성한다.** 어느 쪽이든 골라 충돌만 없앤 뒤(`git checkout --ours docs/rtm.md` 등) 스크립트를 다시 돌려 그 결과를 커밋한다. 이 표는 두 원천에서 계산되는 값이라 양쪽 diff를 섞으면 어느 쪽과도 다른 상태가 된다. -요약: FR 288건 중 테스트 연결 256건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 25건, 성격상 테스트 비대상 7건 별도) +요약: FR 290건 중 테스트 연결 259건, 검증 공백 0건 (계획·폐기라 테스트 부재가 정상인 24건, 성격상 테스트 비대상 7건 별도) | 요구사항 ID | SRS 상태 | 검증 테스트 | |---|---|---| @@ -201,8 +201,10 @@ | FR-EVENT-12 | 구현됨 | MissionAwardQueryTest, MissionProgressQueryTest, MissionVideoCountQueryTest, MissionVideoListQueryTest, MissionVisitedGridQueryTest | | FR-EVENT-13 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest, EventSubmissionImageStoreTest, EventSubmissionValidationTest | | FR-EVENT-14 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest | -| FR-EVENT-15 | 계획 | (없음) | +| FR-EVENT-15 | 진행 중 | AdminEventSubmissionControllerTest, AdminEventSubmissionReviewTest, EventSubmissionApprovalConcurrencyTest, EventSubmissionImageStoreTest, EventSubmissionReviewSchemaTest, MissionQueryServiceCacheTest | | FR-EVENT-16 | 구현됨 | EventOccurrenceRepositoryTest, OrgEventControllerTest, OrgEventQueryServiceTest | +| FR-EVENT-17 | 진행 중 | AdminEventUnpublishTest, EventSubmissionApprovalConcurrencyTest | +| FR-EVENT-18 | 구현됨 | AdminApprovedEventListTest | | FR-ROUTE-01 | 진행 중 | AnonymousReadAccessHttpTest, RouteCandidateCollectorTest, RouteControllerTest, RouteIntentClientTest, RouteRecommendServiceTest | | FR-ROUTE-02 | 진행 중 | RouteCandidateCollectorTest, RouteRecommendServiceTest | | FR-ROUTE-03 | 구현됨 | EventQueryServiceTest, PlaceSearchServiceTest, RouteCandidateCollectorTest, RouteOrderPlannerTest | @@ -296,7 +298,7 @@ | FR-USER-13 | 진행 중 | UserProfileControllerTest, UserProfileImageIntegrationTest | | FR-USER-14 | 구현됨 | UserLocationConsentIntegrationTest, UserProfileControllerTest | | FR-USER-15 | 구현됨 | UserConsentControllerTest, UserConsentIntegrationTest | -| FR-USER-16 | 진행 중 | OrgAccountControllerTest, OrgAccountSecurityPersistenceTest, OrgEmailChangeConcurrencyTest | +| FR-USER-16 | 구현됨 | AdminEmailChangeReviewTest, EmailChangeApprovalConcurrencyTest, EventSubmissionReviewSchemaTest, OrgAccountControllerTest, OrgAccountSecurityPersistenceTest, OrgEmailChangeConcurrencyTest | ## 검증 공백: 구현됐는데 대응 테스트가 없다 (조치 대상) @@ -322,7 +324,6 @@ - FR-EVENT-03 (폐기됨) - FR-EVENT-04 (폐기됨) - FR-EVENT-05 (폐기됨) -- FR-EVENT-15 (계획) - FR-ROUTE-15 (계획) - FR-NOTI-12 (계획) - FR-NOTI-13 (폐기됨) diff --git a/docs/srs-changelog.md b/docs/srs-changelog.md index 1fc15497..9401e13a 100644 --- a/docs/srs-changelog.md +++ b/docs/srs-changelog.md @@ -144,3 +144,4 @@ union이 붙어 양쪽 행이 그대로 남는다. 2026-08-14에 같은 자리 | 2026-08-29 | FR-EVENT-13·FR-EVENT-14를 계획에서 구현됨으로 올렸다. 행사 운영자 콘솔의 신청 제출(presign 포함)·내 목록·상세·반려본 재제출 API 5개가 검증 테스트와 같은 브랜치에 실렸다. 위치당 영역 상한은 사각형 합집합 기준 81칸으로 판정하고, 존재 은닉은 13430 단일 응답, 재제출 전이는 소유권 술어 포함 조건부 UPDATE로 원자화했다. 반려 행 쓰기와 승인 반영(FR-EVENT-15)은 MSG-500 몫으로 계획 유지 | MSG-498 구현 (2026-08-29) | | 2026-08-30 | FR-EVENT-15 문장을 재작성하고 계획에서 진행 중으로 올렸다. 승인 반영이 유형별 분기로 확정되어(지역축제는 축제 미션, 팝업스토어는 팝업 미션으로 칩 등재, 이벤트 참여형만 부모 이벤트 아래 행사 위치로 반영 + 참여 속성 부가 노출) 구 "행사 도메인에 시리즈·회차·위치 생성" 서술을 대체했다. 반려 항목 코드 4종 필수도 함께 등재했다. FR-EVENT-01 근거의 "등재 기준 재정의 대상" 예고는 이 확정으로 닫았다 — 행사 칩에 새 행사가 오르는 경로는 여전히 수동 시드뿐이다 | 2026-08-29 정민 확정, PRD `docs/prd/event-submission.md` v2.2(MSG-502 브랜치 반영 중), 스펙 `docs/spec/MSG-500.md` | | 2026-08-30 | FR-EVENT-17(관리자 노출 중지 — 사유 필수, 사용자 대면 전 경로 즉시 제외, 직접 접근 포함, 완료 기록·뱃지 비회수, 운영자 사유 통지)과 FR-EVENT-18(승인 행사 상태별 목록과 건수, 중지 식별)을 진행 중으로 등재했다. PRD FR-20·FR-25에 대응하는 SRS 행이 없어 MSG-500의 M4 테스트가 인용할 ID가 비어 있던 갭을 선등재로 닫았다(MSG-390의 소급 주석 교훈) | MSG-500 구현 중 (2026-08-30), PRD `docs/prd/event-submission.md` FR-20·25 | +| 2026-08-30 | MSG-500 구현 반영. FR-USER-16을 구현됨으로 올렸다(잔여였던 아이디 변경 요청의 관리자 검토·승인 처리 완료 — 승인은 요청 전이와 이메일 교체 한 트랜잭션, 검토 시점 가드, 새 이메일 통지). FR-EVENT-18도 구현됨(승인 행사 상태별 목록, KST 파생 탭). FR-EVENT-15·17은 진행 중 유지 — 미션 경로(심사·승인·반려·중지)는 구현됐지만 이벤트 참여형 승인 반영과 위치 숨김·노출 영역 재계산이 MSG-502(V50) 머지 후 잔여라서다. 검증 테스트는 같은 브랜치의 구현 커밋들에 동반된다 | MSG-500 구현 (2026-08-30), 브랜치 feature/MSG-500-admin-review | diff --git a/docs/srs.md b/docs/srs.md index 542e9f30..4563b305 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -394,10 +394,10 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | FR-EVENT-12 | 행사 영상은 미션 집계 어디에도 잡히지 않는다. 미션 진행도, 완료 판정, 방문 스팟, 미션 영상 목록(첫 페이지와 다음 페이지), 격자별 미션 영상 수 전부에서 제외된다. 행사 영상이 일반 업로드의 부수효과(점령, 스트릭, 핫스코어, 뱃지 등)를 그대로 받는 가운데 유일한 제외 항목이다 | 구현됨 | 구 PRD "미션 축 연계 비목표" 승계 확정 (2026-08-20). 계약 정본 `docs/spec/MSG-438.md` §부수효과 계약. MSG-450 구현 (2026-08-22 — `videos` 를 직접 조인하는 미션 쿼리 여섯 곳(진행도·완료 판정·방문 스팟·미션 영상 목록 두 쿼리·격자별 영상 수)에 `NOT EXISTS event_videos` 안티조인을 넣었다. 쿼리 1~3 은 LEFT/INNER 조인의 ON 절, 4~6 은 WHERE 절이라 미진행 미션의 0 진행도가 유지된다. 술어 동등 계약을 안티조인 포함으로 갱신, 새 테이블·인덱스·마이그레이션 없음). 쓰기 경로의 판정 시점 가시성 계약은 MSG-440 이 미션 판정 훅 자체를 행사 업로드에서 빼는 방식으로 이미 만족한다(`VideoServiceImpl.confirmAndStore` 는 미션 훅 밖) | | FR-EVENT-13 | 행사 운영자는 등록 유형(지역축제, 팝업스토어) 중 하나를 골라 행사를 신청할 수 있고 유형마다 기본 정보 항목이 다르다(세 번째 유형인 이벤트 참여형은 승인 이벤트를 골라 참여를 신청하는 별도 구조로 MSG-501·MSG-502에 분리). 위치는 지도 격자 사각형 영역으로 지정하며 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있고, 위치당 영역은 사각형 합집합 기준 최대 격자 81칸이다. 대표 격자는 신청자가 아니라 서버가 계산한다. 접수된 신청은 심사 중 상태가 되고 신청 번호가 부여된다. 임시 저장은 미포함 확정 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 개정 (2026-08-28 승인, 유형 재편·81칸 상한 확정·임시 저장 미포함) · 스펙 `docs/spec/MSG-498.md` (2026-08-28). 위치 영역 형식은 기존 시드 areaRects와 동일. MSG-498 구현 (2026-08-29 — `POST /api/org/event-submissions` + presign, V49 스키마 4테이블, 81칸 합집합 판정, 신청 번호 FM-{연도}-{4자리} 전역 시퀀스, 대표 격자 서버 계산) | | FR-EVENT-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 | 관리자는 신청을 상태별로 조회하고 상세에서 위치 사각형과 노출 영역을 검토해 승인하거나 반려할 수 있다. 반려에는 항목 코드(행사 기간, 위치 영역, 홍보 이미지, 행사 정보 중 1개 이상)와 자유 서술이 필수다. 승인 반영은 유형에 따라 갈린다. 지역축제 승인분은 축제 미션으로, 팝업스토어 승인분은 팝업 미션으로 등재되어 지도 홈의 기존 축제·팝업 칩 목록에 실리고, 이벤트 참여형 승인분은 부모 이벤트 아래 행사 위치로 반영되며 운영 주체·소개·공개 기간·참여 방식·커버 이미지가 위치의 부가 정보로 함께 노출된다. 승인에는 승인 번호가 부여되고, 승인 산출물은 기존 산출물과 같은 규칙(대표 격자 결정, 회차 내 격자 단일 귀속, 생명주기)을 따르며 승인 즉시 지도 조회에 반영된다 | 진행 중 | PRD `docs/prd/event-submission.md` v2.2 (2026-08-29 정민 확정 — 노출 방식 유형별 분기, MSG-502 브랜치 반영 중. 구 "행사 도메인에 시리즈·회차·위치 생성" 서술은 참여형 한정으로 좁혀짐). 스펙 `docs/spec/MSG-500.md` (2026-08-29). MSG-500 구현 중. 승인 후 일정 수정 정책은 유보 확정(운영팀 문의 안내) | +| FR-EVENT-15 | 관리자는 신청을 상태별로 조회하고 상세에서 위치 사각형과 노출 영역을 검토해 승인하거나 반려할 수 있다. 반려에는 항목 코드(행사 기간, 위치 영역, 홍보 이미지, 행사 정보 중 1개 이상)와 자유 서술이 필수다. 승인 반영은 유형에 따라 갈린다. 지역축제 승인분은 축제 미션으로, 팝업스토어 승인분은 팝업 미션으로 등재되어 지도 홈의 기존 축제·팝업 칩 목록에 실리고, 이벤트 참여형 승인분은 부모 이벤트 아래 행사 위치로 반영되며 운영 주체·소개·공개 기간·참여 방식·커버 이미지가 위치의 부가 정보로 함께 노출된다. 승인에는 승인 번호가 부여되고, 승인 산출물은 기존 산출물과 같은 규칙(대표 격자 결정, 회차 내 격자 단일 귀속, 생명주기)을 따르며 승인 즉시 지도 조회에 반영된다 | 진행 중 | PRD `docs/prd/event-submission.md` v2.2 (2026-08-29 정민 확정 — 노출 방식 유형별 분기, MSG-502 브랜치 반영 중). 스펙 `docs/spec/MSG-500.md`. MSG-500이 심사 큐·상세·반려와 지역축제·팝업 승인(미션 등재·커밋 후 스냅숏 무효화)을 구현(2026-08-30, 검증 테스트 다수). **잔여는 이벤트 참여형 승인의 위치 반영과 참여 속성 노출로, MSG-502(V50) 머지 후 착수한다**(M6). 승인 후 일정 수정 정책은 유보 확정(운영팀 문의 안내) | | FR-EVENT-16 | 행사 운영자는 등록 유형 "이벤트"에서 참여할 승인 이벤트 목록을 조회할 수 있다. 목록은 이벤트 카테고리(지역축제와 팝업스토어를 제외한 큰 행사)의 종료 전 회차만 담고, 시·도 필터와 이벤트 이름 검색과 시·도별 건수를 제공하며, 항목마다 이름·기간·장소 라벨·시·도가 담긴다. 노출 시작 전 예정 회차도 이 콘솔 목록에는 보인다 | 구현됨 | MSG-501 구현 (2026-08-28, `GET /api/org/events` — 검증 테스트 25건 동반). PRD `docs/prd/event-submission.md` FR-26 (2026-08-28 최소 개정), v2.1 [행사 운영자 3-1] 이벤트 선택 모달. 부모 범위는 이벤트 카테고리만으로 사용자 확정(2026-08-28, 시안의 지역축제/팝업스토어 배지는 구판 잔재). FR-EVENT-13의 유형 서술(행사방)을 v2.1(이벤트 참여형)로 갱신하는 것은 MSG-498 레인 몫이라 여기서 손대지 않는다 | -| FR-EVENT-17 | 관리자는 승인된 행사의 노출을 사유와 함께 중지할 수 있다. 중지하면 그 승인 산출물이 사용자 대면 조회와 판정 경로 전부(지도 칩 목록, 격자 선택, 영상 목록, 이미 알고 있는 식별자로의 직접 접근 포함)에서 즉시 사라지고, 행사 운영자에게 사유가 통지된다. 이미 완료한 사용자의 진행 기록과 뱃지 지표는 회수되지 않고, 관리자 화면에서는 중지된 행사의 기록이 계속 조회된다 | 진행 중 | PRD `docs/prd/event-submission.md` FR-20 (2026-08-28 승인, v2 [관리자 5] 시안). 스펙 `docs/spec/MSG-500.md` D-3·D-5 (2026-08-29). MSG-500 구현 중. 중지 해제(재노출)는 미결정이라 범위 밖 | -| FR-EVENT-18 | 관리자는 승인된 행사를 노출 중, 예정, 종료 상태별로 조회할 수 있고 상태별 건수가 함께 제공된다. 상태는 조회 시점과 행사 기간으로 판정하며, 중지된 행사는 목록에서 중지로 식별된다 | 진행 중 | PRD `docs/prd/event-submission.md` FR-25 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-11 (2026-08-29). MSG-500 구현 중 | +| FR-EVENT-17 | 관리자는 승인된 행사의 노출을 사유와 함께 중지할 수 있다. 중지하면 그 승인 산출물이 사용자 대면 조회와 판정 경로 전부(지도 칩 목록, 격자 선택, 영상 목록, 이미 알고 있는 식별자로의 직접 접근 포함)에서 즉시 사라지고, 행사 운영자에게 사유가 통지된다. 이미 완료한 사용자의 진행 기록과 뱃지 지표는 회수되지 않고, 관리자 화면에서는 중지된 행사의 기록이 계속 조회된다 | 진행 중 | PRD `docs/prd/event-submission.md` FR-20 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-3·D-5. MSG-500이 미션 경로 중지를 구현(2026-08-30 — hidden_at 숨김, 소비 경로 전수 필터·피드 404 수렴, 커밋 후 스냅숏 무효화, 사유 메일. 검증 테스트 다수). **잔여는 참여형 위치 숨김과 부모 회차 노출 영역 재계산으로 MSG-502 머지 후**(M6) — 그 전까지 "승인 산출물 전부"가 절반이다. 중지 해제(재노출)는 미결정이라 범위 밖 | +| FR-EVENT-18 | 관리자는 승인된 행사를 노출 중, 예정, 종료 상태별로 조회할 수 있고 상태별 건수가 함께 제공된다. 상태는 조회 시점과 행사 기간으로 판정하며, 중지된 행사는 목록에서 중지로 식별된다 | 구현됨 | PRD `docs/prd/event-submission.md` FR-25 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-11. MSG-500 구현 (2026-08-30 — `GET /api/admin/events`, 탭 상태는 KST 오늘로 파생하고 필터·항목·건수가 같은 CASE 식 한 벌을 쓴다. 경계일 검증 포함 테스트 6건) | ### ROUTE: AI 경로 추천 @@ -528,7 +528,7 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | FR-USER-13 | 내 프로필 조회에는 프로필 이미지 URL(미설정이면 null)과 가입 시각이 담긴다. 설정한 이미지는 친구 목록, 친구 프로필, 받은 친구 요청 응답에도 반영된다 | 진행 중 | MSG-373 (2026-08-11). 코드 완료(PR #151), 이미지 표시가 공개 읽기 정책(ops)에 걸려 있어 진행 중 유지 | | FR-USER-14 | 첫 로그인(가입) 직후 온보딩에서 위치기반서비스 이용 동의를 필수로 받는다. 계정은 미동의 상태로 생성되고, 동의 전에는 클라이언트 온보딩이 서비스 진입을 막는다(서버는 차단하지 않는다). 도입 시점에 이미 존재하는 사용자도 미동의로 시작해 다음 로그인에서 같은 온보딩을 지난다. 이 동의는 한 번 하면 철회할 수 없다. 서버는 철회 요청을 400으로 거절하고, 켜는 방향(동의)만 받는다(2026-08-19 팀 합의 — 위치가 서비스 핵심이라 철회 상태의 앱 이용을 두지 않는다. 종전 "프로필에서 켜고 끌 수 있다"는 폐기, 프로필·설정 화면의 위치 토글도 디자인에서 제거). 서버는 현재 동의 여부와 마지막으로 동의한 시각을 보관한다. 이 동의는 위치정보법상 위치기반서비스 이용 동의를 겸한다. 현재 동의 상태는 내 프로필 조회에 담긴다 | 구현됨 | MSG-402 (2026-08-15 구현 — V32, PUT /api/users/me/location-consent, 프로필 응답 동봉. 온보딩 UI·차단 목록은 FE 몫) + MSG-433 (2026-08-19 철회 차단 시행). 2026-08-15 정민 확정(가입 시 필수 수취까지 같은 날 확정), 2026-08-19 팀 합의로 철회 불가 전환. 웹 디자인 ver 13 프로필 편집 모달(노드 14599-3501)의 위치정보 사용 토글이 발단. 저장 테이블 부재는 위키 갭 분석(2026-07-17)이 지적했던 공백. 서버 무차단은 GPS 검증 포기 기결정(업로드 PRD)과 일관. MSG-402, PRD `docs/prd/MSG-402-prd.md` | | FR-USER-15 | 가입 동의는 5항목이다. 만 14세 이상 확인, 서비스 이용약관, 개인정보 수집·이용, 위치기반서비스 이용약관이 필수이고 마케팅 정보 수신이 선택이다. 서버는 철회가 없는 필수 4항목(만 14세, 서비스 이용약관, 개인정보, 위치기반서비스 — 위치는 FR-USER-14의 2026-08-19 철회 불가 전환에 따름)의 동의 사실과 시각을 보관하고, 철회할 수 있는 유일한 항목인 마케팅 수신은 현재 상태와 마지막으로 동의하거나 철회한 시각을 보관하며, 클라이언트는 로그인 직후 조회 한 번으로 필수 동의 완료 여부를 판별해 미완료면 동의 게이트로 진입을 막는다(서버는 차단하지 않는다, FR-USER-14와 같은 원칙). 필수 항목이 하나라도 빠진 제출은 거절되고 아무 항목도 저장되지 않으며, 이미 동의한 사용자의 재제출은 오류가 아니다. 위치기반서비스 항목은 FR-USER-14의 위치 동의와 한 값으로 관리되어 서로 모순되지 않는다. 마케팅 수신 동의는 가입 후에도 조회하고 변경할 수 있고 변경 시각이 남는다. 도입 시점에 이미 존재하는 사용자는 미완료로 시작해 다음 로그인에서 같은 게이트를 지난다. 만 14세 확인은 자기 확인 체크 사실만 저장하고 생년월일은 수집하지 않는다 | 구현됨 | MSG-433 (2026-08-19 구현 — V37, GET/PUT /me/consents·PUT /me/marketing-consent, 게이트 생명주기 통합 시나리오 포함 47건 green. 검증 테스트 2파일에 // 검증: FR-USER-15 주석 연결). 약관 전문 작성과 버전 관리·재동의는 범위 밖. PRD `docs/prd/MSG-433-prd.md` | -| FR-USER-16 | 행사 운영자는 담당자 이름과 연락처를 자체 수정할 수 있다. 아이디인 공식 이메일은 기관 인증의 근거라 자체 변경이 불가하고, 변경 요청을 접수해 관리자가 승인해야 바뀐다 | 진행 중 | MSG-497이 조회·수정과 변경 요청 접수·저장까지 구현 (2026-08-28), PRD FR-23. **잔여는 아이디 변경 요청의 관리자 검토·승인 처리 하나이고 담당은 MSG-500이다** — MSG-499가 만든 관리자 큐는 계정 발급 요청(`org_account_requests`) 전용이라 아이디 변경 요청(`org_email_change_requests`)은 접수만 되고 화면에 노출되지 않는다(MSG-499 스펙 비범위 명문) | +| FR-USER-16 | 행사 운영자는 담당자 이름과 연락처를 자체 수정할 수 있다. 아이디인 공식 이메일은 기관 인증의 근거라 자체 변경이 불가하고, 변경 요청을 접수해 관리자가 승인해야 바뀐다 | 구현됨 | MSG-497이 조회·수정과 변경 요청 접수·저장까지(2026-08-28), MSG-500이 잔여였던 관리자 검토·승인 처리를 구현(2026-08-30 — `GET/POST /api/admin/email-change-requests`, 승인은 요청 전이와 users.email 교체 한 트랜잭션, 검토 시점 가드 1429, 통지는 새 이메일 발송. 검증 테스트 17건 동반). PRD FR-23 | --- From 183b772e2e2922cdaca491dd544435dd4c342c96 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:49:13 +0900 Subject: [PATCH 24/35] =?UTF-8?q?MSG-500=20fix:=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=98=20=EB=8F=99=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=EC=9D=B4=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=20=EC=B2=98=EB=A6=AC=EB=90=A8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EB=A0=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이메일 선검사를 전이 UPDATE 뒤로 옮겨 재승인은 0행 분기의 1428로 결정된다. 1409 거절 시 전이가 함께 롤백됨을 실커밋 테스트로 고정했다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../AdminEmailChangeRequestService.java | 40 +++++++---- .../AdminEmailChangeReviewTest.java | 9 ++- .../EmailChangeApprovalConcurrencyTest.java | 66 ++++++++++++++++++- 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java b/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java index ff7de885..314c6b78 100644 --- a/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java +++ b/src/main/java/com/msg/fillmap/user/service/AdminEmailChangeRequestService.java @@ -97,35 +97,47 @@ public AdminEmailChangeRequestListResponseDto getRequests(String status, int pag /** * 승인 (§API 7, D-13). 실행 순서가 계약이다. *

- * ① 요청을 읽어 상태와 검토 시각을 먼저 본다(빠른 거절, 값도 여기서 챙긴다). ①-1 대상 계정 행을 - * 비관 잠금으로 잡는다 — 계정 삭제와 잠금 순서를 맞추기 위한 것이고 근거는 아래 - * {@code findWithLockById} 호출부 주석에 있다. ② 새 이메일이 다른 계정에 - * 있는지 선검사한다 — 읽히는 1409 를 주기 위한 것이고 경합에는 진다(서로 다른 두 요청이 같은 - * 이메일을 노리면 둘 다 통과한다). ③ 조건부 UPDATE 로 전이한다. 술어에 접수 시각이 들어 있어 검토 이후 - * 재제출된 요청은 여기서 걸린다. ④ 0행이면 재조회로 가른다(1427·1428·1429). ⑤ 같은 트랜잭션에서 이메일을 - * 교체한다 — 벌크 UPDATE 라 uq_users_email 위반이 실행 즉시 뜨므로 그 자리에서 1409 로 번역한다 - * (커밋 시점까지 미뤄지면 잡을 자리가 없다). ⑥ 커밋 후 새 이메일로 통지한다. + * ① 요청을 읽어 상태와 검토 시각을 먼저 본다(빠른 거절, 값도 여기서 챙긴다). ② 대상 계정 행을 비관 + * 잠금으로 잡는다 — 계정 삭제와 잠금 순서를 맞추기 위한 것이고 근거는 아래 + * {@code findWithLockById} 호출부 주석에 있다. ③ 조건부 UPDATE 로 전이한다. 술어에 접수 시각이 들어 + * 있어 검토 이후 재제출된 요청은 여기서 걸린다. ④ 0행이면 재조회로 가른다(1427·1428·1429). + * ⑤ 새 이메일이 다른 계정에 있는지 선검사한다 — 읽히는 1409 를 주기 위한 것이고 경합에는 진다 + * (서로 다른 두 요청이 같은 이메일을 노리면 둘 다 통과한다). ⑥ 같은 트랜잭션에서 이메일을 교체한다 — + * 벌크 UPDATE 라 uq_users_email 위반이 실행 즉시 뜨므로 그 자리에서 1409 로 번역한다(커밋 + * 시점까지 미뤄지면 잡을 자리가 없다). ⑦ 커밋 후 새 이메일로 통지한다. + *

+ * 선검사가 전이보다 뒤인 것이 계약이다 (Codex 스톱 게이트 적발). 앞에 두면 같은 요청의 동시 + * 재승인에서 늦은 쪽이 1409 로 잘못 수렴한다 — 아래 호출부 주석에 상세가 있다. 순서를 되돌리면 + * {@code EmailChangeApprovalConcurrencyTest} 의 동시 재승인 케이스가 1428 대신 1409 를 받아 실패한다. */ public EmailChangeApproveResponseDto approve(Long requestId, EmailChangeApproveRequestDto request) { Approved approved = transactionTemplate.execute(status -> { OrgEmailChangeRequest target = findReviewable(requestId, request.requestedAt()); + Long userId = target.getUserId(); String newEmail = target.getRequestedEmail(); // 잠금 순서를 users → 요청 행으로 통일한다. 계정 삭제(UserService)는 users 행을 지우고 FK // ON DELETE CASCADE(V46)가 그 사용자의 요청 행을 잠그므로, 여기서 요청 행을 먼저 잠그면 // 반대 순서가 되어 동시 실행이 AB-BA 데드락(한쪽 500)으로 끝난다. 읽기만 하고 버리는 // 조회지만 잡는 잠금이 목적이고, 그 사이 탈퇴한 계정은 여기서 1404 로 갈린다. - userRepository.findWithLockById(target.getUserId()) + // 이 조회가 영속성 컨텍스트에 올린 User 는 아무도 다시 쓰지 않는다 — 값은 위에서 이미 + // 챙겼고, 바로 아래 두 벌크 UPDATE 의 clearAutomatically 가 컨텍스트를 비워 스테일 재사용 + // 경로 자체가 없다(수정한 적이 없으니 비워질 때 나갈 flush 도 없다). + userRepository.findWithLockById(userId) .orElseThrow(() -> new ApiException(UserErrorCode.USER_NOT_FOUND)); - if (userRepository.existsByEmail(newEmail)) { - throw new ApiException(UserErrorCode.EMAIL_ALREADY_EXISTS); - } LocalDateTime now = LocalDateTime.now(clock); if (requestRepository.approvePending(requestId, request.requestedAt(), now) == 0) { throw transitionFailure(requestId, request.requestedAt()); } - replaceEmail(target.getUserId(), newEmail); - return new Approved(target.getUserId(), newEmail); + // 이메일 선검사가 전이 인 것이 계약이다. 앞에 두면 같은 요청의 동시 재승인에서 늦은 + // 쪽이 잠금 대기 후 이긴 쪽이 이미 적용한 그 이메일을 발견해 1409 로 끝난다 — 관리자에게 + // "다른 계정과 충돌"이라는 거짓 원인을 보여준다. 전이가 먼저면 늦은 쪽은 0행 → 1428 로 + // 결정적으로 수렴하고, 진짜 충돌(다른 계정 선점)은 여기서 1409 로 잡히며 전이는 롤백된다. + if (userRepository.existsByEmail(newEmail)) { + throw new ApiException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + replaceEmail(userId, newEmail); + return new Approved(userId, newEmail); }); return new EmailChangeApproveResponseDto(requestId, approved.email(), notify(approved.email())); } diff --git a/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java b/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java index 57ae6186..71e447a0 100644 --- a/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java +++ b/src/test/java/com/msg/fillmap/user/controller/AdminEmailChangeReviewTest.java @@ -203,9 +203,15 @@ class Approve { assertThat(저장된_계정().getEmail()).isEqualTo(requestedEmail); } + /** + * 응답 코드만 본다. "전이도 함께 롤백된다"는 여기서 관찰할 수 없다 — 선검사가 전이 라 + * (같은 요청 동시 재승인을 1428 로 수렴시키기 위한 순서) 되돌리기가 트랜잭션 롤백에 달려 있는데, + * 이 클래스는 롤백 격리라 서비스의 TransactionTemplate 이 테스트 트랜잭션에 합류해 진짜 롤백이 + * 일어나지 않는다. 그 검증은 {@code EmailChangeApprovalConcurrencyTest} 가 실커밋으로 한다. + */ // 검증: FR-USER-16 @Test - @DisplayName("요청 이메일이 이미 다른 계정에 있으면 1409 이고 아이디는 그대로다") + @DisplayName("요청 이메일이 이미 다른 계정에 있으면 1409 다") void 요청_이메일이_이미_다른_계정에_있으면_1409로_거부된다() throws Exception { String taken = "taken-" + UUID.randomUUID() + "@fillmap.dev"; User other = saveUser(UserRole.ORG); @@ -218,7 +224,6 @@ class Approve { .andExpect(jsonPath("$.developCode").value(1409)); assertThat(저장된_계정().getEmail()).isNotEqualTo(taken); - assertThat(저장된_요청().getStatus()).isEqualTo(OrgEmailChangeStatus.PENDING); } // 검증: FR-USER-16 diff --git a/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java index bb590767..d671848c 100644 --- a/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java +++ b/src/test/java/com/msg/fillmap/user/service/EmailChangeApprovalConcurrencyTest.java @@ -1,6 +1,7 @@ package com.msg.fillmap.user.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.LocalDateTime; import java.time.ZoneOffset; @@ -38,9 +39,10 @@ import com.msg.fillmap.user.repository.UserRepository; /** - * 아이디 변경 승인의 커밋 경계 (MSG-500 D-13, 실 DB). 두 가지를 본다: 같은 이메일을 노린 동시 승인이 - * 읽히는 1409 로 끝나는지(유니크 위반 500 이 아니라), 그리고 승인이 email 컬럼만 갱신해 다른 - * 트랜잭션이 방금 커밋한 값을 되돌리지 않는지다. + * 아이디 변경 승인의 커밋 경계 (MSG-500 D-13, 실 DB). 네 가지를 본다: 같은 이메일을 노린 동시 승인이 + * 읽히는 1409 로 끝나는지(유니크 위반 500 이 아니라), 같은 요청의 동시 승인이 1428 로 + * 수렴하는지(1409 로 새면 관리자가 거짓 원인을 본다), 선점 이메일 거절이 요청 전이까지 롤백하는지, + * 그리고 승인이 email 컬럼만 갱신해 다른 트랜잭션이 방금 커밋한 값을 되돌리지 않는지다. * *

진짜 커밋이 필요해 클래스 수준 {@code @Transactional} 롤백 격리를 쓸 수 없다 — 동시 승인은 두 * 트랜잭션이 실제로 경합해야 하고, 컬럼 보존은 "다른 트랜잭션이 먼저 커밋한 상태"가 전제다 @@ -129,6 +131,64 @@ void tearDown() { } } + // 검증: FR-USER-16 + @Test + @DisplayName("같은 요청을 동시에 두 번 승인하면 늦은 쪽이 1428 이다 — 1409 로 새지 않는다") + void 같은_요청의_동시_승인은_늦은_쪽이_1428이다() throws Exception { + long userId = 계정을_만든다(); + String 새_이메일 = "double-" + UUID.randomUUID() + "@fillmap.dev"; + OrgEmailChangeRequest request = 접수한다(userId, 새_이메일); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + try { + Future left = executor.submit(() -> 승인한다(barrier, request)); + Future right = executor.submit(() -> 승인한다(barrier, request)); + + List failures = Stream.of(결과를_받는다(left), 결과를_받는다(right)) + .filter(Objects::nonNull) + .toList(); + + // users 잠금이 두 트랜잭션을 직렬화하므로 결과가 결정적이다: 이긴 쪽 성공 + 늦은 쪽 1428. + // 이메일 선검사가 전이보다 앞이면 늦은 쪽이 "이긴 쪽이 방금 적용한 그 이메일"을 발견해 + // 1409(다른 계정과 충돌)로 끝난다 — 관리자에게 거짓 원인을 보여주는 그 회귀를 이 단언이 잡는다. + assertThat(failures).hasSize(1).first().isInstanceOf(ApiException.class); + assertThat(((ApiException) failures.getFirst()).getErrorCode()) + .isEqualTo(UserErrorCode.EMAIL_CHANGE_REQUEST_ALREADY_PROCESSED); + assertThat(jdbcTemplate.queryForObject( + "SELECT email FROM users WHERE id = ?", String.class, userId)).isEqualTo(새_이메일); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM org_email_change_requests WHERE id = ?", String.class, request.getId())) + .isEqualTo("APPROVED"); + } finally { + executor.shutdownNow(); + } + } + + // 검증: FR-USER-16 + @Test + @DisplayName("선점된 이메일의 승인은 1409 이고 요청 전이도 함께 롤백된다") + void 선점된_이메일의_승인은_전이까지_롤백된다() { + long takenUserId = 계정을_만든다(); + String 선점된_이메일 = jdbcTemplate.queryForObject( + "SELECT email FROM users WHERE id = ?", String.class, takenUserId); + OrgEmailChangeRequest request = 접수한다(계정을_만든다(), 선점된_이메일); + + assertThatThrownBy(() -> adminEmailChangeRequestService.approve(request.getId(), + new EmailChangeApproveRequestDto(request.getCreatedAt()))) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", UserErrorCode.EMAIL_ALREADY_EXISTS); + + // 선검사가 전이 뒤라 거절 시점엔 이미 APPROVED 로 바뀐 상태였다 — 롤백이 그것을 되돌려야 + // 관리자가 다시 심사할 수 있다(되돌리지 않으면 승인됨인데 이메일은 옛것인 절반이 남는다). + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM org_email_change_requests WHERE id = ?", String.class, request.getId())) + .isEqualTo("PENDING"); + assertThat(jdbcTemplate.queryForObject( + "SELECT processed_at FROM org_email_change_requests WHERE id = ?", String.class, request.getId())) + .isNull(); + } + /** * 승인이 전 컬럼 UPDATE 였다면, 승인 트랜잭션이 들고 있던 스냅숏이 그 사이 커밋된 비밀번호를 되돌린다. * 단일 컬럼 UPDATE 면 이메일만 바뀌고 나머지는 다른 트랜잭션이 마지막으로 커밋한 값 그대로다. From 33fef30ab7fe3d0388714e748aa1368b68a4ed29 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 12:49:13 +0900 Subject: [PATCH 25/35] =?UTF-8?q?MSG-500=20docs:=20=EC=84=A0=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=88=9C=EC=84=9C=20=EA=B3=84=EC=95=BD=EC=9D=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=A1=9C=EA=B7=B8=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/spec/MSG-500.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/spec/MSG-500.md b/docs/spec/MSG-500.md index dbe08c4f..9ddb472a 100644 --- a/docs/spec/MSG-500.md +++ b/docs/spec/MSG-500.md @@ -781,7 +781,7 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic | 1427 | 404 | `AdminEmailChangeRequestService.findReviewable`·`transitionFailure` | 없는 아이디 변경 요청 | | 1428 | 409 | 같음 | 이미 처리된 요청 | | 1429 | 409 | 같음 (0행이고 PENDING인데 시각 불일치) | 검토 이후 재접수로 내용이 바뀜 | -| 1409 | 409 | `approve` 선검사 + `replaceEmail`의 `DataIntegrityViolationException` 번역 | 요청 이메일이 이미 다른 계정에 있음 | +| 1409 | 409 | `approve`의 선검사(**전이 뒤**) + `replaceEmail`의 `DataIntegrityViolationException` 번역 | 요청 이메일이 이미 다른 계정에 있음 — 전이는 롤백된다 | | 1404 | 404 | `approve`의 계정 잠금 조회·`replaceEmail` 0행 | 그 사이 탈퇴한 계정 | | 400(공통) | 400 | `MethodArgumentNotValidException` 핸들러 | `reasonText`·`reason`·`requestedAt` 등 Bean Validation 실패 | @@ -805,6 +805,13 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic 데드락으로 한쪽이 500이라, 전이 **앞**에서 `findWithLockById`로 users 행을 먼저 잡아 두 경로의 잠금 순서를 통일했다(Codex 스톱 게이트 적발). 문장 순서를 바꾸는 안(교체 선행)은 1429·1409 우선순위가 케이스마다 뒤바뀌어 계약이 흔들려 기각했다. 반려는 users를 잠그지 않아 교차가 없다. +- **이메일 선검사는 전이 뒤다** (같은 게이트의 후속 적발). 선검사가 앞이면 같은 요청의 동시 재승인에서 + 늦은 쪽이 users 잠금을 기다린 뒤 **이긴 쪽이 방금 적용한 그 이메일**을 발견해 1409로 끝난다 — 계약은 + 1428이고, 관리자는 "다른 계정과 충돌"이라는 거짓 원인을 보게 된다. 전이를 앞으로 옮기면 늦은 쪽은 + 0행 → 1428로 결정적으로 수렴하고(users 잠금이 직렬화한다), 진짜 충돌은 그 뒤 선검사에서 1409로 잡히며 + **전이가 롤백돼** 요청은 PENDING으로 남는다. 잠금 뒤 상태 재검증 쿼리를 하나 더 두는 안은 기각했다 — + 재배치가 쿼리 0개로 같은 효과다. 검증은 실커밋 클래스 몫이다(롤백 격리 테스트에서는 서비스의 + TransactionTemplate이 테스트 트랜잭션에 합류해 롤백 자체가 일어나지 않는다). - **미션 경유 업로드에는 잔여 TOCTOU가 남는다.** `requireUploadable`의 숨김 검사와 스탬프 판정 사이 밀리초 창에 중지가 커밋되면, **이미 스탬프를 가진 사용자**는 그 창을 통과해 영상이 확정된다 (`requireStamped`가 `existsById`로 기존 스탬프를 인정하기 때문). **첫 완료는 닫혀 있다** — 스탬프가 없는 From c4541d8a9849aef93f1fe6bae2b13baea1d8d070 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 26/35] =?UTF-8?q?MSG-500=20feat:=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EC=97=90=20=EC=B0=B8=EC=97=AC=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EC=88=A8=EA=B9=80=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=9D=84=20=EB=A7=A4=ED=95=91=ED=95=98=EA=B3=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=C2=B7=EC=9E=A0=EA=B8=88=20=EC=BF=BC=EB=A6=AC=EB=A5=BC?= =?UTF-8?q?=20=EB=A7=8C=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../fillmap/event/entity/EventLocation.java | 66 +++++++++++++++++++ .../EventLocationGridRepository.java | 35 ++++++++++ .../repository/EventLocationRepository.java | 22 +++++++ .../repository/EventOccurrenceRepository.java | 13 ++++ 4 files changed, 136 insertions(+) diff --git a/src/main/java/com/msg/fillmap/event/entity/EventLocation.java b/src/main/java/com/msg/fillmap/event/entity/EventLocation.java index 7d8c8706..a136881a 100644 --- a/src/main/java/com/msg/fillmap/event/entity/EventLocation.java +++ b/src/main/java/com/msg/fillmap/event/entity/EventLocation.java @@ -1,5 +1,8 @@ package com.msg.fillmap.event.entity; +import java.time.LocalDate; +import java.time.LocalDateTime; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -27,6 +30,13 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class EventLocation { + /** + * 승인 산출물 위치의 자연키 접두 (MSG-500 D-8) — {@code sub-{submissionNo}-{순번}}. 신청 하나가 만든 + * 위치를 접두로 역산하고(신청 행에 단일 FK 를 둘 수 없다), 시드 위치와 기여 축을 가르는 표식이기도 + * 하다: 시드 위치의 노출 영역 기여는 시드 사각형이 정본이고, 이 접두를 가진 위치만 확장 기여분이다. + */ + public static final String SUBMISSION_KEY_PREFIX = "sub-"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -54,11 +64,67 @@ public class EventLocation { @Column(name = "representative_grid_id", length = 20, nullable = false) private String representativeGridId; + /** + * 노출 중지 시각 (V51, MSG-500 D-3). NULL = 노출 중이고, 값이 있으면 회차 상세·격자 조회·영상 목록· + * 업로드·직접 접근에서 전부 빠진다. 시드 위치는 전부 NULL 이라 기존 행사 조회 동작이 불변이다. + */ + @Column(name = "hidden_at") + private LocalDateTime hiddenAt; + + /** + * 참여 속성 6종 (V51, MSG-500 D-8) — 이벤트 참여형 승인분만 값을 갖고 시드 위치는 전부 NULL 이다. + * 신청 한 건의 값이 그 신청이 만든 모든 위치로 복사된다(속성이 신청 단위라는 PRD v2.2 확정 1). + *

+ * {@code startsOn}·{@code endsOn} 은 표기 정보이지 노출 창이 아니다 — 위치 노출은 종전대로 부모 + * 회차 생명주기와 {@link #hiddenAt} 이 지배한다. 노출 창으로 해석하면 위치별 상태 판정 축이 하나 더 + * 생기는데 PRD v2.2 는 "부가 정보"라고만 확정했다(미해결 질문). + */ + @Column(name = "organizer_name", length = 100) + private String organizerName; + + @Column(name = "description") + private String description; + + @Column(name = "starts_on") + private LocalDate startsOn; + + @Column(name = "ends_on") + private LocalDate endsOn; + + @Column(name = "participation_method") + private String participationMethod; + + /** 공개 프리픽스 사본 키 (D-6). 공개 주소 조립은 조회 시점이라 여기엔 키만 저장한다. */ + @Column(name = "image_key", length = 255) + private String imageKey; + public EventLocation(EventOccurrence occurrence, String locationKey) { this.occurrence = occurrence; this.locationKey = locationKey; } + /** + * 승인 산출물 위치 (MSG-500 D-8) — 이벤트 참여형 신청이 승인될 때 부모 회차 아래 만들어진다. + * 시더가 쓰는 {@link #update} 와 갈라 두는 것은 이 경로가 참여 속성까지 한 번에 정하고 다시 + * 갱신되지 않기 때문이다(재시드 대상이 아니다 — locationKey 접두 {@code sub-} 가 그 표식이다). + */ + public static EventLocation forSubmission(EventOccurrence occurrence, String locationKey, String name, + EventLocationType type, int displayOrder, String representativeGridId, String organizerName, + String description, LocalDate startsOn, LocalDate endsOn, String participationMethod, String imageKey) { + EventLocation location = new EventLocation(occurrence, locationKey); + location.name = name; + location.type = type; + location.displayOrder = displayOrder; + location.representativeGridId = representativeGridId; + location.organizerName = organizerName; + location.description = description; + location.startsOn = startsOn; + location.endsOn = endsOn; + location.participationMethod = participationMethod; + location.imageKey = imageKey; + return location; + } + /** 재시드 갱신 — 대표 격자는 매 실행 3단 규칙으로 재계산된 값이 들어온다. */ public void update(EventOccurrence occurrence, String name, EventLocationType type, String operatingHours, int displayOrder, String representativeGridId) { diff --git a/src/main/java/com/msg/fillmap/event/repository/EventLocationGridRepository.java b/src/main/java/com/msg/fillmap/event/repository/EventLocationGridRepository.java index afd09346..e2e93637 100644 --- a/src/main/java/com/msg/fillmap/event/repository/EventLocationGridRepository.java +++ b/src/main/java/com/msg/fillmap/event/repository/EventLocationGridRepository.java @@ -34,4 +34,39 @@ public interface EventLocationGridRepository extends JpaRepository findLocationsByGridId(@Param("gridId") String gridId); + + /** + * 회차의 전 격자 (MSG-500 D-9 겹침 사전 검사) — 숨긴 위치의 격자도 포함한다. + * uq_event_grid_per_occ 는 행이 있으면 걸리는 제약이라 숨김 여부를 보지 않기 때문이다. 가시 격자만 + * 세면 중지된 위치와 같은 칸을 새 승인이 집어 커밋 시점 500 이 된다. + */ + @Query("SELECT g.id.gridId FROM EventLocationGrid g WHERE g.eventOccurrenceId = :occurrenceId") + List findGridIdsByOccurrenceId(@Param("occurrenceId") Long occurrenceId); + + /** + * 회차의 가시 격자 (MSG-500 D-3 노출 영역 재계산) — 위 조회와 정확히 반대 용도다. 노출 영역은 + * "보이는 위치들을 감싸는 범위"라는 불변식이라 숨긴 위치의 칸이 들어가면 중지가 영역을 줄이지 못한다. + */ + @Query(""" + SELECT g.id.gridId FROM EventLocationGrid g + JOIN g.location l + WHERE g.eventOccurrenceId = :occurrenceId AND l.hiddenAt IS NULL + """) + List findVisibleGridIdsByOccurrenceId(@Param("occurrenceId") Long occurrenceId); + + /** + * 회차의 가시 격자 중 승인 산출물 위치(locationKey 접두 {@code sub-})의 것만 (MSG-500 재시드 보존). + * 위 조회와 갈라 둔 것은 시더가 순서에 무관해야 하기 때문이다 — 시더는 회차를 먼저 갱신하고 위치를 + * 나중에 동기화하므로, 전 가시 격자를 합집합에 넣으면 시드가 줄이거나 옮긴 옛 시드 격자가 아직 살아 + * 있는 채로 들어가 부풀린 영역이 다음 재기동까지 남는다. 시드 위치의 기여는 시드 사각형이 정본이므로 + * 확장 입력에서 빼는 것이 의미상으로도 맞다. + */ + @Query(""" + SELECT g.id.gridId FROM EventLocationGrid g + JOIN g.location l + WHERE g.eventOccurrenceId = :occurrenceId AND l.hiddenAt IS NULL + AND l.locationKey LIKE CONCAT(:locationKeyPrefix, '%') + """) + List findVisibleGridIdsByOccurrenceIdAndKeyPrefix(@Param("occurrenceId") Long occurrenceId, + @Param("locationKeyPrefix") String locationKeyPrefix); } diff --git a/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java b/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java index 46a7ac83..892c267e 100644 --- a/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java +++ b/src/main/java/com/msg/fillmap/event/repository/EventLocationRepository.java @@ -1,5 +1,6 @@ package com.msg.fillmap.event.repository; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -8,6 +9,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -52,4 +54,24 @@ public interface EventLocationRepository extends JpaRepository findWithOccurrenceByOccurrenceIdIn(@Param("occurrenceIds") Collection occurrenceIds); + + /** + * 승인 산출물 위치 조회 (MSG-500 D-3) — 신청 하나가 만든 위치는 locationKey 접두 + * {@code sub-{submissionNo}-} 로 결정적으로 역산된다(위치가 여러 행이라 신청 행에 단일 FK 를 두지 않았다). + */ + List findByLocationKeyStartingWith(String locationKeyPrefix); + + /** + * 노출 중지 (MSG-500 D-3) — 접두로 잡히는 위치를 한 문장에 숨긴다. 엔티티 더티 체킹을 쓰지 않는 것은 + * {@code @DynamicUpdate} 부재로 전 컬럼 UPDATE 가 나가 같은 시각의 재시드 갱신을 되덮기 때문이다 + * (missions.hidden_at 과 같은 규칙). {@code hidden_at IS NULL} 술어가 재중지를 0행으로 만들어 첫 중지 + * 시각을 보존하고, clearAutomatically 는 뒤이은 노출 영역 재계산이 새 상태를 읽게 한다. + */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE EventLocation l SET l.hiddenAt = :now + WHERE l.locationKey LIKE CONCAT(:locationKeyPrefix, '%') AND l.hiddenAt IS NULL + """) + int hideByLocationKeyPrefix(@Param("locationKeyPrefix") String locationKeyPrefix, + @Param("now") LocalDateTime now); } diff --git a/src/main/java/com/msg/fillmap/event/repository/EventOccurrenceRepository.java b/src/main/java/com/msg/fillmap/event/repository/EventOccurrenceRepository.java index c4a76a67..65d79c6a 100644 --- a/src/main/java/com/msg/fillmap/event/repository/EventOccurrenceRepository.java +++ b/src/main/java/com/msg/fillmap/event/repository/EventOccurrenceRepository.java @@ -4,7 +4,10 @@ import java.util.List; import java.util.Optional; +import jakarta.persistence.LockModeType; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -57,4 +60,14 @@ List findBySeriesIdAndStartsAtLessThanOrderByStartsAtDescIdDesc """) List findStartNotificationCandidates( @Param("now") LocalDateTime now, @Param("windowFloor") LocalDateTime windowFloor); + + /** + * 승인·중지의 회차 직렬화 잠금 (MSG-500 D-9) — 참여형 승인이 격자 겹침을 검사하고 삽입하는 사이에 + * 다른 승인이 끼어드는 경합을 트랜잭션 순서로 막는다. 잠금이 없으면 두 사전 검사가 모두 통과하고, + * uq_event_grid_per_occ 가 지연 제약이라 지는 쪽이 커밋 시점 500 으로 끝난다(관리자에게 읽을 수 + * 없는 오류다). 중지도 같은 잠금을 같은 순서로 잡는다 — 승인과 중지가 노출 영역을 함께 고쳐서다. + * {@code EventLocationRepository.findWithLockByLocationKey} 와 같은 패턴이다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithLockById(Long id); } From efa217c8a2beb425009ebe58a46953bce4f023c9 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 27/35] =?UTF-8?q?MSG-500=20feat:=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=82=B0=EC=88=A0=EC=9D=84=20=ED=95=9C=20?= =?UTF-8?q?=EA=B3=B3=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=93=9C=EA=B0=80=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=84=20=EB=B3=B4=EC=A1=B4=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인·중지·재시드가 같은 산술에 입력만 달리 넣는다. 재시드 합집합은 참여 위치 가시 격자로 한정해 시드 축소 시 옛 격자로 부풀지 않는다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../msg/fillmap/event/seed/EventSeeder.java | 18 ++++- .../event/support/EventExposureArea.java | 72 ++++++++++++++++++ .../fillmap/event/seed/EventSeederTest.java | 74 +++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/msg/fillmap/event/support/EventExposureArea.java 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 4236c23d..9380ca11 100644 --- a/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java +++ b/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java @@ -38,6 +38,7 @@ import com.msg.fillmap.event.repository.EventNotificationSubscriptionRepository; import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.support.EventExposureArea; import com.msg.fillmap.event.repository.EventVideoRepository; import com.msg.fillmap.global.geo.AreaCell; import com.msg.fillmap.global.geo.RepresentativeGridResolver; @@ -158,10 +159,25 @@ private EventOccurrence upsertOccurrence(EventSeries series, EventSeed.Occurrenc .orElseGet(() -> new EventOccurrence(series, seed.occurrenceKey())); releaseSubscriptionsIfEnded(occurrence, now); + // 시드 사각형은 바닥값이다 (MSG-500 D-8). 관리자 승인이 넓힌 참여 위치 영역을 여기서 다시 + // 얹지 않으면, 재기동마다 영역이 시드 값으로 되돌아가 원래 범위 밖의 참여 위치가 뷰포트 필터에서 + // 빠진다 — 위치 행은 그대로 남는데 지도 칩만 사라지는, 눈에 잘 안 띄는 발견성 손실이다. + // 산술은 승인·중지와 같은 한 곳(EventExposureArea)을 쓰고, 입력만 "시드 ∪ 가시 참여 위치"다. + // 참여 위치로 한정하는 것이 계약이다 — 위치 동기화(syncGrids)가 이 뒤라, 전 가시 격자를 넣으면 + // 시드가 줄이거나 옮긴 옛 시드 격자가 아직 살아 있는 채 합집합에 들어가 영역이 부푼다. + // 신규 회차는 아직 위치가 없어 시드 사각형 그대로다. + EventExposureArea area = EventExposureArea + .of(exposure.minGridY(), exposure.maxGridY(), exposure.minGridX(), exposure.maxGridX()); + if (occurrence.getId() != null) { + area = area.union(EventExposureArea.ofGridIds( + locationGridRepository.findVisibleGridIdsByOccurrenceIdAndKeyPrefix( + occurrence.getId(), EventLocation.SUBMISSION_KEY_PREFIX))); + } + int revisionBefore = occurrence.getScheduleRevision(); occurrence.update(series, seed.title(), seed.cityName(), toUtc(seed.startsAt(), seed.occurrenceKey()), toUtc(seed.endsAt(), seed.occurrenceKey()), - exposure.minGridY(), exposure.maxGridY(), exposure.minGridX(), exposure.maxGridX()); + area.minGridY(), area.maxGridY(), area.minGridX(), area.maxGridX()); EventOccurrence saved = occurrenceRepository.save(occurrence); if (saved.getScheduleRevision() > revisionBefore) { notifyScheduleChanged(saved); diff --git a/src/main/java/com/msg/fillmap/event/support/EventExposureArea.java b/src/main/java/com/msg/fillmap/event/support/EventExposureArea.java new file mode 100644 index 00000000..16fcd5be --- /dev/null +++ b/src/main/java/com/msg/fillmap/event/support/EventExposureArea.java @@ -0,0 +1,72 @@ +package com.msg.fillmap.event.support; + +import java.util.Collection; + +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.global.geo.AreaCell; +import com.msg.fillmap.grid.GridEncoder; +import com.msg.fillmap.grid.GridEncoder.GridIndex; + +/** + * 회차 노출 영역의 경계 사각형 산술 (MSG-500 D-8·D-3). "노출 영역 = 위치 사각형들을 감싸는 범위"라는 + * 불변식을 세 경로가 같은 규칙으로 유지한다: 참여형 승인의 확장, 중지의 재계산, 그리고 재시드다. + * 세 곳이 각자 min/max 를 접으면 한 곳만 고쳐졌을 때 같은 회차가 경로마다 다른 범위를 갖는다. + * + *

세 경로의 입력은 다르고 그 차이가 곧 정책이다. + *

    + *
  • 승인: 기존 영역 ∪ 새 위치 셀 — 넓히기만 한다.
  • + *
  • 중지: 남은 가시 위치 격자만 — 숨긴 위치가 커버하던 범위를 되돌린다(시드 값 아래로도 줄어든다).
  • + *
  • 재시드: 시드 사각형 ∪ 가시 위치 격자 — 시드가 바닥값이라 중지와 달리 그 밑으로는 안 줄어든다. + * 시더는 시드 파일이 정본인 값을 쓰는 자리이고, 승인이 넓힌 몫만 얹는다.
  • + *
+ */ +public record EventExposureArea(int minGridY, int maxGridY, int minGridX, int maxGridX) { + + public static EventExposureArea of(int minGridY, int maxGridY, int minGridX, int maxGridX) { + return new EventExposureArea(minGridY, maxGridY, minGridX, maxGridX); + } + + /** 회차의 현재 영역. */ + public static EventExposureArea of(EventOccurrence occurrence) { + return new EventExposureArea(occurrence.getMinGridY(), occurrence.getMaxGridY(), + occurrence.getMinGridX(), occurrence.getMaxGridX()); + } + + /** 격자 id 집합의 경계 — 비어 있으면 null 이다(감쌀 것이 없으면 영역도 없다). */ + public static EventExposureArea ofGridIds(Collection gridIds) { + EventExposureArea area = null; + for (String gridId : gridIds) { + GridIndex index = GridEncoder.decode(gridId); + area = expand(area, (int) index.gridY(), (int) index.gridX()); + } + return area; + } + + /** 셀 집합의 경계 — 비어 있으면 null 이다. */ + public static EventExposureArea ofCells(Collection cells) { + EventExposureArea area = null; + for (AreaCell cell : cells) { + area = expand(area, cell.gridY(), cell.gridX()); + } + return area; + } + + /** 합집합의 경계 — {@code other} 가 null 이면 자기 자신이다(감쌀 것이 늘지 않았다). */ + public EventExposureArea union(EventExposureArea other) { + if (other == null) { + return this; + } + return new EventExposureArea( + Math.min(minGridY, other.minGridY()), Math.max(maxGridY, other.maxGridY()), + Math.min(minGridX, other.minGridX()), Math.max(maxGridX, other.maxGridX())); + } + + private static EventExposureArea expand(EventExposureArea area, int gridY, int gridX) { + if (area == null) { + return new EventExposureArea(gridY, gridY, gridX, gridX); + } + return new EventExposureArea( + Math.min(area.minGridY(), gridY), Math.max(area.maxGridY(), gridY), + Math.min(area.minGridX(), gridX), Math.max(area.maxGridX(), gridX)); + } +} diff --git a/src/test/java/com/msg/fillmap/event/seed/EventSeederTest.java b/src/test/java/com/msg/fillmap/event/seed/EventSeederTest.java index 3d1add11..31f0f9af 100644 --- a/src/test/java/com/msg/fillmap/event/seed/EventSeederTest.java +++ b/src/test/java/com/msg/fillmap/event/seed/EventSeederTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; @@ -28,6 +29,7 @@ import com.msg.fillmap.event.entity.EventLocation; import com.msg.fillmap.event.entity.EventLocationGrid; +import com.msg.fillmap.event.entity.EventLocationType; import com.msg.fillmap.event.entity.EventOccurrence; import com.msg.fillmap.event.repository.EventLocationGridRepository; import com.msg.fillmap.event.repository.EventLocationRepository; @@ -150,6 +152,30 @@ private Resource json(EventSeed... seeds) { return locationRepository.findByLocationKey(locationKey).orElseThrow(); } + private EventOccurrence 회차조회(String occurrenceKey) { + em.flush(); + em.clear(); + return occurrenceRepository.findByOccurrenceKey(occurrenceKey).orElseThrow(); + } + + /** + * 관리자 승인(MSG-500 D-8)이 만드는 참여 위치를 흉내 낸다 — 시더 재실행이 그 영역을 지우지 않는지가 + * 검증 대상이라, 승인 서비스를 통째로 태우지 않고 산출물 형태(sub- 접두 자연키 + 격자 1칸)만 심는다. + */ + private void 참여_위치를_심는다(EventOccurrence occurrence, String locationKey, String gridId, + LocalDateTime hiddenAt) { + EventLocation location = EventLocation.forSubmission(occurrence, locationKey, "참여 부스", + EventLocationType.ETC, 90, gridId, "필맵 파트너스", "참여 부스를 운영합니다", + LocalDate.now(), LocalDate.now().plusDays(3), "현장 스탬프", null); + if (hiddenAt != null) { + ReflectionTestUtils.setField(location, "hiddenAt", hiddenAt); + } + locationRepository.save(location); + locationGridRepository.save(new EventLocationGrid(location.getId(), occurrence.getId(), gridId)); + em.flush(); + em.clear(); + } + private List 격자조회(String locationKey) { return locationGridRepository.findByIdEventLocationId(위치조회(locationKey).getId()).stream() .map(EventLocationGrid::getGridId) @@ -294,6 +320,54 @@ class Idempotency { assertThatCode(EventSeederTest.this::지연제약검증).doesNotThrowAnyException(); } + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인이 넓힌 노출 영역은 재시드에도 유지된다 — 시드 사각형은 바닥값이다") + void 승인이_넓힌_노출_영역은_재시드에도_유지된다() { + seeder().seed(json(시리즈("msg500-expand", 회차("msg500-expand-2026", + 위치("msg500-expand-loc", 사각형(Y, Y, X, X)))))); + EventOccurrence 회차 = 회차조회("msg500-expand-2026"); + assertThat(회차.getMaxGridX()).isEqualTo(X + 100); + + // 관리자 승인이 만드는 참여 위치 — 시드 사각형 밖의 칸을 차지하고 회차 영역을 넓힌다. + 참여_위치를_심는다(회차, "sub-FM-2026-9001-1", (Y + 500) + "_" + (X + 500), null); + // 중지된 참여 위치는 영역을 넓히지 않는다 — 재계산 입력이 가시 격자라서다. + 참여_위치를_심는다(회차, "sub-FM-2026-9002-1", (Y + 900) + "_" + (X + 900), + LocalDateTime.now(ZoneOffset.UTC)); + + // 재기동 시뮬레이션 — 같은 시드 문서로 다시 돌린다. + seeder().seed(json(시리즈("msg500-expand", 회차("msg500-expand-2026", + 위치("msg500-expand-loc", 사각형(Y, Y, X, X)))))); + + EventOccurrence 재시드후 = 회차조회("msg500-expand-2026"); + // 시드 값으로 되돌아가면 원래 영역 밖의 참여 위치가 뷰포트 필터에서 빠져 칩이 사라진다. + assertThat(재시드후.getMaxGridY()).isEqualTo(Y + 500); + assertThat(재시드후.getMaxGridX()).isEqualTo(X + 500); + // 시드 사각형이 바닥값이라 그 아래로는 줄지 않는다(중지 경로와 다른 점). + assertThat(재시드후.getMinGridY()).isEqualTo(Y - 100); + assertThat(재시드후.getMinGridX()).isEqualTo(X - 100); + // 일정이 그대로라 개정 번호는 오르지 않는다 — 영역 변화는 알릴 일정 변경이 아니다. + assertThat(재시드후.getScheduleRevision()).isEqualTo(회차.getScheduleRevision()); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("시드 위치가 줄어든 재시드는 영역을 부풀리지 않는다 — 확장 입력은 참여 위치뿐이다") + void 시드_위치가_줄어든_재시드는_영역을_부풀리지_않는다() { + // 시드 위치가 노출 사각형 밖(+300)을 덮다가 안(+0)으로 줄어든다. 시더는 회차를 먼저 갱신하고 + // 위치를 나중에 동기화하므로, 확장 입력이 전 가시 격자였다면 아직 살아 있는 옛 격자(+300)가 + // 합집합에 들어가 영역이 부푼 채 다음 재기동까지 남는다. + seeder().seed(json(시리즈("msg500-shrinkseed", 회차("msg500-shrinkseed-2026", + 위치("msg500-shrinkseed-loc", 사각형(Y + 300, Y + 300, X + 300, X + 300)))))); + + seeder().seed(json(시리즈("msg500-shrinkseed", 회차("msg500-shrinkseed-2026", + 위치("msg500-shrinkseed-loc", 사각형(Y, Y, X, X)))))); + + EventOccurrence 재시드후 = 회차조회("msg500-shrinkseed-2026"); + assertThat(재시드후.getMaxGridY()).isEqualTo(Y + 100); + assertThat(재시드후.getMaxGridX()).isEqualTo(X + 100); + } + @Test @DisplayName("격자를 다른 위치로 옮기는 재시드가 한 트랜잭션에서 성공한다 (지연 UNIQUE, flush 순서 무관)") void 격자를_다른_위치로_옮기는_재시드가_한_트랜잭션에서_성공한다() { From 80061d479afbca0cec3c311454bfa60a3946614e Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 28/35] =?UTF-8?q?MSG-500=20feat:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=ED=98=95=20=EC=8B=A0=EC=B2=AD=EC=9D=84=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EB=B6=80=EB=AA=A8=20=ED=9A=8C=EC=B0=A8=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=ED=96=89=EC=82=AC=20=EC=9C=84=EC=B9=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EA=B0=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회차 잠금 아래 두 방향 겹침 검사 후 위치·참여 속성·커버 이미지를 반영하고 노출 영역을 넓힌다. 심사 상세에 참여 방식과 부모 이벤트를 싣는다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- ...AdminEventSubmissionDetailResponseDto.java | 15 +- .../service/AdminEventSubmissionService.java | 192 ++++++++++++++++-- .../service/EventSubmissionImageStore.java | 18 +- 3 files changed, 207 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java index a9d2b47f..b1d056f4 100644 --- a/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java +++ b/src/main/java/com/msg/fillmap/event/submission/dto/AdminEventSubmissionDetailResponseDto.java @@ -11,11 +11,16 @@ * 더한다 — 신청 계정 정보(누가 냈나), 노출 영역 사각형(어디에 깔리나), 상태 이력이다. *

* 존재 은닉이 없다: 없는 id 는 그대로 404 다(관리자는 전체를 보는 주체라 숨길 것이 없다). + *

+ * 참여형 재료 둘({@code participationMethod}·{@code parentEvent})은 EVENT 유형에만 값이 있다 — 승인이 + * 부모 회차 아래 위치를 만드는 경로라(D-8) 어느 이벤트에 무슨 방식으로 실리는지를 보지 못하면 + * 관리자가 대상을 모르는 채 승인하게 된다. 부모 표현은 행사 운영자 상세(MSG-502)와 같은 타입을 + * 쓴다: 두 화면이 같은 회차를 다른 이름으로 부르면 심사 대화가 어긋난다. */ @Schema(description = "관리자 심사 상세", requiredProperties = {"id", "submissionNo", "type", "status", "title", "organizerName", "startsOn", "endsOn", - "operatingHours", "programDescription", "description", "imageUrl", "orgName", "contactName", "email", - "locations", "exposureRect", "history", "createdAt", "updatedAt"}) + "operatingHours", "programDescription", "participationMethod", "parentEvent", "description", "imageUrl", + "orgName", "contactName", "email", "locations", "exposureRect", "history", "createdAt", "updatedAt"}) public record AdminEventSubmissionDetailResponseDto( @Schema(description = "신청 id", example = "7") Long id, @@ -47,6 +52,12 @@ public record AdminEventSubmissionDetailResponseDto( @Schema(description = "주요 프로그램 — FESTIVAL 만 값이 있다", nullable = true) String programDescription, + @Schema(description = "참여 방식 — EVENT(참여형)만 값이 있다", nullable = true) + String participationMethod, + + @Schema(description = "참여할 부모 이벤트 회차 — EVENT(참여형)만 값이 있다", nullable = true) + EventSubmissionParentEventResponseDto parentEvent, + @Schema(description = "행사 소개") String description, diff --git a/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java b/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java index 3cf689e5..4dccf0d2 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/AdminEventSubmissionService.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; @@ -14,12 +15,21 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventLocationGrid; +import com.msg.fillmap.event.entity.EventLocationType; +import com.msg.fillmap.event.entity.EventOccurrence; import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.support.EventExposureArea; import com.msg.fillmap.event.submission.dto.AdminEventSubmissionDetailResponseDto; import com.msg.fillmap.event.submission.dto.AdminEventSubmissionListResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionAreaRectDto; import com.msg.fillmap.event.submission.dto.EventSubmissionHistoryResponseDto; +import com.msg.fillmap.event.submission.dto.EventSubmissionParentEventResponseDto; import com.msg.fillmap.event.submission.dto.EventSubmissionRejectRequestDto; import com.msg.fillmap.event.submission.entity.EventSubmission; import com.msg.fillmap.event.submission.entity.EventSubmissionAreaRect; @@ -55,6 +65,9 @@ public class AdminEventSubmissionService { private static final int MIN_PAGE_SIZE = 1; private static final int MAX_PAGE_SIZE = 100; + /** event_locations.name 컬럼 길이 — 순번 접미사를 붙여도 넘지 않게 제목을 먼저 자르는 기준이다 (D-8). */ + private static final int MAX_LOCATION_NAME_LENGTH = 100; + /** 승인 번호의 연도 라벨과 기간 판정의 "오늘"은 사용자·관리자가 읽는 값이라 KST 다 (D-4, 접수 선례). */ private static final ZoneId KST = ZoneId.of("Asia/Seoul"); @@ -64,6 +77,9 @@ public class AdminEventSubmissionService { private final EventSubmissionLocationView locationView; private final UserRepository userRepository; private final MissionRegistrationService missionRegistrationService; + private final EventOccurrenceRepository occurrenceRepository; + private final EventLocationRepository locationRepository; + private final EventLocationGridRepository locationGridRepository; private final Clock clock; /** 프로덕션 생성자 — clock 을 systemUTC 로 고정해 전체 생성자로 위임한다 (EventSubmissionServiceImpl 선례). */ @@ -71,21 +87,28 @@ public class AdminEventSubmissionService { public AdminEventSubmissionService(EventSubmissionRepository submissionRepository, EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, EventSubmissionLocationView locationView, UserRepository userRepository, - MissionRegistrationService missionRegistrationService) { + MissionRegistrationService missionRegistrationService, EventOccurrenceRepository occurrenceRepository, + EventLocationRepository locationRepository, EventLocationGridRepository locationGridRepository) { this(submissionRepository, historyRepository, imageStore, locationView, userRepository, - missionRegistrationService, Clock.systemUTC()); + missionRegistrationService, occurrenceRepository, locationRepository, locationGridRepository, + Clock.systemUTC()); } public AdminEventSubmissionService(EventSubmissionRepository submissionRepository, EventSubmissionStatusHistoryRepository historyRepository, EventSubmissionImageStore imageStore, EventSubmissionLocationView locationView, UserRepository userRepository, - MissionRegistrationService missionRegistrationService, Clock clock) { + MissionRegistrationService missionRegistrationService, EventOccurrenceRepository occurrenceRepository, + EventLocationRepository locationRepository, EventLocationGridRepository locationGridRepository, + Clock clock) { this.submissionRepository = submissionRepository; this.historyRepository = historyRepository; this.imageStore = imageStore; this.locationView = locationView; this.userRepository = userRepository; this.missionRegistrationService = missionRegistrationService; + this.occurrenceRepository = occurrenceRepository; + this.locationRepository = locationRepository; + this.locationGridRepository = locationGridRepository; this.clock = clock; } @@ -133,6 +156,8 @@ public AdminEventSubmissionDetailResponseDto getSubmission(Long submissionId) { submission.getEndsOn(), submission.getOperatingHours(), submission.getProgramDescription(), + submission.getParticipationMethod(), + toParentEvent(submission), submission.getDescription(), imageStore.presignGet(submission.getImageKey()), applicant.getOrgName(), @@ -174,7 +199,11 @@ public EventSubmissionApproveResponseDto approve(Long submissionId) { EventSubmission approved = submissionRepository.findWithLocationsById(submissionId) .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); - approved.linkPublishedMission(publish(approved)); + // 참여형은 산출물이 여러 위치라 실을 단일 링크가 없다 — 그 경우 publish 가 null 을 준다. + Long missionId = publish(approved); + if (missionId != null) { + approved.linkPublishedMission(missionId); + } return new EventSubmissionApproveResponseDto(submissionId, approvalNo, EventSubmissionStatus.APPROVED.name()); } @@ -197,19 +226,28 @@ public void reject(Long submissionId, EventSubmissionRejectRequestDto request) { } /** - * 승인 산출물 (D-2) — 지역축제·팝업스토어는 미션 1건이 된다. 위치가 여럿이어도 칩 카드는 하나가 - * 자연스럽다. + * 승인 산출물 (D-1 유형별 분기) — 지역축제·팝업스토어는 미션 1건이 되고(위치가 여럿이어도 칩 + * 카드는 하나가 자연스럽다), 이벤트 참여형은 부모 회차 아래 행사 위치가 된다. *

- * switch 에 default 를 두지 않는 것은 의도된 미구현 가드다: MSG-502 가 참여형 유형 값을 추가하는 - * 순간 이 분기가 컴파일되지 않아, 참여형 승인이 채워지지 않은 채 미션으로 잘못 흘러가는 경로가 만들어질 - * 수 없다. 지금은 V49 CHECK 가 두 값만 허용해 도달 자체가 불가능하다. + * switch 에 default 를 두지 않는다 — 유형이 또 늘면 여기서 컴파일이 깨져 새 유형이 아무 산출물 없이 + * 승인되는 경로가 만들어지지 않는다(MSG-502 참여형 값 추가 때 실제로 이 가드가 발화했다). + * + * @return 미션 경로면 미션 id, 참여형이면 null (산출물이 여러 위치라 신청 행에 실을 단일 링크가 없다) */ - private long publish(EventSubmission submission) { - MissionType missionType = switch (submission.getType()) { - case FESTIVAL -> MissionType.EVENT; // 지도 홈 "축제" 칩 — 행사 카테고리 "이벤트"와 무관하다 - case POPUP -> MissionType.POPUP; + private Long publish(EventSubmission submission) { + return switch (submission.getType()) { + // 지도 홈 "축제" 칩 — 행사 카테고리 "이벤트"와 무관하다. + case FESTIVAL -> registerMission(submission, MissionType.EVENT); + case POPUP -> registerMission(submission, MissionType.POPUP); + case EVENT -> { + publishParticipation(submission); + yield null; + } }; - // 운영 시간은 팝업만 값을 갖는다 — 접수 검증이 축제의 운영 시간을 애초에 거부하므로 분기가 필요 없다. + } + + /** 미션 등재 (D-2). 운영 시간은 팝업만 값을 갖는다 — 접수 검증이 축제의 운영 시간을 애초에 거부한다. */ + private long registerMission(EventSubmission submission, MissionType missionType) { return missionRegistrationService.register(new MissionRegistration( missionType, submission.getTitle(), @@ -222,6 +260,118 @@ private long publish(EventSubmission submission) { EventSubmissionCells.union(submission.getLocations()).stream().map(AreaCell::gridId).toList())); } + /** + * 이벤트 참여형 → 부모 회차 아래 행사 위치 (D-8·D-9). 순서가 계약이다. + *

+ * ① 부모 회차를 비관 잠금으로 잡아 같은 회차의 승인을 직렬화한다 — 잠금이 없으면 서로 다른 두 + * 신청의 겹침 사전 검사가 모두 통과하고, 회차 내 격자 단일 귀속이 지연 제약이라 지는 쪽이 커밋 시점 + * 500 이 된다. ② 끝난 회차는 반영할 노출이 없으므로 13451 이다. ③ 겹침을 두 방향으로 검사한다: + * 회차의 기존 격자와, 이번 신청의 위치들끼리. 후자가 필요한 이유는 접수 검증이 위치를 독립적으로 봐서 + * 위치 간 겹침이 심사까지 통과해 오기 때문이다. 어느 쪽이든 13452 이고 관리자의 다음 조작은 AREA 반려다. + * ④ 위치를 전개하며 참여 속성을 복사하고 ⑤ 노출 영역을 합집합으로 넓힌다. + *

+ * 커버 이미지 공개 사본은 신청당 한 번 복사해 위치들이 공유한다. + */ + private void publishParticipation(EventSubmission submission) { + EventOccurrence occurrence = occurrenceRepository.findWithLockById(submission.getParentEventOccurrenceId()) + .orElseThrow(() -> new ApiException(EventErrorCode.PARENT_EVENT_NOT_FOUND)); + if (!occurrence.getEndsAt().isAfter(LocalDateTime.now(clock))) { + throw new ApiException(EventErrorCode.SUBMISSION_PERIOD_PASSED); + } + + List locations = submission.getLocations(); + Set newCells = requireNoGridConflict(occurrence, locations); + String imageKey = imageStore.copyToLocationImage(submission.getImageKey()); + int displayOrder = nextDisplayOrder(occurrence.getId()); + + for (EventSubmissionLocation location : locations) { + EventLocation created = locationRepository.save(EventLocation.forSubmission( + occurrence, + "%s%s-%d".formatted(EventLocation.SUBMISSION_KEY_PREFIX, submission.getSubmissionNo(), + location.getDisplayOrder()), + locationName(submission, locations.size(), location.getDisplayOrder()), + // 참여 방식 문자열은 위치 유형이 아니다 — 넷에 안 걸리는 위치의 도피처가 ETC 다. + EventLocationType.ETC, + displayOrder++, + // 접수 때 계산해 저장한 위치별 대표 격자를 그대로 쓴다 (FR-9) — 셀 집합 소속이 이미 + // 성립해 지연 FK(fk_event_loc_rep_grid)를 통과한다. + location.getRepresentativeGridId(), + submission.getOrganizerName(), + submission.getDescription(), + submission.getStartsOn(), + submission.getEndsOn(), + submission.getParticipationMethod(), + imageKey)); + locationGridRepository.saveAll(EventSubmissionCells.of(location).stream() + .map(cell -> new EventLocationGrid(created.getId(), occurrence.getId(), cell.gridId())) + .toList()); + } + expandExposure(occurrence, newCells); + } + + /** + * 위치 이름 (D-8) — 신청 위치에는 이름이 없어서(접수 정책 #102) 신청 제목을 쓰고, 여럿이면 순번을 + * 붙인다. name·title 이 둘 다 VARCHAR(100) 이라 접미사 길이만큼 제목을 먼저 잘라낸다 — 100자 + * 제목이 그대로 들어오면 접미사를 붙이는 순간 길이 초과로 승인이 DB 오류로 죽는다. + *

+ * 순번 분기는 현재 입력으로는 도달하지 않는다 — PRD v2.2 FR-8 이 참여형 신청의 위치를 대표 + * 위치 한 곳으로 제한하고 접수가 2곳 이상을 13439 로 막는다. 그래도 남겨 두는 것은 이 메서드가 위치 + * N개를 전제로 쓰인 D-8 규칙의 구현이고, 제한이 풀리는 날 이름이 조용히 겹치거나 길이로 죽는 것보다 + * 낫기 때문이다. 검증은 접수를 우회한 SQL 시드로 들어온다 + * ({@code AdminEventParticipationApprovalTest} 의 두 위치 케이스). + */ + private String locationName(EventSubmission submission, int locationCount, int displayOrder) { + String title = submission.getTitle(); + if (locationCount == 1) { + return title; + } + String suffix = " " + displayOrder; + int room = MAX_LOCATION_NAME_LENGTH - suffix.length(); + return (title.length() <= room ? title : title.substring(0, room)) + suffix; + } + + /** + * 겹침 사전 검사 (D-9) — 회차 기존 격자와의 겹침, 그리고 이번 신청 위치들 상호 겹침. 삽입 전에 + * 13452 로 거부하는 것은 지연 제약 위반이 커밋 시점 DataIntegrityViolation 으로 터져 관리자에게 + * 읽을 수 없는 500 이 되기 때문이다(제약은 그대로 백스톱으로 남는다). + * + * @return 이번 신청이 새로 차지하는 격자 집합 (노출 영역 확장의 입력) + */ + private Set requireNoGridConflict(EventOccurrence occurrence, + List locations) { + Set existing = Set.copyOf(locationGridRepository.findGridIdsByOccurrenceId(occurrence.getId())); + Set claimed = new LinkedHashSet<>(); + for (EventSubmissionLocation location : locations) { + for (AreaCell cell : EventSubmissionCells.of(location)) { + if (existing.contains(cell.gridId()) || !claimed.add(cell)) { + throw new ApiException(EventErrorCode.SUBMISSION_GRID_CONFLICT); + } + } + } + return claimed; + } + + /** 부모 회차의 다음 표시 순번 — 기존 위치 뒤에 이어 붙인다(기존 위치의 순번은 건드리지 않는다). */ + private int nextDisplayOrder(Long occurrenceId) { + return locationRepository.findByOccurrenceIdOrderByDisplayOrderAscIdAsc(occurrenceId).stream() + .mapToInt(EventLocation::getDisplayOrder) + .max() + .orElse(0) + 1; + } + + /** + * 노출 영역 확장 (D-8) — "노출 영역 = 위치 사각형들을 감싸는 범위"라는 기존 불변식을 유지한다. + * {@code EventOccurrence.update} 를 그대로 쓰는 것이 계약이다: 시각을 바꾸지 않으므로 + * scheduleRevision 이 오르지 않아 일정 변경 알림이 나가지 않는다(영역이 넓어진 것은 구독자에게 + * 알릴 일정 변경이 아니다). + */ + private void expandExposure(EventOccurrence occurrence, Set newCells) { + EventExposureArea area = EventExposureArea.of(occurrence).union(EventExposureArea.ofCells(newCells)); + occurrence.update(occurrence.getSeries(), occurrence.getTitle(), occurrence.getCityName(), + occurrence.getStartsAt(), occurrence.getEndsAt(), + area.minGridY(), area.maxGridY(), area.minGridX(), area.maxGridX()); + } + /** 심사 대상 조회 — 없으면 13430, 심사 중이 아니면 13450 (전이 전 빠른 거절). */ private EventSubmission findForReview(Long submissionId) { EventSubmission submission = submissionRepository.findWithLocationsById(submissionId) @@ -280,6 +430,20 @@ private String nextApprovalNo() { LocalDate.now(clock.withZone(KST)).getYear(), submissionRepository.nextApprovalSequence()); } + /** + * 부모 이벤트 회차 요약 (§API 2) — 참여형이 아니면 null 이다. 행사 운영자 상세(MSG-502)와 같은 타입·같은 + * 원천({@code event_occurrences.title})을 쓴다. 회차가 사라진 신청(부모 삭제)은 null 로 두고 승인 시점에 + * 13440 으로 갈린다 — 조회가 500 으로 죽는 것보다 심사 화면이 열리는 편이 낫다. + */ + private EventSubmissionParentEventResponseDto toParentEvent(EventSubmission submission) { + if (submission.getParentEventOccurrenceId() == null) { + return null; + } + return occurrenceRepository.findById(submission.getParentEventOccurrenceId()) + .map(parent -> new EventSubmissionParentEventResponseDto(parent.getId(), parent.getTitle())) + .orElse(null); + } + /** * 노출 영역 (§API 2) — 전 위치 사각형을 감싸는 경계 사각형이다. 셀을 전개하지 않고 사각형의 min·max 만 * 접는 것으로 충분하다: 합집합의 경계는 원본 사각형들의 경계와 같다. 조회 시점 계산이라 저장하지 않는다. 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 index 0e77674b..f492c31f 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/EventSubmissionImageStore.java @@ -62,8 +62,9 @@ public class EventSubmissionImageStore { private static final String PENDING_PREFIX = "event-submissions/pending/"; private static final String ORIGINAL_PREFIX = "event-submissions/original/"; - /** 승인 미션 카드가 공개 URL 로 읽는 자리 (MSG-500 D-6). 참여형 위치용 프리픽스는 그 분기와 함께 온다. */ + /** 승인 산출물이 공개 URL 로 읽는 자리 (MSG-500 D-6) — 미션 카드와 참여형 위치가 각자 프리픽스를 쓴다. */ private static final String MISSION_PUBLIC_PREFIX = "missions/org-submission/"; + private static final String LOCATION_PUBLIC_PREFIX = "event-locations/org-submission/"; private final S3Presigner s3Presigner; private final S3Client s3Client; @@ -143,7 +144,20 @@ public String confirm(Long userId, String pendingKey) { * 트랜잭션이 뒤에서 실패하면 아무도 참조하지 않는 사본이 남는다. */ public String copyToMissionImage(String originalKey) { - String publicKey = "%s%s.%s".formatted(MISSION_PUBLIC_PREFIX, UUID.randomUUID(), extensionOf(originalKey)); + return copyToPublic(originalKey, MISSION_PUBLIC_PREFIX); + } + + /** + * 참여형 승인 위치의 공개 사본 (MSG-500 D-6). 미션 사본과 프리픽스만 다르다 — 소비처가 미션 카드가 + * 아니라 회차 상세의 위치 목록이라 저장하는 것도 주소가 아니라 다(조립은 조회 시점). + * 신청 하나에 사본 하나다 — 위치가 여럿이어도 같은 키를 공유한다(위치별 재복사는 같은 바이트의 중복). + */ + public String copyToLocationImage(String originalKey) { + return copyToPublic(originalKey, LOCATION_PUBLIC_PREFIX); + } + + private String copyToPublic(String originalKey, String publicPrefix) { + String publicKey = "%s%s.%s".formatted(publicPrefix, UUID.randomUUID(), extensionOf(originalKey)); deleteOnRollback(publicKey); s3Client.copyObject(CopyObjectRequest.builder() .sourceBucket(awsProperties.s3().bucket()) From 74c0db9cf410decb6b359da4d1903729efce042f Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 29/35] =?UTF-8?q?MSG-500=20feat:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=ED=98=95=20=ED=96=89=EC=82=AC=20=EC=A4=91=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EB=A5=BC=20=EC=88=A8=EA=B8=B0=EA=B3=A0=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EC=98=81=EC=97=AD=EC=9D=84=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../service/AdminApprovedEventService.java | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java b/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java index 6d3ffe7d..68a47a8c 100644 --- a/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java +++ b/src/main/java/com/msg/fillmap/event/submission/service/AdminApprovedEventService.java @@ -14,12 +14,19 @@ import lombok.extern.slf4j.Slf4j; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventOccurrence; import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.support.EventExposureArea; import com.msg.fillmap.event.submission.dto.AdminApprovedEventListResponseDto; import com.msg.fillmap.event.submission.dto.AdminEventUnpublishRequestDto; import com.msg.fillmap.event.submission.dto.AdminEventUnpublishResponseDto; import com.msg.fillmap.event.submission.entity.EventSubmission; import com.msg.fillmap.event.submission.entity.EventSubmissionStatus; +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.global.mail.MailSender; @@ -59,6 +66,9 @@ public class AdminApprovedEventService { private final EventSubmissionRepository submissionRepository; private final MissionRegistrationService missionRegistrationService; + private final EventOccurrenceRepository occurrenceRepository; + private final EventLocationRepository locationRepository; + private final EventLocationGridRepository locationGridRepository; private final UserRepository userRepository; private final MailSender mailSender; private final TransactionTemplate transactionTemplate; @@ -68,19 +78,26 @@ public class AdminApprovedEventService { @Autowired public AdminApprovedEventService(EventSubmissionRepository submissionRepository, MissionRegistrationService missionRegistrationService, UserRepository userRepository, - MailSender mailSender, TransactionTemplate transactionTemplate) { + MailSender mailSender, TransactionTemplate transactionTemplate, + EventOccurrenceRepository occurrenceRepository, EventLocationRepository locationRepository, + EventLocationGridRepository locationGridRepository) { this(submissionRepository, missionRegistrationService, userRepository, mailSender, transactionTemplate, - Clock.systemUTC()); + occurrenceRepository, locationRepository, locationGridRepository, Clock.systemUTC()); } public AdminApprovedEventService(EventSubmissionRepository submissionRepository, MissionRegistrationService missionRegistrationService, UserRepository userRepository, - MailSender mailSender, TransactionTemplate transactionTemplate, Clock clock) { + MailSender mailSender, TransactionTemplate transactionTemplate, + EventOccurrenceRepository occurrenceRepository, EventLocationRepository locationRepository, + EventLocationGridRepository locationGridRepository, Clock clock) { this.submissionRepository = submissionRepository; this.missionRegistrationService = missionRegistrationService; this.userRepository = userRepository; this.mailSender = mailSender; this.transactionTemplate = transactionTemplate; + this.occurrenceRepository = occurrenceRepository; + this.locationRepository = locationRepository; + this.locationGridRepository = locationGridRepository; this.clock = clock; } @@ -123,6 +140,8 @@ public AdminEventUnpublishResponseDto unpublish(Long submissionId, AdminEventUnp .orElseThrow(() -> new ApiException(EventErrorCode.SUBMISSION_NOT_FOUND)); if (submission.getPublishedMissionId() != null) { missionRegistrationService.hide(submission.getPublishedMissionId(), now); + } else if (submission.getType() == EventSubmissionType.EVENT) { + hideParticipationLocations(submission, now); } String email = userRepository.findById(submission.getUserId()) .orElseThrow(() -> new ApiException(UserErrorCode.USER_NOT_FOUND)) @@ -133,6 +152,42 @@ public AdminEventUnpublishResponseDto unpublish(Long submissionId, AdminEventUnp notify(unpublished, request.reason())); } + /** + * 참여형 산출물 숨김 (D-3) — 승인이 만든 위치를 숨기고 부모 회차의 노출 영역을 남은 가시 위치 + * 기준으로 재계산한다. 승인이 넓힌 영역을 되돌리지 않으면 "노출 영역 = 위치 사각형들을 감싸는 범위" + * 불변식이 깨지고, 숨긴 위치만 커버하던 뷰포트에서 부모 행사가 계속 노출된다. + *

+ * 회차 잠금을 먼저 잡는 것이 계약이다 — 승인(D-9)과 같은 순서라야 두 경로가 동시에 돌아도 + * 데드락이 없다. 잠금 뒤에 위치를 숨기고, 벌크 UPDATE 가 영속성 컨텍스트를 비우므로 회차를 다시 읽어 + * 영역을 고친다(잠금은 트랜잭션 것이라 재조회에 추가 대기가 없다). 시각을 바꾸지 않으므로 + * scheduleRevision 은 오르지 않는다 — 영역 축소는 구독자에게 알릴 일정 변경이 아니다. + *

+ * 가시 위치가 하나도 남지 않으면 영역을 그대로 둔다. 회차의 사각형 컬럼이 NOT NULL 이라 비울 수 + * 없고, 위치가 없는 회차는 어차피 그릴 것이 없어 영역 값이 관찰되지 않는다. + */ + private void hideParticipationLocations(EventSubmission submission, LocalDateTime now) { + Long occurrenceId = submission.getParentEventOccurrenceId(); + occurrenceRepository.findWithLockById(occurrenceId) + .orElseThrow(() -> new ApiException(EventErrorCode.PARENT_EVENT_NOT_FOUND)); + locationRepository.hideByLocationKeyPrefix( + "%s%s-".formatted(EventLocation.SUBMISSION_KEY_PREFIX, submission.getSubmissionNo()), now); + + // 여기서는 가시 위치를 본다 — 시더의 접두 한정 조회를 쓰지 않는 것이 의도다. 시더는 시드 + // 사각형을 바닥값으로 깔 수 있지만 런타임에는 그 값을 알 방법이 없어(시드 파일에만 있다), 참여 위치만 + // 세면 시드 위치가 커버하던 범위까지 사라진다. 전 가시 위치면 시드 콘텐츠는 그대로 남고 잃는 것은 + // 작도된 여백뿐이며, 그 여백은 다음 재기동의 시드 합집합이 되돌린다(자기 치유). + EventExposureArea area = EventExposureArea.ofGridIds( + locationGridRepository.findVisibleGridIdsByOccurrenceId(occurrenceId)); + if (area == null) { + return; + } + EventOccurrence occurrence = occurrenceRepository.findWithLockById(occurrenceId) + .orElseThrow(() -> new ApiException(EventErrorCode.PARENT_EVENT_NOT_FOUND)); + occurrence.update(occurrence.getSeries(), occurrence.getTitle(), occurrence.getCityName(), + occurrence.getStartsAt(), occurrence.getEndsAt(), + area.minGridY(), area.maxGridY(), area.minGridX(), area.maxGridX()); + } + /** * 사유 통지 — 실패는 삼키고 false 로 알린다 (MSG-499 발급 발송 선례). 중지는 이미 커밋됐고, 저장된 사유가 * 수기 재통지의 재료라 발송 실패가 중지를 되돌릴 이유가 되지 않는다. 재발송 API 는 만들지 않는다. From e589de01d80d0f33f238466a5822df9f96c30d15 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 30/35] =?UTF-8?q?MSG-500=20feat:=20=EC=88=A8=EA=B8=B4=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=C2=B7=EC=98=81=EC=83=81=C2=B7=EB=B0=98?= =?UTF-8?q?=EC=9D=91=20=EC=A0=84=20=EA=B2=BD=EB=A1=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A7=89=EA=B3=A0=20=EC=B0=B8=EC=97=AC=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=EC=B9=98=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=8B=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../event/dto/EventLocationResponseDto.java | 28 +++++++++++++++++-- .../event/service/EventQueryServiceImpl.java | 28 ++++++++++++++++--- .../EventVideoInteractionServiceImpl.java | 4 +++ .../event/service/EventVideoServiceImpl.java | 8 ++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/msg/fillmap/event/dto/EventLocationResponseDto.java b/src/main/java/com/msg/fillmap/event/dto/EventLocationResponseDto.java index 1eac8f7e..7dce3dd8 100644 --- a/src/main/java/com/msg/fillmap/event/dto/EventLocationResponseDto.java +++ b/src/main/java/com/msg/fillmap/event/dto/EventLocationResponseDto.java @@ -1,5 +1,6 @@ package com.msg.fillmap.event.dto; +import java.time.LocalDate; import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; @@ -8,10 +9,15 @@ * 행사 위치 하나 (MSG-439 API 3). gridIds 는 FE 영역 채색 재료고, 영상은 그중 representativeGridId * 하나에만 붙는다 (FR-EVENT-08). 표시명 재료 세 필드는 대표 격자 기준이며 조립 규칙은 격자 응답 9종과 같다: * {@code zoneName ? zoneName + " " + zoneCell : regionName} (MSG-341·349). + *

+ * 참여 속성 5종(MSG-500 D-8)은 이벤트 참여형 승인분에만 값이 있다 — 시드 위치는 전부 null 이라 + * 기존 화면은 필드가 는 것 외에 달라지지 않는다. {@code participationStartsOn}·{@code participationEndsOn} + * 은 표기 정보이지 노출 창이 아니다(위치 노출은 부모 회차 생명주기와 중지 여부가 지배한다). */ @Schema(description = "행사 위치 하나 — 영역 격자·대표 격자·표시명 재료·영상 수", requiredProperties = {"locationId", "name", "type", "operatingHours", "gridIds", "representativeGridId", - "zoneName", "zoneCell", "regionName", "videoCount"}) + "zoneName", "zoneCell", "regionName", "videoCount", "organizerName", "description", + "participationStartsOn", "participationEndsOn", "participationMethod", "imageUrl"}) public record EventLocationResponseDto( @Schema(description = "행사 위치 id — 위치별 영상 피드 진입 키", example = "31") Long locationId, @@ -43,6 +49,24 @@ public record EventLocationResponseDto( String regionName, @Schema(description = "이 위치의 영상 수 — 조회 시점 실측(전역 노출 게이트 통과분)", example = "7") - Long videoCount + Long videoCount, + + @Schema(description = "운영 주체 — 참여형 승인분만 값이 있다", example = "필맵 주식회사", nullable = true) + String organizerName, + + @Schema(description = "참여 소개 — 참여형 승인분만 값이 있다", nullable = true) + String description, + + @Schema(description = "공개 시작일 (표기 정보, 노출 창 아님)", example = "2026-11-07", nullable = true) + LocalDate participationStartsOn, + + @Schema(description = "공개 종료일 (표기 정보, 노출 창 아님)", example = "2026-11-09", nullable = true) + LocalDate participationEndsOn, + + @Schema(description = "참여 방식 서술 — 참여형 승인분만 값이 있다", nullable = true) + String participationMethod, + + @Schema(description = "커버 이미지 공개 URL — 참여형 승인분만 값이 있다", nullable = true) + String imageUrl ) { } diff --git a/src/main/java/com/msg/fillmap/event/service/EventQueryServiceImpl.java b/src/main/java/com/msg/fillmap/event/service/EventQueryServiceImpl.java index 1aaacd7e..7a6c84ca 100644 --- a/src/main/java/com/msg/fillmap/event/service/EventQueryServiceImpl.java +++ b/src/main/java/com/msg/fillmap/event/service/EventQueryServiceImpl.java @@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import com.msg.fillmap.event.dto.EventLocationResponseDto; + import com.msg.fillmap.event.dto.EventOccurrenceChipResponseDto; import com.msg.fillmap.event.dto.EventOccurrenceDetailResponseDto; import com.msg.fillmap.event.dto.EventOccurrenceDetailResponseDto.PreviousOccurrenceDto; @@ -33,6 +34,7 @@ import com.msg.fillmap.event.repository.EventLocationVideoCount; import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; import com.msg.fillmap.grid.GridEncoder; import com.msg.fillmap.grid.GridEncoder.GridIndex; @@ -100,6 +102,7 @@ public class EventQueryServiceImpl implements EventQueryService { private final GridQueryService gridQueryService; private final ZoneNameQueryService zoneNameQueryService; private final EventNotificationService eventNotificationService; + private final AwsProperties awsProperties; private final Clock clock; /** @@ -115,10 +118,11 @@ public EventQueryServiceImpl( EventVideoRepository eventVideoRepository, GridQueryService gridQueryService, ZoneNameQueryService zoneNameQueryService, - EventNotificationService eventNotificationService + EventNotificationService eventNotificationService, + AwsProperties awsProperties ) { this(occurrenceRepository, locationRepository, locationGridRepository, eventVideoRepository, - gridQueryService, zoneNameQueryService, eventNotificationService, Clock.systemUTC()); + gridQueryService, zoneNameQueryService, eventNotificationService, awsProperties, Clock.systemUTC()); } public EventQueryServiceImpl( @@ -129,6 +133,7 @@ public EventQueryServiceImpl( GridQueryService gridQueryService, ZoneNameQueryService zoneNameQueryService, EventNotificationService eventNotificationService, + AwsProperties awsProperties, Clock clock ) { this.occurrenceRepository = occurrenceRepository; @@ -138,6 +143,7 @@ public EventQueryServiceImpl( this.gridQueryService = gridQueryService; this.zoneNameQueryService = zoneNameQueryService; this.eventNotificationService = eventNotificationService; + this.awsProperties = awsProperties; this.clock = clock; } @@ -181,7 +187,11 @@ public List getLocations(long occurrenceId) { occurrenceRepository.findById(occurrenceId) .filter(found -> isVisible(found, now)) .orElseThrow(() -> new ApiException(EventErrorCode.EVENT_NOT_FOUND)); - List locations = locationRepository.findByOccurrenceIdOrderByDisplayOrderAscIdAsc(occurrenceId); + // 노출 중지된 위치는 목록에서 빠진다 (MSG-500 D-3) — 중지가 "즉시 사라진다"를 만드는 자리 중 하나다. + List locations = locationRepository.findByOccurrenceIdOrderByDisplayOrderAscIdAsc(occurrenceId) + .stream() + .filter(location -> location.getHiddenAt() == null) + .toList(); if (locations.isEmpty()) { return List.of(); } @@ -208,6 +218,7 @@ public Map> getLocationsBulk(Collection occurren // 미노출 회차는 키 자체가 빠진다 — 단건 조회의 13404 은닉과 같은 결이다. occurrence 는 fetch join 으로 // 이미 로딩돼 있어 노출 판정·그룹핑이 추가 쿼리를 만들지 않는다. return locationRepository.findWithOccurrenceByOccurrenceIdIn(occurrenceIds).stream() + .filter(location -> location.getHiddenAt() == null) .filter(location -> isVisible(location.getOccurrence(), now)) .collect(Collectors.groupingBy(location -> location.getOccurrence().getId(), LinkedHashMap::new, Collectors.mapping( @@ -220,6 +231,7 @@ public List getLocationsByGrid(String gridId) { LocalDateTime now = LocalDateTime.now(clock); // 미노출 예정 회차는 배열에서 아예 뺀다 — 노출 전 회차는 존재 자체가 비공개 정보다 (§API 4). List locations = locationGridRepository.findLocationsByGridId(gridId).stream() + .filter(location -> location.getHiddenAt() == null) .filter(location -> isVisible(location.getOccurrence(), now)) .sorted(reverseLookupOrder(now)) .toList(); @@ -299,7 +311,15 @@ private EventLocationResponseDto toLocationDto(EventLocation location, Map new ApiException(EventErrorCode.EVENT_NOT_FOUND)); EventLocation location = locationRepository.findWithLockById(locationId) .filter(found -> found.getOccurrence().getId().equals(occurrenceId)) + // 중지된 위치에는 올릴 수 없다 (MSG-500 D-3) — 없는 위치와 같은 404 로 수렴한다. + .filter(found -> found.getHiddenAt() == null) .orElseThrow(() -> new ApiException(EventErrorCode.EVENT_LOCATION_NOT_FOUND)); // 잠금 획득 후 회차 재독 — 위 조회는 잠금이 없어, 잠금 대기 사이 부팅 리시드(롤링 배포 중 새 // 인스턴스의 멱등 UPSERT)가 일정을 바꾸면 영속성 컨텍스트에 캐시된 startsAt·endsAt 이 낡은 값으로 @@ -169,6 +171,8 @@ public EventLocationVideoPageResponseDto getLocationVideos(long occurrenceId, lo .orElseThrow(() -> new ApiException(EventErrorCode.EVENT_NOT_FOUND)); locationRepository.findById(locationId) .filter(found -> found.getOccurrence().getId().equals(occurrenceId)) + // 중지된 위치의 영상 목록도 없는 위치와 같은 404 다 (MSG-500 D-3). + .filter(found -> found.getHiddenAt() == null) .orElseThrow(() -> new ApiException(EventErrorCode.EVENT_LOCATION_NOT_FOUND)); int pageSize = size < 1 ? PAGE_DEFAULT_SIZE : Math.min(size, PAGE_MAX_SIZE); @@ -237,6 +241,10 @@ public EventVideoDetailResponseDto getVideoDetail(long videoId, Long userId) { throw new ApiException(EventErrorCode.EVENT_VIDEO_NOT_FOUND); } EventLocation location = link.getLocation(); + // 중지된 위치의 영상은 알던 videoId 로도 열리지 않는다 (MSG-500 D-3) — 기존 존재 은닉과 같은 404 다. + if (location.getHiddenAt() != null) { + throw new ApiException(EventErrorCode.EVENT_VIDEO_NOT_FOUND); + } EventOccurrence occurrence = location.getOccurrence(); // 정상 데이터에선 도달하지 않지만(노출 전 회차엔 영상이 없다) 은닉 판정을 명시해 일관성을 지킨다. if (!isVisible(occurrence, now)) { From f28e5079416cdf6fe96788c4b2859af2c419e76d Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 31/35] =?UTF-8?q?MSG-500=20test:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=ED=98=95=20=EC=8A=B9=EC=9D=B8=C2=B7=EC=A4=91=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=2013=EA=B1=B4=EC=9D=84=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시 승인 직렬화, 숨긴 격자 재승인 거부, 시드 위치 불변까지 계약 단위로 덮는다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .../EventOccurrenceControllerTest.java | 3 +- .../event/service/EventQueryServiceTest.java | 7 +- .../service/OrgEventQueryServiceTest.java | 7 +- .../AdminApprovedEventListTest.java | 27 +- .../AdminEventParticipationApprovalTest.java | 478 ++++++++++++++++++ .../AdminEventSubmissionControllerTest.java | 6 +- .../AdminEventSubmissionReviewTest.java | 11 +- .../controller/AdminEventUnpublishTest.java | 18 +- ...tParticipationApprovalConcurrencyTest.java | 210 ++++++++ ...ventSubmissionApprovalConcurrencyTest.java | 2 +- 10 files changed, 745 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/msg/fillmap/event/submission/controller/AdminEventParticipationApprovalTest.java create mode 100644 src/test/java/com/msg/fillmap/event/submission/service/EventParticipationApprovalConcurrencyTest.java diff --git a/src/test/java/com/msg/fillmap/event/controller/EventOccurrenceControllerTest.java b/src/test/java/com/msg/fillmap/event/controller/EventOccurrenceControllerTest.java index 96bd98d8..3aa2ea80 100644 --- a/src/test/java/com/msg/fillmap/event/controller/EventOccurrenceControllerTest.java +++ b/src/test/java/com/msg/fillmap/event/controller/EventOccurrenceControllerTest.java @@ -154,7 +154,8 @@ class Locations { void 위치_목록_응답에_표시명_재료가_실린다() throws Exception { given(eventQueryService.getLocations(OCCURRENCE_ID)).willReturn(List.of( new EventLocationResponseDto(31L, "부산역 팝업", "POPUP", "11:00 ~ 20:00", - List.of("19443_9582", "19443_9583"), "19443_9582", "서면", "A-14", "부전동", 7L))); + List.of("19443_9582", "19443_9583"), "19443_9582", "서면", "A-14", "부전동", 7L, + null, null, null, null, null, null))); mockMvc.perform(get("/api/event-occurrences/{id}/locations", OCCURRENCE_ID)) .andExpect(status().isOk()) diff --git a/src/test/java/com/msg/fillmap/event/service/EventQueryServiceTest.java b/src/test/java/com/msg/fillmap/event/service/EventQueryServiceTest.java index 961b395a..f240cbfb 100644 --- a/src/test/java/com/msg/fillmap/event/service/EventQueryServiceTest.java +++ b/src/test/java/com/msg/fillmap/event/service/EventQueryServiceTest.java @@ -36,6 +36,7 @@ import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.repository.EventSeriesRepository; import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.global.exception.ApiException; import com.msg.fillmap.grid.GridEncoder; import com.msg.fillmap.grid.GridEncoder.GridIndex; @@ -68,6 +69,10 @@ @DisplayName("EventQueryService 행사 조회 (실 PostgreSQL)") class EventQueryServiceTest { + /** 참여형 위치의 커버 이미지 공개 주소 조립에만 쓰인다 — 시드 위치는 키가 없어 결과가 null 이다. */ + private static final AwsProperties AWS_PROPERTIES = new AwsProperties("ap-northeast-2", + new AwsProperties.S3("fillmap-video-dev", 104857600L, 2147483648L)); + /** 고정 서버 시각 — 상태·노출 판정의 기준. UTC 저장 컬럼과 같은 축이라 존 스큐가 없다. */ private static final LocalDateTime NOW = LocalDateTime.of(2026, 6, 1, 0, 0); @@ -137,7 +142,7 @@ private EventQueryService service() { private EventQueryService service(LocalDateTime now) { return new EventQueryServiceImpl(occurrenceRepository, locationRepository, locationGridRepository, eventVideoRepository, gridQueryService, zoneNameQueryService, eventNotificationService, - Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); + AWS_PROPERTIES, Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); } /* ---------- 픽스처 ---------- */ diff --git a/src/test/java/com/msg/fillmap/event/service/OrgEventQueryServiceTest.java b/src/test/java/com/msg/fillmap/event/service/OrgEventQueryServiceTest.java index 033349b1..8252a004 100644 --- a/src/test/java/com/msg/fillmap/event/service/OrgEventQueryServiceTest.java +++ b/src/test/java/com/msg/fillmap/event/service/OrgEventQueryServiceTest.java @@ -30,6 +30,7 @@ import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.repository.EventSeriesRepository; import com.msg.fillmap.event.repository.EventVideoRepository; +import com.msg.fillmap.global.config.AwsProperties; import com.msg.fillmap.grid.service.GridQueryService; import com.msg.fillmap.zone.service.ZoneNameQueryService; @@ -46,6 +47,10 @@ @DisplayName("EventQueryService 승인 이벤트 목록 (실 PostgreSQL)") class OrgEventQueryServiceTest { + /** 참여형 위치의 커버 이미지 공개 주소 조립에만 쓰인다 — 시드 위치는 키가 없어 결과가 null 이다. */ + private static final AwsProperties AWS_PROPERTIES = new AwsProperties("ap-northeast-2", + new AwsProperties.S3("fillmap-video-dev", 104857600L, 2147483648L)); + /** 고정 서버 시각 — 종료 전 판정의 기준. UTC 저장 컬럼과 같은 축이라 존 스큐가 없다. */ private static final LocalDateTime NOW = LocalDateTime.of(2026, 6, 1, 0, 0); @@ -79,7 +84,7 @@ class OrgEventQueryServiceTest { private EventQueryService service() { return new EventQueryServiceImpl(occurrenceRepository, locationRepository, locationGridRepository, eventVideoRepository, gridQueryService, zoneNameQueryService, eventNotificationService, - Clock.fixed(NOW.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); + AWS_PROPERTIES, Clock.fixed(NOW.toInstant(ZoneOffset.UTC), ZoneOffset.UTC)); } /* ---------- 픽스처 ---------- */ diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java index 1d3fcfc6..4989a442 100644 --- a/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminApprovedEventListTest.java @@ -31,6 +31,9 @@ import org.springframework.transaction.support.TransactionTemplate; import com.msg.fillmap.auth.jwt.TokenProvider; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; import com.msg.fillmap.event.submission.dto.AdminApprovedEventItemResponseDto; import com.msg.fillmap.event.submission.dto.AdminApprovedEventListResponseDto; import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; @@ -69,6 +72,15 @@ class AdminApprovedEventListTest { @Autowired private EventSubmissionRepository submissionRepository; + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + @Autowired private UserRepository userRepository; @@ -97,7 +109,8 @@ void setUp() { admin = saveUser(UserRole.ADMIN); // 목록은 미션도 메일도 건드리지 않는다 — 중지 경로 의존이라 목으로 채운다. service = new AdminApprovedEventService(submissionRepository, mock(MissionRegistrationService.class), - userRepository, mock(MailSender.class), transactionTemplate, KST_NEW_DAY); + userRepository, mock(MailSender.class), transactionTemplate, occurrenceRepository, locationRepository, + locationGridRepository, KST_NEW_DAY); } private User saveUser(UserRole role) { @@ -141,7 +154,7 @@ private User saveUser(UserRole role) { @DisplayName("파생 탭") class DerivedTab { - // 검증: FR-EVENT-18 + // 검증: FR-EVENT-19 @Test @DisplayName("탭 상태는 KST 오늘과 기간으로 파생되고 경계일은 양끝 포함이다") void 승인_행사_목록의_탭_상태는_KST_오늘과_기간으로_파생된다() { @@ -164,7 +177,7 @@ class DerivedTab { assertThat(목록("EXPOSED").events()).allMatch(event -> "EXPOSED".equals(event.status())); } - // 검증: FR-EVENT-18 + // 검증: FR-EVENT-19 @Test @DisplayName("탭 건수 3종은 탭 필터와 무관한 전체 집계다") void 탭_건수는_필터와_무관한_전체_집계다() { @@ -180,7 +193,7 @@ class DerivedTab { assertThat(나중.endedCount()).isEqualTo(처음.endedCount() + 1); } - // 검증: FR-EVENT-18 + // 검증: FR-EVENT-19 @Test @DisplayName("중지된 행사는 파생 탭에 남고 unpublished 가 참이다") void 중지된_행사는_파생_탭에_남고_unpublished가_참이다() { @@ -204,7 +217,7 @@ class DerivedTab { @DisplayName("파라미터와 인가") class Guards { - // 검증: FR-EVENT-18 + // 검증: FR-EVENT-19 @Test @DisplayName("지원하지 않는 탭은 13455, 페이지 범위 밖은 13456 이다") void 파라미터_거절은_심사_큐와_같은_코드다() throws Exception { @@ -216,7 +229,7 @@ class Guards { .andExpect(jsonPath("$.developCode").value(13456)); } - // 검증: FR-EVENT-18 + // 검증: FR-EVENT-19 @Test @DisplayName("관리자가 아니면 승인 행사 목록에 접근할 수 없다") void 관리자가_아니면_접근할_수_없다() throws Exception { @@ -225,7 +238,7 @@ class Guards { mockMvc.perform(get(URL)).andExpect(status().isUnauthorized()); } - // 검증: FR-EVENT-18 + // 검증: FR-EVENT-19 @Test @DisplayName("기본 탭은 노출 중이다") void 기본_탭은_노출_중이다() throws Exception { diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventParticipationApprovalTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventParticipationApprovalTest.java new file mode 100644 index 00000000..7b76f158 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventParticipationApprovalTest.java @@ -0,0 +1,478 @@ +package com.msg.fillmap.event.submission.controller; + +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.eventBody; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.location; +import static com.msg.fillmap.event.submission.EventSubmissionFixtures.rect; +import static org.assertj.core.api.Assertions.assertThat; +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +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.EventTestFixtures; +import com.msg.fillmap.event.entity.EventLocation; +import com.msg.fillmap.event.entity.EventLocationType; +import com.msg.fillmap.event.entity.EventOccurrence; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.submission.repository.EventSubmissionRepository; +import com.msg.fillmap.global.mail.MailSender; +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-500 D-8·D-9·D-3, 실 DB). 미션 경로와 달리 산출물이 부모 회차 아래 + * 행사 위치라, 검증 대상이 위치·격자 삽입, 참여 속성 복사, 노출 영역 확장·축소, 회차 내 격자 단일 + * 귀속(지연 제약)과의 관계처럼 전부 DB 동작이다. + * + *

실입력은 대표 위치 한 곳이다 (PRD v2.2 FR-8 — 접수가 2곳 이상을 13439 로 막는다). 구현은 위치 + * N개에서도 성립하게 짜여 있어, 그 일반성(이름 순번·위치 상호 겹침)은 접수를 우회해 SQL 로 심은 신청으로 + * 확인한다 — 승인 경로만 태우고 접수 정책을 흔들지 않는다. + * + *

부모 회차는 서해 먼바다 격자에 만든다(EventTestFixtures 규약) — 공유 로컬 DB 의 육상 실데이터와 + * 겹치지 않아야 회차 내 격자 단일 귀속이 남의 데이터로 깨지지 않는다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("이벤트 참여형 승인·중지 (MSG-500, 실 DB)") +class AdminEventParticipationApprovalTest { + + private static final String ADMIN_URL = "/api/admin/event-submissions"; + private static final String ORG_URL = "/api/org/event-submissions"; + + /** 서해 먼바다 — 육상 실데이터와 겹치지 않는 자리다. 부모 회차와 신청 위치가 이 근방을 쓴다. */ + private static final int SEA_GRID_Y = 19000; + private static final int SEA_GRID_X = 5000; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EventSubmissionRepository submissionRepository; + + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private EntityManager entityManager; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private ThumbnailUrlPresigner thumbnailUrlPresigner; + + @MockitoBean + private MailSender mailSender; + + private EventTestFixtures fixtures; + private User organizer; + private User admin; + private EventOccurrence parent; + + @BeforeEach + void setUp() { + fixtures = new EventTestFixtures(seriesRepository, occurrenceRepository, locationRepository, + locationGridRepository); + organizer = saveUser(UserRole.ORG); + admin = saveUser(UserRole.ADMIN); + 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"); + + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + // 부모 회차는 신청 기간(오늘+30~39일)을 품도록 넉넉히 잡는다 — 끝난 회차는 승인이 13451 로 막힌다. + parent = fixtures.회차(fixtures.시리즈(), now.minusDays(1), now.plusDays(60), gridId(0, 0)); + } + + private User saveUser(UserRole role) { + User user = User.createLocalUser("part-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", role); + return userRepository.saveAndFlush(user); + } + + private static String gridId(int dy, int dx) { + return (SEA_GRID_Y + dy) + "_" + (SEA_GRID_X + dx); + } + + private String bearer(User user) { + return "Bearer " + tokenProvider.issueAccessToken(user.getId(), user.getRole()); + } + + /** 참여형 신청 1건 — 접수 API 를 그대로 탄다(대표 위치 한 곳, v2.2 FR-8). */ + private long 참여를_신청한다(int dy, int dx) throws Exception { + String body = eventBody(organizer.getId(), parent.getId(), + location(rect(SEA_GRID_Y + dy, SEA_GRID_Y + dy, SEA_GRID_X + dx, SEA_GRID_X + dx))); + String response = mockMvc.perform(post(ORG_URL) + .header(HttpHeaders.AUTHORIZATION, bearer(organizer)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return ((Number) JsonPath.read(response, "$.data.id")).longValue(); + } + + private ResultActions 승인한다(long submissionId) throws Exception { + entityManager.flush(); + return mockMvc.perform(post(ADMIN_URL + "/" + submissionId + "/approve") + .header(HttpHeaders.AUTHORIZATION, bearer(admin))); + } + + private ResultActions 중지한다(long submissionId) throws Exception { + entityManager.flush(); + return mockMvc.perform(post("/api/admin/events/" + submissionId + "/unpublish") + .header(HttpHeaders.AUTHORIZATION, bearer(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "행사가 취소되어 노출을 중지합니다"}""")); + } + + private List 승인된_위치들(long submissionId) { + entityManager.flush(); + entityManager.clear(); + String submissionNo = submissionRepository.findById(submissionId).orElseThrow().getSubmissionNo(); + return locationRepository.findByLocationKeyStartingWith("sub-%s-".formatted(submissionNo)); + } + + private EventOccurrence 저장된_회차() { + entityManager.flush(); + entityManager.clear(); + return occurrenceRepository.findById(parent.getId()).orElseThrow(); + } + + /** + * 접수를 우회해 위치 두 곳짜리 참여형 신청을 심는다. 실입력에는 없는 형태이지만(v2.2 FR-8 이 한 곳으로 + * 제한한다) 승인 구현은 N개에서도 성립해야 하므로, 이름 순번과 위치 상호 겹침은 이 경로로만 확인된다. + */ + private long 두_위치_신청을_심는다(String title, int firstDx, int secondDx) { + entityManager.flush(); + String submissionNo = "FM-2026-" + UUID.randomUUID().toString().substring(0, 8); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + jdbcTemplate.update(""" + INSERT INTO event_submissions + (submission_no, user_id, type, status, title, organizer_name, starts_on, ends_on, + participation_method, description, image_key, created_at, updated_at, + parent_event_occurrence_id) + VALUES (?, ?, 'EVENT', 'IN_REVIEW', ?, '필맵 주식회사', ?, ?, + '현장에서 스탬프를 찍고 굿즈를 받는 방식입니다', '이벤트 참여형 부스를 운영합니다', + 'event-submissions/original/1/a.jpg', ?, ?, ?) + """, submissionNo, organizer.getId(), title, LocalDate.now().plusDays(1), LocalDate.now().plusDays(9), + now, now, parent.getId()); + Long submissionId = jdbcTemplate.queryForObject( + "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); + + int order = 1; + for (int dx : new int[] {firstDx, secondDx}) { + jdbcTemplate.update(""" + INSERT INTO event_submission_locations (event_submission_id, display_order, representative_grid_id) + VALUES (?, ?, ?) + """, submissionId, order++, gridId(0, dx)); + Long locationId = jdbcTemplate.queryForObject( + "SELECT MAX(id) FROM event_submission_locations WHERE event_submission_id = ?", + Long.class, submissionId); + jdbcTemplate.update(""" + INSERT INTO event_submission_location_rects + (event_submission_location_id, min_grid_y, max_grid_y, min_grid_x, max_grid_x) + VALUES (?, ?, ?, ?, ?) + """, locationId, SEA_GRID_Y, SEA_GRID_Y, SEA_GRID_X + dx, SEA_GRID_X + dx); + } + entityManager.clear(); + return submissionId; + } + + @Nested + @DisplayName("승인 — 참여형 전개") + class Approve { + + // 검증: FR-EVENT-15 + @Test + @DisplayName("승인하면 부모 회차 아래 위치와 격자가 생기고 회차 노출 영역이 넓어진다") + void 참여형_신청을_승인하면_부모_회차_아래_위치와_격자가_생기고_회차_노출_영역이_넓어진다() throws Exception { + int 기존_최대열 = parent.getMaxGridX(); + long id = 참여를_신청한다(0, 50); + + 승인한다(id).andExpect(status().isOk()); + + List created = 승인된_위치들(id); + assertThat(created).singleElement().satisfies(location -> { + assertThat(location.getOccurrence().getId()).isEqualTo(parent.getId()); + assertThat(location.getType()).isEqualTo(EventLocationType.ETC); + assertThat(location.getRepresentativeGridId()).isEqualTo(gridId(0, 50)); + assertThat(location.getOperatingHours()).isNull(); + assertThat(location.getHiddenAt()).isNull(); + assertThat(locationGridRepository.findByIdEventLocationId(location.getId())) + .extracting(grid -> grid.getGridId()) + .containsExactly(gridId(0, 50)); + }); + // 노출 영역이 새 위치를 품도록 넓어진다 — 안 넓어지면 그 뷰포트에서 부모 행사가 안 보인다. + assertThat(저장된_회차().getMaxGridX()).isEqualTo(Math.max(기존_최대열, SEA_GRID_X + 50)); + // 승인 산출물이 미션이 아니라 위치라 신청 행의 미션 링크는 비어 있다. + assertThat(submissionRepository.findById(id).orElseThrow().getPublishedMissionId()).isNull(); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("참여 속성이 신청 행에서 위치 행으로 복사되어 위치 목록 응답에 실린다") + void 참여_속성이_신청_행에서_위치_행으로_복사되어_위치_목록_응답에_실린다() throws Exception { + long id = 참여를_신청한다(0, 51); + 승인한다(id).andExpect(status().isOk()); + + EventLocation created = 승인된_위치들(id).getFirst(); + assertThat(created.getOrganizerName()).isEqualTo("필맵 파트너스"); + assertThat(created.getParticipationMethod()).isNotBlank(); + assertThat(created.getStartsOn()).isNotNull(); + assertThat(created.getEndsOn()).isNotNull(); + assertThat(created.getImageKey()).startsWith("event-locations/org-submission/"); + + mockMvc.perform(get("/api/event-occurrences/{id}/locations", parent.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.locationId == %d)].organizerName".formatted(created.getId())) + .value("필맵 파트너스")) + .andExpect(jsonPath("$.data[?(@.locationId == %d)].participationMethod".formatted(created.getId())) + .exists()) + // 키가 아니라 조립된 공개 주소가 나간다 — 화면이 그대로 에 넣는 값이다. + .andExpect(jsonPath("$.data[?(@.locationId == %d)].imageUrl".formatted(created.getId())) + .value(org.hamcrest.Matchers.hasItem( + org.hamcrest.Matchers.containsString("event-locations/org-submission/")))); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("시드 위치의 참여 속성 필드는 전부 null 이다 — 기존 화면이 불변이다") + void 시드_위치의_참여_속성_필드는_전부_null이다() throws Exception { + EventLocation seeded = fixtures.위치(parent, "시드 위치", gridId(0, 90)); + + mockMvc.perform(get("/api/event-occurrences/{id}/locations", parent.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.locationId == %d)].organizerName".formatted(seeded.getId())) + .value(org.hamcrest.Matchers.everyItem(org.hamcrest.Matchers.nullValue()))) + .andExpect(jsonPath("$.data[?(@.locationId == %d)].imageUrl".formatted(seeded.getId())) + .value(org.hamcrest.Matchers.everyItem(org.hamcrest.Matchers.nullValue()))) + .andExpect(jsonPath("$.data[?(@.locationId == %d)].participationStartsOn".formatted(seeded.getId())) + .value(org.hamcrest.Matchers.everyItem(org.hamcrest.Matchers.nullValue()))); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("심사 상세가 참여 방식과 부모 이벤트를 준다 — 무엇에 실리는지 보고 승인한다") + void 심사_상세는_참여_방식과_부모_이벤트를_준다() throws Exception { + long id = 참여를_신청한다(0, 53); + + mockMvc.perform(get(ADMIN_URL + "/" + id).header(HttpHeaders.AUTHORIZATION, bearer(admin))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("EVENT")) + .andExpect(jsonPath("$.data.participationMethod").isNotEmpty()) + // 부모를 특정할 재료가 없으면 관리자가 어느 이벤트에 실리는지 모르는 채 승인하게 된다. + .andExpect(jsonPath("$.data.parentEvent.occurrenceId").value(parent.getId())) + .andExpect(jsonPath("$.data.parentEvent.name").value(parent.getTitle())); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("노출 영역 확장은 일정 개정 번호를 올리지 않는다 — 변경 알림이 나가지 않는다") + void 노출_영역_확장은_일정_개정_번호를_올리지_않는다() throws Exception { + int 기존_개정 = 저장된_회차().getScheduleRevision(); + long id = 참여를_신청한다(0, 52); + + 승인한다(id).andExpect(status().isOk()); + + assertThat(저장된_회차().getScheduleRevision()).isEqualTo(기존_개정); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("부모 회차의 기존 위치와 격자가 겹치면 13452 로 거부된다") + void 부모_회차의_기존_위치와_격자가_겹치면_13452로_거부된다() throws Exception { + fixtures.위치(parent, "기존 위치", gridId(0, 60)); + long id = 참여를_신청한다(0, 60); + + 승인한다(id) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13452)); + + // 삽입 전에 막혔다 — 지연 제약이 커밋 때 터지는 500 이 아니라 읽히는 409 다. + // 승인 흔적(전이·승인 번호)의 원복은 여기서 관찰할 수 없다: 롤백 격리 클래스라 서비스 트랜잭션이 + // 테스트 트랜잭션에 합류해 진짜 롤백이 일어나지 않는다(M3 승인 원자성 테스트와 같은 제약). + assertThat(승인된_위치들(id)).isEmpty(); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("같은 신청의 신규 위치끼리 격자가 겹쳐도 삽입 전 13452 로 거부된다") + void 같은_신청의_신규_위치끼리_격자가_겹쳐도_삽입_전_13452로_거부된다() throws Exception { + // 접수 검증은 위치를 독립적으로 보므로 위치 간 겹침은 심사까지 통과해 온다 — 그 자리를 막는다. + long id = 두_위치_신청을_심는다("참여형 부스", 70, 70); + + 승인한다(id) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13452)); + + assertThat(승인된_위치들(id)).isEmpty(); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("위치가 여럿이면 이름이 순번 접미사를 포함해 100자 안으로 잘린다") + void 위치가_여럿이면_이름이_순번_접미사를_포함해_100자_안으로_잘린다() throws Exception { + String 백자_제목 = "가".repeat(100); + long id = 두_위치_신청을_심는다(백자_제목, 80, 81); + + 승인한다(id).andExpect(status().isOk()); + + List created = 승인된_위치들(id); + assertThat(created).hasSize(2); + assertThat(created).allSatisfy(location -> { + assertThat(location.getName()).hasSizeLessThanOrEqualTo(100); + assertThat(location.getName()).startsWith("가"); + }); + assertThat(created).extracting(EventLocation::getName) + .containsExactlyInAnyOrder(백자_제목.substring(0, 98) + " 1", 백자_제목.substring(0, 98) + " 2"); + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("종료된 부모 회차로는 승인할 수 없다 — 13451") + void 종료된_부모_회차의_승인은_13451이다() throws Exception { + long id = 참여를_신청한다(0, 55); + // 심사 지연 사이에 부모가 끝난 상황 — 반영할 노출이 없다. + entityManager.flush(); + jdbcTemplate.update("UPDATE event_occurrences SET ends_at = ? WHERE id = ?", + LocalDateTime.now(ZoneOffset.UTC).minusDays(1), parent.getId()); + entityManager.clear(); + + 승인한다(id) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13451)); + assertThat(승인된_위치들(id)).isEmpty(); + } + } + + @Nested + @DisplayName("중지 — 참여형 경로") + class Unpublish { + + // 검증: FR-EVENT-18 + @Test + @DisplayName("중지하면 참여형 위치가 회차 상세와 격자 조회에서 사라진다") + void 중지하면_참여형_위치가_회차_상세와_격자_조회에서_사라진다() throws Exception { + long id = 참여를_신청한다(0, 40); + 승인한다(id).andExpect(status().isOk()); + long locationId = 승인된_위치들(id).getFirst().getId(); + + mockMvc.perform(get("/api/event-occurrences/{id}/locations", parent.getId())) + .andExpect(jsonPath("$.data[?(@.locationId == %d)]".formatted(locationId)).exists()); + + 중지한다(id).andExpect(status().isOk()); + + entityManager.flush(); + entityManager.clear(); + mockMvc.perform(get("/api/event-occurrences/{id}/locations", parent.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.locationId == %d)]".formatted(locationId)).doesNotExist()); + mockMvc.perform(get("/api/grids/{gridId}/event-locations", gridId(0, 40))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.locationId == %d)]".formatted(locationId)).doesNotExist()); + // 업로드 확인도 같은 404 로 막힌다(위치 자체가 없는 것과 구분되지 않는다). + assertThat(locationRepository.findById(locationId).orElseThrow().getHiddenAt()).isNotNull(); + } + + // 검증: FR-EVENT-18 + @Test + @DisplayName("중지하면 부모 회차 노출 영역이 남은 가시 위치 범위로 줄어든다") + void 중지하면_부모_회차_노출_영역이_남은_가시_위치_범위로_줄어든다() throws Exception { + fixtures.위치(parent, "남는 위치", gridId(0, 1)); + long id = 참여를_신청한다(0, 45); + 승인한다(id).andExpect(status().isOk()); + assertThat(저장된_회차().getMaxGridX()).isEqualTo(SEA_GRID_X + 45); + int 기존_개정 = 저장된_회차().getScheduleRevision(); + + 중지한다(id).andExpect(status().isOk()); + + // 남은 가시 위치(열 +1)만 감싸는 범위로 돌아온다 — 안 줄이면 숨긴 위치 뷰포트에서 계속 노출된다. + assertThat(저장된_회차().getMaxGridX()).isEqualTo(SEA_GRID_X + 1); + assertThat(저장된_회차().getScheduleRevision()).isEqualTo(기존_개정); + } + + // 검증: FR-EVENT-18 + @Test + @DisplayName("중지된 참여형 위치의 격자는 남아 있어 같은 칸의 재승인이 13452 로 막힌다") + void 중지된_위치의_격자는_남아_회차_내_단일_귀속을_지킨다() throws Exception { + long first = 참여를_신청한다(0, 46); + 승인한다(first).andExpect(status().isOk()); + 중지한다(first).andExpect(status().isOk()); + + long second = 참여를_신청한다(0, 46); + + // 숨긴 위치의 격자도 uq_event_grid_per_occ 에 그대로 남아 있다 — 겹침 검사가 가시 격자만 보면 + // 여기서 통과하고 커밋 시점 500 이 된다. + 승인한다(second) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.developCode").value(13452)); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java index c0dc63c8..62eeb3d9 100644 --- a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionControllerTest.java @@ -250,7 +250,11 @@ class Detail { .andExpect(jsonPath("$.data.locations[0].cellCount").value(21)) .andExpect(jsonPath("$.data.imageUrl").value("https://signed.example/image.jpg")) .andExpect(jsonPath("$.data.history.length()").value(1)) - .andExpect(jsonPath("$.data.history[0].status").value("IN_REVIEW")); + .andExpect(jsonPath("$.data.history[0].status").value("IN_REVIEW")) + // 참여형 재료 둘은 축제·팝업 신청에서 키만 있고 값이 없다 (EVENT 전용) — 키 자체는 + // 스키마 계약상 항상 나간다(required + nullable 병기). + .andExpect(jsonPath("$.data.participationMethod").isEmpty()) + .andExpect(jsonPath("$.data.parentEvent").isEmpty()); } // 검증: FR-EVENT-15 diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java index a2c4d90c..801cc662 100644 --- a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventSubmissionReviewTest.java @@ -18,6 +18,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.UUID; @@ -207,13 +208,17 @@ class ApproveToMission { assertThat(mission.getSourceKey()).isEqualTo(저장된_신청(id).getSubmissionNo()); assertThat(mission.getTargetCount()).isEqualTo(1); // KST 날짜 라벨 → UTC 순간 (축제 시더와 같은 규칙): 시작 KST 00:00, 끝 KST 23:59:59. - assertThat(mission.getStartAt()).isEqualTo(LocalDateTime.of(2026, 11, 6, 15, 0, 0)); - assertThat(mission.getEndAt()).isEqualTo(LocalDateTime.of(2026, 11, 7, 14, 59, 59)); + // 기대값은 픽스처와 같은 상대 날짜에서 만들되 변환은 여기서 독립적으로 쓴다(KST = UTC+9 고정). + // 축제 픽스처는 당일 행사라 시작일과 종료일이 같다(둘 다 +30일). + LocalDate 시작일 = LocalDate.now(ZoneOffset.UTC).plusDays(30); + LocalDate 종료일 = 시작일; + assertThat(mission.getStartAt()).isEqualTo(시작일.atStartOfDay().minusHours(9)); + assertThat(mission.getEndAt()).isEqualTo(종료일.atTime(23, 59, 59).minusHours(9)); assertThat(mission.getDescription()).isEqualTo("광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제"); assertThat(mission.getOperationTime()).isNull(); // 재시드·재기동 없이 기존 활성 조회 술어에 그대로 잡힌다 (기간 안의 시각으로 확인). - assertThat(missionRepository.findActive(LocalDateTime.of(2026, 11, 7, 3, 0))) + assertThat(missionRepository.findActive(시작일.atTime(3, 0))) .extracting(Mission::getId) .contains(mission.getId()); } diff --git a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java index 3161ef83..bf1731bb 100644 --- a/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/controller/AdminEventUnpublishTest.java @@ -163,7 +163,7 @@ private String bearer(User user) { @DisplayName("사용자 대면 노출") class UserFacing { - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지하면 미션이 활성 조회와 스탬프 후보 판정에서 사라진다") void 중지하면_미션이_활성_조회와_스탬프_판정에서_사라진다() throws Exception { @@ -179,7 +179,7 @@ class UserFacing { .doesNotContain(missionId); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지된 미션은 격자 선택 조회에서도 빠진다") void 중지된_미션은_격자_선택_조회에서도_빠진다() throws Exception { @@ -196,7 +196,7 @@ class UserFacing { .andExpect(jsonPath("$.data[?(@.missionId == %d)]".formatted(missionId)).doesNotExist()); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지된 미션의 상세와 영상 피드는 없는 미션과 같은 404 다") void 중지된_미션의_상세와_영상_피드는_404다() throws Exception { @@ -216,7 +216,7 @@ class UserFacing { .andExpect(jsonPath("$.developCode").value(12404)); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지해도 진행도 조회는 남는다 — 기록은 회수하지 않는다") void 이미_완료한_사용자의_진행도는_중지_후에도_남는다() throws Exception { @@ -236,7 +236,7 @@ class UserFacing { @DisplayName("중지 처리") class Unpublishing { - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지 사유가 신청 계정의 공식 이메일로 발송된다") void 중지_사유가_공식_이메일로_발송된다() throws Exception { @@ -251,7 +251,7 @@ class Unpublishing { assertThat(body.getValue()).contains("행사가 취소되어 노출을 중지합니다"); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("발송이 실패해도 중지는 유지되고 emailSent 가 거짓이다") void 발송이_실패해도_중지는_유지된다() throws Exception { @@ -267,7 +267,7 @@ class Unpublishing { assertThat(활성_목록에_있다()).isFalse(); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("이미 중지된 행사의 재중지는 13453 이고 첫 중지 사유가 유지된다") void 이미_중지된_행사의_재중지는_13453이다() throws Exception { @@ -282,7 +282,7 @@ class Unpublishing { .isEqualTo("행사가 취소되어 노출을 중지합니다"); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("승인되지 않은 신청과 없는 id 의 중지는 둘 다 13430 이다") void 미승인_신청_id의_중지는_13430이다() throws Exception { @@ -299,7 +299,7 @@ class Unpublishing { .andExpect(jsonPath("$.developCode").value(13430)); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지된 뒤에도 미션 행은 남는다 — 삭제가 아니라 숨김이다") void 중지는_삭제가_아니라_숨김이다() throws Exception { diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventParticipationApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventParticipationApprovalConcurrencyTest.java new file mode 100644 index 00000000..e4c20de7 --- /dev/null +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventParticipationApprovalConcurrencyTest.java @@ -0,0 +1,210 @@ +package com.msg.fillmap.event.submission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +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.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +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.transaction.support.TransactionTemplate; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectResponse; + +import com.msg.fillmap.event.EventTestFixtures; +import com.msg.fillmap.event.exception.EventErrorCode; +import com.msg.fillmap.event.repository.EventLocationGridRepository; +import com.msg.fillmap.event.repository.EventLocationRepository; +import com.msg.fillmap.event.repository.EventOccurrenceRepository; +import com.msg.fillmap.event.repository.EventSeriesRepository; +import com.msg.fillmap.event.submission.dto.EventSubmissionApproveResponseDto; +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-500 D-9, 실 DB). 부모 회차 비관 잠금이 두 승인을 + * 직렬화하는지를 본다 — 잠금이 없으면 두 겹침 사전 검사가 모두 통과하고, 회차 내 격자 단일 귀속이 + * 지연 제약이라 지는 쪽이 커밋 시점 500(DataIntegrityViolation)으로 끝난다. 잠금이 있으면 늦은 쪽은 + * 이긴 쪽의 격자를 보고 읽히는 13452 를 받는다(다음 조작은 AREA 코드 반려다). + * + *

진짜 동시 트랜잭션이 필요해 클래스 수준 {@code @Transactional} 롤백 격리를 쓸 수 없다 + * (EventSubmissionApprovalConcurrencyTest 와 같은 이유). 만든 행은 FK 순서대로 직접 지운다. + */ +@SpringBootTest +@DisplayName("참여형 동시 승인 직렬화 (MSG-500 D-9, 실 DB)") +class EventParticipationApprovalConcurrencyTest { + + /** 서해 먼바다 — 공유 로컬 DB 의 육상 실데이터와 겹치지 않는 자리다. */ + private static final int SEA_GRID_Y = 19100; + private static final int SEA_GRID_X = 5100; + private static final String CONTESTED_GRID_ID = SEA_GRID_Y + "_" + SEA_GRID_X; + + @Autowired + private AdminEventSubmissionService adminEventSubmissionService; + + @Autowired + private EventSeriesRepository seriesRepository; + + @Autowired + private EventOccurrenceRepository occurrenceRepository; + + @Autowired + private EventLocationRepository locationRepository; + + @Autowired + private EventLocationGridRepository locationGridRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TransactionTemplate transactionTemplate; + + /** 승인이 커버 이미지를 공개 프리픽스로 복사한다 — 이 클래스가 보는 것은 복사가 아니라 잠금이다. */ + @MockitoBean + private S3Client s3Client; + + private Long userId; + private Long occurrenceId; + private Long seriesId; + private long firstSubmissionId; + private long secondSubmissionId; + + @BeforeEach + void setUp() { + given(s3Client.copyObject(any(CopyObjectRequest.class))).willReturn(CopyObjectResponse.builder().build()); + transactionTemplate.executeWithoutResult(status -> { + User user = User.createLocalUser("concurrent-part-" + UUID.randomUUID() + "@fillmap.dev", + passwordEncoder.encode("Initial1234"), "김담당"); + ReflectionTestUtils.setField(user, "role", UserRole.ORG); + userId = userRepository.saveAndFlush(user).getId(); + + EventTestFixtures fixtures = new EventTestFixtures(seriesRepository, occurrenceRepository, + locationRepository, locationGridRepository); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + var occurrence = fixtures.회차(fixtures.시리즈(), now.minusDays(1), now.plusDays(60), CONTESTED_GRID_ID); + occurrenceId = occurrence.getId(); + seriesId = occurrence.getSeries().getId(); + }); + // 두 신청이 같은 칸을 노린다 — 잠금이 없으면 둘 다 사전 검사를 통과한다. + firstSubmissionId = 참여형_신청을_심는다(); + secondSubmissionId = 참여형_신청을_심는다(); + } + + @AfterEach + void tearDown() { + transactionTemplate.executeWithoutResult(status -> { + jdbcTemplate.update("DELETE FROM event_submissions WHERE user_id = ?", userId); + jdbcTemplate.update(""" + DELETE FROM event_location_grids WHERE event_location_id IN + (SELECT id FROM event_locations WHERE event_occurrence_id = ?) + """, occurrenceId); + jdbcTemplate.update("DELETE FROM event_locations WHERE event_occurrence_id = ?", occurrenceId); + jdbcTemplate.update("DELETE FROM event_occurrences WHERE id = ?", occurrenceId); + jdbcTemplate.update("DELETE FROM event_series WHERE id = ?", seriesId); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", userId); + }); + } + + /** 접수 API 를 타지 않고 심는다 — 필요한 것은 "같은 칸을 노리는 심사 중 참여형 신청" 두 건이다. */ + private long 참여형_신청을_심는다() { + String submissionNo = "FM-2026-" + UUID.randomUUID().toString().substring(0, 8); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + jdbcTemplate.update(""" + INSERT INTO event_submissions + (submission_no, user_id, type, status, title, organizer_name, starts_on, ends_on, + participation_method, description, image_key, created_at, updated_at, parent_event_occurrence_id) + VALUES (?, ?, 'EVENT', 'IN_REVIEW', '참여형 부스', '필맵 파트너스', ?, ?, + '현장에서 스탬프를 찍는 방식입니다', '이벤트 참여형 부스를 운영합니다', + 'event-submissions/original/1/a.jpg', ?, ?, ?) + """, submissionNo, userId, LocalDate.now().plusDays(1), LocalDate.now().plusDays(9), now, now, + occurrenceId); + Long submissionId = jdbcTemplate.queryForObject( + "SELECT id FROM event_submissions WHERE submission_no = ?", Long.class, submissionNo); + jdbcTemplate.update(""" + INSERT INTO event_submission_locations (event_submission_id, display_order, representative_grid_id) + VALUES (?, 1, ?) + """, submissionId, CONTESTED_GRID_ID); + Long locationId = jdbcTemplate.queryForObject( + "SELECT id FROM event_submission_locations WHERE event_submission_id = ?", Long.class, submissionId); + jdbcTemplate.update(""" + INSERT INTO event_submission_location_rects + (event_submission_location_id, min_grid_y, max_grid_y, min_grid_x, max_grid_x) + VALUES (?, ?, ?, ?, ?) + """, locationId, SEA_GRID_Y, SEA_GRID_Y, SEA_GRID_X, SEA_GRID_X); + return submissionId; + } + + // 검증: FR-EVENT-15 + @Test + @DisplayName("같은 회차를 노리는 두 신청의 동시 승인은 직렬화되어 늦은 쪽이 13452 다") + void 같은_회차를_노리는_두_신청의_동시_승인은_늦은_쪽이_13452다() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + try { + Future first = executor.submit(() -> 승인한다(barrier, + firstSubmissionId)); + Future second = executor.submit(() -> 승인한다(barrier, + secondSubmissionId)); + + List failures = Stream.of(결과를_받는다(first), 결과를_받는다(second)) + .filter(Objects::nonNull) + .toList(); + + // 500(지연 제약 위반)이 아니라 읽히는 409 다 — 그 차이를 만드는 것이 회차 잠금이다. + assertThat(failures).hasSize(1).first().isInstanceOf(ApiException.class); + assertThat(((ApiException) failures.getFirst()).getErrorCode()) + .isEqualTo(EventErrorCode.SUBMISSION_GRID_CONFLICT); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM event_locations WHERE event_occurrence_id = ?", Long.class, occurrenceId)) + .isEqualTo(1L); + } finally { + executor.shutdownNow(); + } + } + + private EventSubmissionApproveResponseDto 승인한다(CyclicBarrier barrier, long submissionId) throws Exception { + barrier.await(); + return adminEventSubmissionService.approve(submissionId); + } + + /** 성공이면 null, 실패면 그 예외 — 어느 쪽이 이겼는지는 스케줄에 달려 있어 결과로 가른다. */ + private Throwable 결과를_받는다(Future future) throws InterruptedException { + try { + future.get(); + return null; + } catch (ExecutionException e) { + return e.getCause(); + } + } +} diff --git a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java index ddfb262b..1c8e6e27 100644 --- a/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java +++ b/src/test/java/com/msg/fillmap/event/submission/service/EventSubmissionApprovalConcurrencyTest.java @@ -223,7 +223,7 @@ void tearDown() { assertThat(after).extracting(MissionResponseDto::missionId).contains(missionId); } - // 검증: FR-EVENT-17 + // 검증: FR-EVENT-18 @Test @DisplayName("중지 커밋 후 스냅숏이 무효화되어 TTL 전에도 목록에서 빠진다") void 중지_커밋_후_미션_스냅숏이_무효화되어_TTL_전에도_목록에서_빠진다() { From eb3e2184b7c0668ca76d274469d3c51c42ecde25 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 32/35] =?UTF-8?q?MSG-500=20docs:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=ED=98=95=20=EC=99=84=EA=B2=B0=EB=A1=9C=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=99=80=20=EA=B5=AC=ED=98=84=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=EC=9D=84=20=ED=98=84=ED=96=89=ED=99=94=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=B2=84=ED=82=B7=20=EC=A0=95=EC=B1=85=20=EB=9F=B0?= =?UTF-8?q?=EB=B6=81=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- .claude/docs/deploy.md | 7 +++ .claude/docs/status.md | 2 +- docs/spec/MSG-500.md | 120 ++++++++++++++++++++++++++++++++++------- 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/.claude/docs/deploy.md b/.claude/docs/deploy.md index ca32a1e7..c927f783 100644 --- a/.claude/docs/deploy.md +++ b/.claude/docs/deploy.md @@ -155,6 +155,13 @@ UPDATE users SET role = 'ADMIN' WHERE id = {대상 id}; 200이 아니면 시더를 돌리지 않는다. 403인 채로 적재하면 전량을 나중에 다시 손봐야 한다. dev 버킷은 2026-08-14 적용을 마쳤다(`PublicReadProfileOriginal`과 나란히 두 번째 Statement). prod 버킷은 아직이다. +4. **`event-locations/org-submission/*` 공개 읽기 버킷 정책** (MSG-500) — 이벤트 참여형 승인이 + 커버 이미지를 이 프리픽스로 복사해 위치 목록 응답에 완성 공개 URL로 싣는다. 승인 미션 + 이미지가 쓰는 `missions/org-submission/`은 위 3번의 `missions/*` 정책이 이미 덮으므로 별도 + 조치가 없지만, 이 프리픽스는 **새 Statement가 필요하다**(3번과 같은 형태, Resource만 + `event-locations/org-submission/*`). 열어 두지 않으면 참여형 승인 위치의 imageUrl이 403이다. + dev·prod 모두 미적용(2026-08-30 기준) — MSG-500 배포 전에 dev부터 적용하고 3번과 같은 + curl 검증을 거친다. ### ⚠️ AWS 프로파일 — 로컬에 계정이 둘이고 기본값이 남의 계정이다 diff --git a/.claude/docs/status.md b/.claude/docs/status.md index 353ed0de..9013ae8f 100644 --- a/.claude/docs/status.md +++ b/.claude/docs/status.md @@ -128,7 +128,7 @@ - MSG-501: 행사 운영자 콘솔 승인 이벤트 목록 — `GET /api/org/events`(`controller/OrgEventController` 신규, city·name 선택 파라미터), `EventQueryService.getApprovedEvents(String, String)` 추가(+Impl — 후보는 `findByEndsAtAfter(now)` 파생 쿼리 하나로 DB에서 거르고(D-1 `ends_at > now`, `statusAt` 미경유), `visible_from` 미적용(D-2 — 콘솔은 노출 전 예정 회차도 노출), 필터·검색(대소문자 무시 부분 일치)·시도별 집계(전체 기준 — totalCount·cityCounts는 필터 무관)·정렬(startsAt→id, 건수 내림차순→이름)은 자바에서. placeLabel은 기존 `findWithOccurrenceByOccurrenceIdIn` 재사용(fetch join, displayOrder→id 정렬의 첫 행 — 승인 흐름산 name 채움 규칙은 MSG-500 접점, 스펙 명시). 쿼리 회차 1 + 위치 일괄 1 고정(빈 후보면 1회). DTO 3종 `Org*ResponseDto`(placeLabel은 required+`@Schema(nullable=true)` 병기 — ResponseSchemaNullabilityTest 규약). SecurityConfig·Flyway·developCode·계약 인터페이스 전부 무변경(/api/org/** matcher가 MSG-496에서 기확정). 테스트 25건(서비스 17·컨트롤러 5 — ORG 200 실경로 포함·리포지토리 3), 전부 `// 검증: FR-EVENT-16`. reviewer 1R(P2 용어 1건 수정)·Codex 1R 수렴(지적 0) - MSG-498: 행사 운영자 행사 등재 신청 — `event.submission` 하위 패키지 신설(V49 `event_submissions`·`_locations`·`_location_rects`·`_status_history` 4테이블 + 신청 번호 전역 시퀀스, 이력 테이블 CHECK가 REJECTED 행에만 사유 강제 — 반려 사유 저장 원천은 이력 하나 D-3). API 5개 전부 `/api/org/event-submissions`(SecurityConfig 무변경 — 496 matcher·497 mustChange 인터셉터 자동 커버, 인가 테스트는 전제 확인 4건): presign(프로필 미러링, jpg·png 10MB, webp 불허) · 제출 POST(유형 FESTIVAL·POPUP 2종, 유형 밖 필드 13439 거부, 81칸은 사각형 **합집합** 판정(전개 전 단일 사각형 long 선판정으로 OOM 차단), 격자 인덱스 상한 상수는 `RepresentativeGridResolver.GRID_INDEX_UPPER_EXCLUSIVE` 승격 공유(시더 사본 제거), 대표 격자 서버 계산, 신청 번호 `FM-{KST연도}-{4자리}` native `nextval` 채번) · 내 목록(상태별 건수는 별도 쿼리 없이 로드 목록에서 groupingBy 파생 — 스냅숏 불일치 차단, 페이지네이션 도입 시 되돌릴 지점) · 상세(존재 은닉 13430 — 모든 조회가 `findByIdAndUserId` 단일 경로, rejection은 이력 최신 행) · 재제출 PATCH(REJECTED 한정, 유형 불변 전체 교체, `@Modifying(clearAutomatically=true)` 조건부 UPDATE 술어에 userId 포함 → 0행이면 소유 조회 분기(부재 13430/비REJECTED 13434), 재로드는 UPDATE 뒤, imageS3Key 생략=유지). 이미지 확정: HEAD 실측(0바이트 13436)→original 복사(uuid 시도마다 신규 — 객체 공유 차단)→롤백 보상 삭제는 **복사 호출 앞 등록**(응답 유실 고아 차단)→커밋 후 pending 삭제(재확정 차단)·STATUS_UNKNOWN엔 미삭제. 엔티티는 다대일 양방향(자식 `@ManyToOne` 주인 + mappedBy 컬렉션 + 편의 메서드, D-12 — 단방향 왕복 실측 후 주류 관행으로 확정). 리스트 요소 null은 type-use `@NotNull`로 공통 400. errorCode 13430~13439(EventErrorCode 확장). 테스트 58건(단위 25·통합 33, 동시 재제출·복사 응답 유실·게이트 커버 포함). reviewer 3R·Codex 3R 수렴(채택 3건 반영, 멱등 키는 스펙 3회+구현 3회 기각 — 콘솔 수동 제출 수용). 심사·승인(APPROVED 쓰기)은 MSG-500 몫, pending S3 라이프사이클 규칙은 배포 전 런북 - MSG-502: 행사 운영자 이벤트 참여형 신청 — **신규 엔드포인트 0개**. MSG-498의 신청 API 5개가 `EVENT` 유형을 받도록 확장(**V50** `event_submissions` +`parent_event_occurrence_id`(FK `event_occurrences(id)`, 인덱스 없음 — 부모로 거는 조회가 없다)·+`participation_method` TEXT, `chk_event_sub_type` 재정의(FESTIVAL·POPUP·**EVENT**), `chk_event_sub_parent` 신설 — `(type = 'EVENT') = (parent_event_occurrence_id IS NOT NULL)` 로 유형과 부모의 짝을 DB가 강제(MSG-500 approve가 이 불변식 위에서 부모를 역참조)). `EventSubmissionType.EVENT` 추가(주석 필수 — glossary 이벤트 카테고리이고 `MissionType.EVENT`(축제 미션)와 이름만 같고 반대편). 엔티티는 부모를 **연관 없이 id 보관**(읽는 값이 상세의 title 하나, userId 선례)이고 `submit(...)` 인자로만 세팅 — 유형처럼 재제출로 바뀌지 않는다(D-3). 검증은 기존 `validateForm` 을 **삼분기 재작성**: "값이 있다 == 그 유형이다" 등식 3개(programDescription·operatingHours·participationMethod) + 부모 존재 등식이라 유형이 늘어도 줄 하나씩 는다. **부모만 폼이 아니라 별도 인자**(재제출 DTO에 부모 필드가 없다) — 제출은 요청 본문 값, 재제출은 **저장값**이 같은 메서드로 들어가 종료만 재검증된다. 대표 위치 정확히 1곳(D-2)은 `buildLocations` 시그니처를 건드리지 않고 **검증 순서로** 해결(validateForm 이 먼저 도는 자리에서 2곳 이상 13439, 0곳은 손대지 않고 기존 13431로 흘려보냄). `EventErrorCode` +2 — 13440 PARENT_EVENT_NOT_FOUND(404, **존재 은닉 없음** — 승인 이벤트 목록이 ORG 전원에게 같은 전량을 보여줘 회차 존재는 비밀이 아니다)·13441 PARENT_EVENT_CLOSED(409, `endsAt <= now` — MSG-501 노출 조건 `endsAt > now`의 정확한 여집합이라 종료 정각에도 목록과 신청이 갈리지 않는다, Clock 주입 판정). 상세 응답 +2(`participationMethod`·`parentEvent`{occurrenceId,name} — name 원천은 `event_occurrences.title`, 다른 유형은 둘 다 null). 기간과 부모 기간의 교차 검증 없음(D-4 — 부모는 UTC 시각, 신청은 KST 날짜 라벨). 격자 겹침 검사도 없음(D-7 — 승인 시점에 어차피 재판정, MSG-500 한 곳). SecurityConfig·계약 인터페이스 무변경. 신규 테스트 22건(목+고정 Clock 15 — 종료 정각 경계 포함 / 실 DB 7 — 저장 실측·상세 부모 이름·PATCH 본문의 부모 변경 시도 무시·재제출 시점 부모 종료 13441), 전부 `// 검증: FR-EVENT-17`. 테스트 픽스처의 고정 날짜를 상대값으로 교체(498분 포함 — 그날이 지나면 성공 경로가 13433으로 먼저 깨지는 시한폭탄 4곳 해제). reviewer 전수 통과·Codex 2R 수렴(픽스처 상대화 채택, 구버전 롤백 비호환 지적은 프리런치라 기각) -- MSG-500: 관리자 심사·승인 반영·노출 관리 — **V51** `event_submissions` +4컬럼(`approval_no` UNIQUE·`published_mission_id` FK(NO ACTION)·`unpublished_at`·`unpublish_reason`) + CHECK 3개(`(status='APPROVED') = (approval_no IS NOT NULL)` · 중지 기록 짝 · 중지는 승인 행만) + 승인 번호 전역 시퀀스 `event_submission_approval_no_seq`. API 6종: `AdminEventSubmissionController`(`/api/admin/event-submissions` — 심사 큐·상세·승인·반려) + `AdminApprovedEventController`(`/api/admin/events` — 승인 행사 목록·노출 중지). 역할 검사는 컨트롤러에 없다(기존 `/api/admin/**` matcher, SecurityConfig 무변경). **심사 조회**: 큐는 users 세타 조인 + 생성자 프로젝션에 `size(s.locations)` 상관 서브쿼리(항목마다 위치를 다시 읽지 않는다), 건수 3종은 필터 무관 전체 집계, 상세는 신청 계정 정보 + `exposureRect`(전 위치 사각형의 경계 — 조회 시점 계산, 저장 안 함) + 이력. 위치 표현은 `EventSubmissionLocationView`로 **행사 운영자 상세와 같은 조립기**를 쓴다(심사자와 신청자가 다른 그림을 보면 반려 사유가 가리키는 대상이 어긋난다). **승인**(D-1·D-2): 상태·기간 선검사(시퀀스 헛소비 방지) → 조건부 UPDATE로 IN_REVIEW→APPROVED 전이와 승인 번호 부여를 한 문장에(동시 승인의 진짜 판정) → 0행이면 재조회로 13430/13450 분기 → 이력 APPROVED 행 → **재로드 후**(벌크 UPDATE가 영속성 컨텍스트를 우회해 앞서 읽은 엔티티는 스테일) 미션 등재. 지역축제→`MissionType.EVENT`(축제 칩)·팝업스토어→`POPUP`, 판정 격자는 전 위치 셀 **합집합**(`EventSubmissionCells` — 상세의 칸 수와 같은 산술), 대표 격자는 그 합집합에 규칙 재적용, 이미지는 확정본을 `missions/org-submission/{uuid}.{ext}`로 복사해 공개 URL 저장(원본은 심사·콘솔 상세가 계속 읽으므로 유지, 롤백 보상 삭제는 복사 호출 앞 등록). 승인 번호는 `APR-{KST연도}-{4자리}`. **반려**: 항목 코드 1개 이상(빈 배열·허용 밖·중복 전부 13454 — Jackson enum 역직렬화에 맡기면 공통 400으로 뭉개진다)과 서술을 이력 반려 행에 쌓고(D-3 저장 원천 단일), 메일 없음. **승인 행사 목록**(D-11): 원천은 APPROVED 신청 행이고 안정 식별자는 신청 id, 탭은 저장하지 않고 **KST 오늘과 기간의 파생**(`TAB_CASE` 컴파일 상수 한 벌을 필터·표시·건수 3자리가 공유 — 식이 갈리면 뱃지 숫자와 목록이 어긋난다), 중지된 행사도 탭에 남고 `unpublished`로 구분. **노출 중지**(D-3): 조건부 UPDATE(`APPROVED AND unpublished_at IS NULL`) → 같은 트랜잭션에서 `MissionRegistrationService.hide` → 커밋 후 신청 계정 이메일로 사유 발송(실패는 `emailSent:false`, 재발송 API 없음 — 저장된 사유가 수기 재통지 재료). 에러 코드 13450~13456 신설(목록 파라미터 두 코드는 판정·메시지가 MSG-499의 1424·1425와 같고 **대역만 event** — 도메인 예외는 도메인이 갖는다). 테스트 42건(심사 조회 8·승인 반려 12·중지 9·목록 6·비트랜잭션 4 — 동시 승인 13450·승인 원자성 롤백·승인/중지 커밋 후 스냅숏 무효화 + 스토어 단위 3). **참여형(이벤트) 승인 분기는 미구현** — `publish()`의 유형 switch에 `default`가 없어 MSG-502가 참여형 값을 추가하면 **컴파일이 깨지는 것이 의도된 가드**이고, 중지의 참여형 경로(위치 숨김·부모 회차 노출 영역 재계산)도 no-op이라 FR-EVENT-18은 절반만 성립한다(M6/MSG-502 대기) +- MSG-500: 관리자 심사·승인 반영·노출 관리 — **V51** `event_submissions` +4컬럼(`approval_no` UNIQUE·`published_mission_id` FK(NO ACTION)·`unpublished_at`·`unpublish_reason`) + CHECK 3개(`(status='APPROVED') = (approval_no IS NOT NULL)` · 중지 기록 짝 · 중지는 승인 행만) + 승인 번호 전역 시퀀스 `event_submission_approval_no_seq`. API 6종: `AdminEventSubmissionController`(`/api/admin/event-submissions` — 심사 큐·상세·승인·반려) + `AdminApprovedEventController`(`/api/admin/events` — 승인 행사 목록·노출 중지). 역할 검사는 컨트롤러에 없다(기존 `/api/admin/**` matcher, SecurityConfig 무변경). **심사 조회**: 큐는 users 세타 조인 + 생성자 프로젝션에 `size(s.locations)` 상관 서브쿼리(항목마다 위치를 다시 읽지 않는다), 건수 3종은 필터 무관 전체 집계, 상세는 신청 계정 정보 + `exposureRect`(전 위치 사각형의 경계 — 조회 시점 계산, 저장 안 함) + 이력. 위치 표현은 `EventSubmissionLocationView`로 **행사 운영자 상세와 같은 조립기**를 쓴다(심사자와 신청자가 다른 그림을 보면 반려 사유가 가리키는 대상이 어긋난다). **승인**(D-1·D-2): 상태·기간 선검사(시퀀스 헛소비 방지) → 조건부 UPDATE로 IN_REVIEW→APPROVED 전이와 승인 번호 부여를 한 문장에(동시 승인의 진짜 판정) → 0행이면 재조회로 13430/13450 분기 → 이력 APPROVED 행 → **재로드 후**(벌크 UPDATE가 영속성 컨텍스트를 우회해 앞서 읽은 엔티티는 스테일) 미션 등재. 지역축제→`MissionType.EVENT`(축제 칩)·팝업스토어→`POPUP`, 판정 격자는 전 위치 셀 **합집합**(`EventSubmissionCells` — 상세의 칸 수와 같은 산술), 대표 격자는 그 합집합에 규칙 재적용, 이미지는 확정본을 `missions/org-submission/{uuid}.{ext}`로 복사해 공개 URL 저장(원본은 심사·콘솔 상세가 계속 읽으므로 유지, 롤백 보상 삭제는 복사 호출 앞 등록). 승인 번호는 `APR-{KST연도}-{4자리}`. **반려**: 항목 코드 1개 이상(빈 배열·허용 밖·중복 전부 13454 — Jackson enum 역직렬화에 맡기면 공통 400으로 뭉개진다)과 서술을 이력 반려 행에 쌓고(D-3 저장 원천 단일), 메일 없음. **승인 행사 목록**(D-11): 원천은 APPROVED 신청 행이고 안정 식별자는 신청 id, 탭은 저장하지 않고 **KST 오늘과 기간의 파생**(`TAB_CASE` 컴파일 상수 한 벌을 필터·표시·건수 3자리가 공유 — 식이 갈리면 뱃지 숫자와 목록이 어긋난다), 중지된 행사도 탭에 남고 `unpublished`로 구분. **노출 중지**(D-3): 조건부 UPDATE(`APPROVED AND unpublished_at IS NULL`) → 같은 트랜잭션에서 `MissionRegistrationService.hide` → 커밋 후 신청 계정 이메일로 사유 발송(실패는 `emailSent:false`, 재발송 API 없음 — 저장된 사유가 수기 재통지 재료). 에러 코드 13450~13456 신설(목록 파라미터 두 코드는 판정·메시지가 MSG-499의 1424·1425와 같고 **대역만 event** — 도메인 예외는 도메인이 갖는다). 테스트 42건(심사 조회 8·승인 반려 12·중지 9·목록 6·비트랜잭션 4 — 동시 승인 13450·승인 원자성 롤백·승인/중지 커밋 후 스냅숏 무효화 + 스토어 단위 3). **참여형(이벤트) 승인 분기**는 MSG-502 머지 후 이어 붙였다 — `publish()`의 유형 switch에 `default`가 없어 MSG-502가 참여형 값을 추가한 순간 컴파일이 깨졌고(의도된 착수 신호), 그 자리에 부모 회차 비관 잠금(`EventOccurrenceRepository.findWithLockById` 신설) → 겹침 **두 방향** 사전 검사(회차 기존 격자 + 이번 신청 위치 상호, 13452 첫 소비) → `EventLocation` 전개(`sub-{submissionNo}-{n}` 자연키·이름 100자 접미사 절단·ETC·displayOrder 이어붙임·저장된 대표 격자) → 참여 속성 6종 복사(V50 신청 컬럼 → V51 위치 컬럼) → 커버 이미지 `event-locations/org-submission/` 공개 복사(신청당 1회, 위치 공유) → 노출 영역 합집합 확장(`EventOccurrence.update` 경유라 scheduleRevision 불변 = 일정 변경 알림 미발송)을 채웠다. `publish()` 반환이 `long` → `Long`(참여형 null — 산출물이 여러 위치라 신청 행에 실을 단일 링크가 없다). 없는 부모는 502의 13440 재사용, 끝난 부모는 13441이 아니라 13451(독자가 신청자가 아니라 승인하는 관리자다). **중지의 참여형 경로**도 함께 닫혔다 — 회차 잠금(승인과 **같은 순서**, AB-BA 방지) → `hideByLocationKeyPrefix` 벌크 UPDATE → 남은 가시 위치로 노출 영역 재계산. 격자 조회를 둘로 가른 것이 계약이다: 겹침 검사는 숨긴 위치 포함 전량(`uq_event_grid_per_occ`가 숨김을 안 본다), 영역 재계산은 가시만. 숨김 소비는 행사 쪽 7곳(회차 상세 위치 목록·위치 벌크·격자의 행사 위치·업로드 위치 확인·위치 영상 목록·영상 상세·댓글/도움돼요 직링크)에 기존 404 수렴으로 추가. `EventLocationResponseDto`에 nullable 5필드(참여 속성, imageUrl은 publicUrl 조립값 — 시드 위치는 전부 null). **업로드 경로의 회차 접근은 읽기 전용인 것이 계약**이다(잠금 없는 `findById`+`refresh`) — 위치 잠금 뒤에 회차 잠금을 더하면 승인·중지와 데드락이다. 테스트 +12(승인 8·중지 3·동시 승인 직렬화 1). SRS는 502와 `FR-EVENT-17`이 충돌해 우리 두 요구를 18·19로 재번호했다 - 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/spec/MSG-500.md b/docs/spec/MSG-500.md index 9ddb472a..ff0af0bf 100644 --- a/docs/spec/MSG-500.md +++ b/docs/spec/MSG-500.md @@ -616,7 +616,7 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic ## 작업 로그 -### 2026-08-30 — 구현 완료 (Owner B 단독, M1~M5 / M6 보류) +### 2026-08-30 — 구현 완료 (Owner B 단독, M1~M5) **완료 모듈 5개** (커밋 19건, 신규·갱신 테스트 76건 green — 전체 501 클래스 2,932건 실패 0) @@ -633,9 +633,9 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic - **M5 아이디 변경 심사**: `AdminEmailChangeRequestService`, 관리자 API 3종(기존 `AdminOrgAccountController` 확장), `UserErrorCode` 1427~1429 (`AdminEmailChangeReviewTest` 15건, `EmailChangeApprovalConcurrencyTest` 2건) -**M6(이벤트 참여형 승인)은 착수하지 않았다** — MSG-502(V50·참여형 유형 값·부모 회차 컬럼)가 develop에 -머지되기 전이라 읽을 컬럼 자체가 없다. 스펙 §구현 순서 의존이 예고한 "미지원 유형 방어"를 **컴파일 가드**로 -남겼다(아래 인계 참조). +**M6(이벤트 참여형 승인)은 이 시점에 보류였다** — MSG-502(V50·참여형 유형 값·부모 회차 컬럼)가 develop에 +머지되기 전이라 읽을 컬럼 자체가 없었다. 스펙 §구현 순서 의존이 예고한 "미지원 유형 방어"를 **컴파일 가드**로 +남겼고, 같은 날 MSG-502 머지 후 그 가드가 발화해 M6 를 채웠다(아래 2026-08-30 M6 항목). **구현 결정 (스펙과 달라진 것)** @@ -712,8 +712,9 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic - `CREATE SEQUENCE event_submission_approval_no_seq` — 연도별 리셋 없는 전역 시퀀스(신청 번호와 같은 구조). - `missions.hidden_at TIMESTAMP` · `event_locations.hidden_at TIMESTAMP` — NULL = 노출 중. 기존 행 전부 NULL. - `event_locations` +6컬럼 (`organizer_name(100)`·`description TEXT`·`starts_on DATE`·`ends_on DATE`· - `participation_method TEXT`·`image_key(255)`) — **DDL만 넣고 쓰는 코드는 M6**다. 엔티티 필드도 아직 없다 - (Hibernate `validate`는 DB에만 있는 컬럼을 문제 삼지 않는다). 시드 위치는 전부 NULL이라 기존 행사 조회 불변. + `participation_method TEXT`·`image_key(255)`) — M1 시점엔 DDL만 넣고 엔티티 필드는 두지 않았다 + (Hibernate `validate`는 DB에만 있는 컬럼을 문제 삼지 않는다). **M6가 엔티티 필드와 쓰기·읽기를 채웠다.** + 시드 위치는 전부 NULL이라 기존 행사 조회 불변. - `org_email_change_requests` +2컬럼 (`processed_at TIMESTAMP`·`reject_reason TEXT`) + CHECK 2개 — `chk_org_email_change_processed ((status='PENDING') = (processed_at IS NULL))` · `chk_org_email_change_reject_reason ((status='REJECTED') = (reject_reason IS NOT NULL))`. @@ -771,7 +772,7 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic | 13430 | 404 | `AdminEventSubmissionService.getSubmission`·`findForReview`·`transitionFailure`, `AdminApprovedEventService.unpublishFailure` | 없는 신청, 승인되지 않은 신청의 중지(관리자에게도 "그런 행사는 없다"가 맞는 답) | | 13450 | 409 | `AdminEventSubmissionService.findForReview`(빠른 거절)·`transitionFailure`(0행 판정) | 심사 중이 아닌 신청의 승인·반려, 동시 승인의 패자 | | 13451 | 409 | `requirePeriodNotPassed` (전이 **앞**) | KST 오늘이 `ends_on`을 지남 — 시퀀스를 소비하기 전에 거른다 | -| 13452 | 409 | (미사용, M6 예약) | 참여형 격자 충돌 — 승인 분기가 미구현이라 던지는 자리가 아직 없다 | +| 13452 | 409 | `AdminEventSubmissionService.requireNoGridConflict` (M6) | 부모 회차 기존 격자와 겹침, 또는 같은 신청의 위치 상호 겹침 | | 13453 | 409 | `AdminApprovedEventService.unpublishFailure` | 이미 중지된 행사의 재중지 | | 13454 | 400 | `parseReasonCodes` (전이 **앞**) | 반려 항목 코드가 비었거나 허용 밖이거나 중복 | | 13455 | 400 | `parseStatus`(심사 큐)·`parseTab`(행사 목록) | 지원하지 않는 상태·탭 필터 | @@ -817,15 +818,96 @@ CLAUDE.md 협업 원칙의 Owner B 목록이다. grid 접점은 `GridQueryServic (`requireStamped`가 `existsById`로 기존 스탬프를 인정하기 때문). **첫 완료는 닫혀 있다** — 스탬프가 없는 사용자는 보존 확인(MSG-459 D-9)에서 12409로 전체 롤백된다. 중지는 노출을 끊는 조치이지 기록을 되돌리는 조치가 아니므로 이 창을 더 좁히지 않았다. -- **FR-EVENT-17은 절반만 성립한다.** 중지가 숨기는 것은 미션 산출물뿐이고, 참여형 승인이 만들 위치 - (`event_locations.hidden_at`)와 부모 회차 노출 영역 재계산은 no-op이다 — 참여형 승인 자체가 없어 숨길 - 대상이 아직 생기지 않는다. M6에서 닫는다. - -**MSG-502 인계 2줄** - -- `AdminEventSubmissionService.publish()`의 유형 switch에 `default`가 없다 — MSG-502가 - `EventSubmissionType`에 참여형 값을 더하는 순간 **이 분기가 컴파일되지 않는 것이 의도된 미구현 가드**다. - 참여형 승인(부모 회차 아래 위치 생성·참여 속성 복사·격자 겹침 13452·회차 잠금·노출 영역 확장)을 그 자리에 채운다. -- `AdminApprovedEventService.unpublish()`는 `publishedMissionId != null`일 때만 숨김을 부른다 — 참여형 - 승인분은 그 링크가 없으므로, 그 분기 옆에 위치 숨김(`event_locations.hidden_at`)과 부모 회차 노출 영역 - 재계산을 더한다. V51이 컬럼을 이미 깔아 두었으니 마이그레이션은 필요 없다. +- **FR-EVENT-18(노출 중지)은 M5 시점에 절반이었다** — 미션 산출물만 숨기고 참여형 위치는 숨길 대상 자체가 + 없었다. **M6가 나머지 절반을 채워 이제 전부 성립한다**(위치 숨김 + 부모 회차 노출 영역 재계산). + +### 2026-08-30 — M6 이벤트 참여형 승인·중지 (MSG-502 머지 후) + +MSG-502가 develop에 들어오면서 M5 시점에 남겨 둔 컴파일 가드가 발화했다 — +`AdminEventSubmissionService.publish()`의 유형 switch가 새 `EVENT` 값을 처리하지 않아 빌드가 깨졌고, +그것이 곧 M6 착수 신호였다(설계대로 동작한 유일한 "의도된 빌드 실패"). + +**병합 해소 7파일** — `EventErrorCode`(502의 13440·13441 + 우리 13450~13456 합집합), +`EventSubmissionServiceImpl`(우리 조립 위임 + 502 부모 검증 의존 통합, 조립기로 옮겨간 zone·grid 의존은 제거), +`EventSubmissionFixtures`·`EventSubmissionValidationTest`(양쪽 추가 통합), `status.md`(합집합), +`rtm.md`(손으로 합치지 않고 재생성). + +**SRS 요구사항 ID 재번호** — 두 레인이 `FR-EVENT-17`을 동시에 점유했다(502 참여형 접수 / 우리 노출 중지). +합집합이 성립하지 않는 유일한 충돌이라, **develop에 먼저 실린 502를 고정**으로 두고 우리 두 행을 밀었다: +노출 중지 → `FR-EVENT-18`, 승인 행사 목록 → `FR-EVENT-19`. 테스트 `// 검증:` 주석과 이 문서·status.md의 +참조도 함께 옮겼다. 병렬 레인이 같은 대역을 잡는 경합이 developCode에 이어 **요구사항 ID에서도 재현**된 +사례다(에러 코드는 표에 행을 먼저 추가하는 규칙이 있지만 SRS ID에는 그런 예약 절차가 없다). + +**참여형 승인** (`publish()`의 EVENT 분기 → `publishParticipation`) — 부모 회차 비관 잠금 → 종료 회차 거절 → +겹침 두 방향 사전 검사 → 위치 전개 + 참여 속성 6종 복사 → 커버 이미지 공개 복사 → 노출 영역 확장. +`publish()`의 반환을 `long`에서 `Long`으로 바꿔 참여형은 null을 준다 — 산출물이 위치 여러 행이라 +신청 행에 실을 단일 링크가 없고, 호출부가 null이면 `linkPublishedMission`을 건너뛴다. + +**참여형 중지** (`unpublish()`의 `else if`) — `publishedMissionId`가 없고 유형이 EVENT면 회차 잠금 → +`hideByLocationKeyPrefix` 벌크 UPDATE → 남은 가시 위치로 노출 영역 재계산. +격자 조회를 **둘로 가른 것이 핵심**이다: 겹침 검사는 `findGridIdsByOccurrenceId`(숨긴 위치 포함 전량), +영역 재계산은 `findVisibleGridIdsByOccurrenceId`(가시만). `uq_event_grid_per_occ`는 행이 있으면 걸리는 +제약이라 숨김을 보지 않으므로, 겹침 검사가 가시 격자만 보면 중지된 위치와 같은 칸을 새 승인이 집어 +커밋 시점 500이 된다(회귀 테스트로 고정). + +**숨김 소비 7곳** — 회차 상세 위치 목록 · 위치 벌크(route 후보) · 격자의 행사 위치 · 업로드 위치 확인 · +위치 영상 목록 · 영상 상세 · 댓글·도움돼요 직링크. 전부 기존 존재 은닉 404(13405/13406)로 수렴한다. + +**잠금 순서 전수 대조** (event 도메인 쓰기 경로 전부): + +| 경로 | 잡는 잠금 | 순서 | +|---|---|---| +| 참여형 승인 | 회차(`findWithLockById`) → 위치 INSERT | 회차 → 위치 | +| 참여형 중지 | 회차(`findWithLockById`) → 위치 벌크 UPDATE | 회차 → 위치 | +| 행사 영상 업로드 | 위치(`findWithLockById`)만 | 위치 (회차는 **잠그지 않는다**) | + +세 경로가 순환을 만들지 않는다. **업로드 경로의 회차 접근은 읽기 전용인 것이 계약이다** — +`occurrenceRepository.findById` + `entityManager.refresh(occurrence)`는 둘 다 잠금 없는 SELECT이고, +여기에 나중에 회차 잠금을 (위치 잠금 **뒤에**) 추가하면 순서가 위치 → 회차가 되어 승인·중지와 AB-BA +데드락이 된다. 업로드에 회차 잠금이 필요해지면 위치 잠금보다 **앞에** 잡아야 한다. + +**부모 검증 에러 코드는 하나만 재사용했다** — 없는 부모 회차는 502의 `PARENT_EVENT_NOT_FOUND`(13440)를 +그대로 쓰고, 끝난 부모 회차는 502의 13441이 아니라 **`SUBMISSION_PERIOD_PASSED`(13451)**를 쓴다. 독자가 +다르기 때문이다: 13441의 문구("종료된 이벤트에는 참여를 신청할 수 없습니다")는 신청하는 행사 운영자를 +향하는데, 여기서 막히는 사람은 승인하려는 관리자다. 반면 13440("참여할 이벤트를 찾을 수 없습니다")은 +두 독자에게 같은 사실이라 새 상수를 만들 이유가 없다. + +**노출 계약 추가** — `EventLocationResponseDto`에 nullable 5필드(organizerName·description· +participationStartsOn·participationEndsOn·participationMethod·imageUrl). `imageUrl`은 저장 키가 아니라 +`AwsProperties.publicUrl` 조립값이고(키를 저장하고 조회 시 조립하는 D-6 규칙), 시드 위치는 전부 null이라 +기존 화면은 필드가 는 것 외에 달라지지 않는다(불변 확인 테스트 포함). + +**테스트 +12** — 참여형 승인 8(전개·속성 복사·시드 null·개정 번호 불변·기존 겹침 13452·위치 상호 겹침 +13452·이름 100자 절단·부모 종료 13451), 중지 3(상세·격자 조회 소거 / 영역 축소 + 개정 번호 불변 / +숨긴 격자의 재승인 13452), 동시 승인 직렬화 1(비트랜잭션 — 잠금이 없으면 커밋 시점 500이 될 자리가 +읽히는 13452로 끝나는지). 실입력은 **대표 위치 한 곳**이고(PRD v2.2 FR-8), 구현의 일반성(이름 순번·위치 +상호 겹침)만 접수를 우회한 SQL 시드로 확인한다. + +**기존 테스트 1건 조정** — MSG-502가 픽스처의 고정 날짜를 `daysFromToday`(상대값)로 바꿔, M3의 KST→UTC +경계 단언이 깨졌다. 기대값을 같은 상대 날짜에서 만들되 변환은 `minusHours(9)`로 독립적으로 썼다(구현을 +복사하면 검증이 공허해진다). + +**노출 영역 산술 공용화와 재시드 보존** (M6 push 전 Codex 리뷰 2라운드 반영) + +- 경계 사각형 접기를 `event/support/EventExposureArea` 한 곳으로 모았다 — 승인(기존 ∪ 새 셀)·중지(남은 + 가시 격자)·재시드(시드 ∪ 가시 참여 격자) 세 경로가 같은 산술을 쓰고 입력만 다른 것이 정책이다. +- **시더가 승인 영역을 되돌리던 결함을 닫았다.** `upsertOccurrence`가 매 기동 시드 사각형을 그대로 써서, + 승인이 넓힌 영역이 재기동마다 시드 값으로 돌아가고 원래 범위 밖의 참여 위치가 뷰포트 필터에서 빠졌다 + (위치 행은 남는데 지도 칩만 사라지는 조용한 발견성 손실). 이제 시드 사각형을 바닥값으로 두고 + 가시 참여 격자를 얹는다. +- 합집합 입력을 **참여 위치(`sub-` 접두)로 한정**한 것이 두 번째 수정이다. 시더는 회차를 먼저 갱신하고 + 위치를 나중에 동기화(`syncGrids`)하므로, 전 가시 격자를 넣으면 시드가 줄이거나 옮긴 옛 시드 격자가 + 아직 살아 있는 채 합집합에 들어가 영역이 부푼 채 다음 재기동까지 남는다. 접두 한정이면 계산이 동기화 + 순서와 무관해지고, 의미도 맞다: 시드 위치의 영역 기여는 시드 사각형이 정본이고 참여 위치만 확장분이다. + 접두 상수의 집은 `EventLocation.SUBMISSION_KEY_PREFIX` 하나다(승인·중지·시더가 공유). +- **중지의 재계산은 전 가시 위치 기준을 유지했다** (같은 이분으로 통일하지 않았다). 런타임에는 시드 + 사각형을 알 방법이 없어(시드 파일에만 있다), 참여 위치만 세면 시드 위치가 커버하던 범위까지 사라진다. + 전 가시 위치면 시드 콘텐츠는 그대로 남고 잃는 것은 작도된 여백뿐이며, 그 여백은 다음 재기동의 시드 + 합집합이 되돌린다(자기 치유). 두 경로의 비대칭은 "시드 값을 아는 쪽만 바닥값을 깐다"는 한 문장이다. +- 검증 2건(`EventSeederTest`): 승인이 넓힌 영역이 재시드에 유지되고 중지된 참여 위치는 넓히지 않는다 · + 시드 위치가 줄어든 재시드가 영역을 부풀리지 않는다. 후자는 수정을 되돌려 실패하는 것을 실측했다. + +**미채택 2건** (같은 리뷰) — ① V50 재번호: MSG-502가 develop에 이미 머지된 산출물이라 번호가 확정이다. +② 유령 칩(가시 위치가 0인 회차가 옛 영역으로 계속 노출된다는 지적): **도달 불가**다. 시드 회차는 시드 +위치를 최소 하나 갖고 그 위치는 숨김 대상이 아니다(중지는 `sub-` 접두 위치만 숨긴다). 가시 위치 0은 +시드 문서에서 위치를 통째로 지운 경우뿐이고, 그때는 회차 자체가 노출 대상이 아니다. From e929bdfcf07f7ef2600515e4ca188060ef00c3a2 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:31:18 +0900 Subject: [PATCH 33/35] =?UTF-8?q?MSG-500=20docs:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=ED=98=95=20=EC=99=84=EA=B2=B0=EB=A1=9C=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=91=90=20=EA=B1=B4=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EB=90=A8=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/rtm.md | 4 ++-- docs/srs-changelog.md | 1 + docs/srs.md | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/rtm.md b/docs/rtm.md index d59e560e..389d85fc 100644 --- a/docs/rtm.md +++ b/docs/rtm.md @@ -201,10 +201,10 @@ | FR-EVENT-12 | 구현됨 | MissionAwardQueryTest, MissionProgressQueryTest, MissionVideoCountQueryTest, MissionVideoListQueryTest, MissionVisitedGridQueryTest | | FR-EVENT-13 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest, EventSubmissionImageStoreTest, EventSubmissionValidationTest | | FR-EVENT-14 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest | -| FR-EVENT-15 | 진행 중 | AdminEventSubmissionControllerTest, AdminEventSubmissionReviewTest, EventSubmissionApprovalConcurrencyTest, EventSubmissionImageStoreTest, EventSubmissionReviewSchemaTest, MissionQueryServiceCacheTest | +| FR-EVENT-15 | 구현됨 | AdminEventParticipationApprovalTest, AdminEventSubmissionControllerTest, AdminEventSubmissionReviewTest, EventParticipationApprovalConcurrencyTest, EventSubmissionApprovalConcurrencyTest, EventSubmissionImageStoreTest, EventSubmissionReviewSchemaTest, MissionQueryServiceCacheTest | | FR-EVENT-16 | 구현됨 | EventOccurrenceRepositoryTest, OrgEventControllerTest, OrgEventQueryServiceTest | | FR-EVENT-17 | 구현됨 | EventSubmissionControllerTest, EventSubmissionValidationTest | -| FR-EVENT-18 | 진행 중 | AdminEventUnpublishTest, EventSubmissionApprovalConcurrencyTest | +| FR-EVENT-18 | 구현됨 | AdminEventParticipationApprovalTest, AdminEventUnpublishTest, EventSubmissionApprovalConcurrencyTest | | FR-EVENT-19 | 구현됨 | AdminApprovedEventListTest | | FR-ROUTE-01 | 진행 중 | AnonymousReadAccessHttpTest, RouteCandidateCollectorTest, RouteControllerTest, RouteIntentClientTest, RouteRecommendServiceTest | | FR-ROUTE-02 | 진행 중 | RouteCandidateCollectorTest, RouteRecommendServiceTest | diff --git a/docs/srs-changelog.md b/docs/srs-changelog.md index 04fbcdc7..b208441b 100644 --- a/docs/srs-changelog.md +++ b/docs/srs-changelog.md @@ -146,3 +146,4 @@ union이 붙어 양쪽 행이 그대로 남는다. 2026-08-14에 같은 자리 | 2026-08-30 | FR-EVENT-17(관리자 노출 중지 — 사유 필수, 사용자 대면 전 경로 즉시 제외, 직접 접근 포함, 완료 기록·뱃지 비회수, 운영자 사유 통지)과 FR-EVENT-18(승인 행사 상태별 목록과 건수, 중지 식별)을 진행 중으로 등재했다. PRD FR-20·FR-25에 대응하는 SRS 행이 없어 MSG-500의 M4 테스트가 인용할 ID가 비어 있던 갭을 선등재로 닫았다(MSG-390의 소급 주석 교훈) | MSG-500 구현 중 (2026-08-30), PRD `docs/prd/event-submission.md` FR-20·25 | | 2026-08-30 | MSG-500 구현 반영. FR-USER-16을 구현됨으로 올렸다(잔여였던 아이디 변경 요청의 관리자 검토·승인 처리 완료 — 승인은 요청 전이와 이메일 교체 한 트랜잭션, 검토 시점 가드, 새 이메일 통지). FR-EVENT-18도 구현됨(승인 행사 상태별 목록, KST 파생 탭). FR-EVENT-15·17은 진행 중 유지 — 미션 경로(심사·승인·반려·중지)는 구현됐지만 이벤트 참여형 승인 반영과 위치 숨김·노출 영역 재계산이 MSG-502(V50) 머지 후 잔여라서다. 검증 테스트는 같은 브랜치의 구현 커밋들에 동반된다 | MSG-500 구현 (2026-08-30), 브랜치 feature/MSG-500-admin-review | | 2026-08-29 | FR-EVENT-17 등재 (구현됨). 등록 유형 "이벤트"의 참여 신청 접수를 등재했다. 신규 엔드포인트 없이 MSG-498의 신청 API 5개가 EVENT 유형을 받는 구조라 접수 뒤 흐름(심사 중·신청 번호·내 목록·상세·재제출)은 세 유형이 같은 경로를 쓴다. 위치는 대표 위치 한 곳으로 한정했는데(FR-EVENT-13의 여러 위치 허용에 대한 예외이고 PRD v2.2가 FR-8에 예외 문장으로 명문화했다) 대표 격자 한 점만 받는 안은 기각했다 — 승인 결과가 회차 아래 위치 1행 + 격자 전개라 점 하나로는 승인 시점에 영역을 지어낼 수 없다. 종료 판정은 `ends_at <= now`로 FR-EVENT-16 노출 조건의 정확한 여집합이라 종료 정각에도 목록과 신청의 경계가 갈리지 않는다. 공개 기간이 부모 회차 기간 안에 들어야 한다는 교차 검증은 두지 않았다(부모는 UTC 시각, 신청은 KST 날짜 라벨이라 경계일 단정이 애매하고 타당성은 관리자 심사가 본다). FR-EVENT-13 문장에는 세 번째 유형의 상호참조를 달았다. 승인 반영(FR-EVENT-15)은 MSG-500 그대로 계획 유지 | MSG-502 구현 (2026-08-29), 스펙 `docs/spec/MSG-502.md` | +| 2026-08-30 | FR-EVENT-15와 FR-EVENT-18을 구현됨으로 올렸다. MSG-502 머지 후 참여형 잔여(승인의 위치 전개·참여 속성 노출, 중지의 위치 숨김·노출 영역 재계산)가 같은 브랜치에서 완결돼 두 요구의 "승인 산출물 전부" 조건이 성립했다. 병합 과정에서 502가 develop에 선점한 FR-EVENT-17(참여형 접수)과 이 브랜치의 옛 17(중지)·18(목록)이 충돌해, 중지를 18로·목록을 19로 재번호하고 테스트 주석과 문서 참조를 함께 옮겼다(ID 불변 원칙은 develop 선점자 기준) | MSG-500 M6 구현 (2026-08-30), 브랜치 feature/MSG-500-admin-review | diff --git a/docs/srs.md b/docs/srs.md index 3fe49839..bff0750c 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -394,10 +394,10 @@ FillMap은 사용자가 방문한 장소를 30초 이내 짧은 영상으로 기 | FR-EVENT-12 | 행사 영상은 미션 집계 어디에도 잡히지 않는다. 미션 진행도, 완료 판정, 방문 스팟, 미션 영상 목록(첫 페이지와 다음 페이지), 격자별 미션 영상 수 전부에서 제외된다. 행사 영상이 일반 업로드의 부수효과(점령, 스트릭, 핫스코어, 뱃지 등)를 그대로 받는 가운데 유일한 제외 항목이다 | 구현됨 | 구 PRD "미션 축 연계 비목표" 승계 확정 (2026-08-20). 계약 정본 `docs/spec/MSG-438.md` §부수효과 계약. MSG-450 구현 (2026-08-22 — `videos` 를 직접 조인하는 미션 쿼리 여섯 곳(진행도·완료 판정·방문 스팟·미션 영상 목록 두 쿼리·격자별 영상 수)에 `NOT EXISTS event_videos` 안티조인을 넣었다. 쿼리 1~3 은 LEFT/INNER 조인의 ON 절, 4~6 은 WHERE 절이라 미진행 미션의 0 진행도가 유지된다. 술어 동등 계약을 안티조인 포함으로 갱신, 새 테이블·인덱스·마이그레이션 없음). 쓰기 경로의 판정 시점 가시성 계약은 MSG-440 이 미션 판정 훅 자체를 행사 업로드에서 빼는 방식으로 이미 만족한다(`VideoServiceImpl.confirmAndStore` 는 미션 훅 밖) | | FR-EVENT-13 | 행사 운영자는 등록 유형(지역축제, 팝업스토어) 중 하나를 골라 행사를 신청할 수 있고 유형마다 기본 정보 항목이 다르다(세 번째 유형인 이벤트 참여형은 승인 이벤트를 골라 참여를 신청하는 별도 구조로 MSG-501·MSG-502에 분리, FR-EVENT-17). 위치는 지도 격자 사각형 영역으로 지정하며 신청 하나에 위치 여러 개, 위치 하나에 사각형 여러 개를 담을 수 있고, 위치당 영역은 사각형 합집합 기준 최대 격자 81칸이다. 대표 격자는 신청자가 아니라 서버가 계산한다. 접수된 신청은 심사 중 상태가 되고 신청 번호가 부여된다. 임시 저장은 미포함 확정 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 개정 (2026-08-28 승인, 유형 재편·81칸 상한 확정·임시 저장 미포함) · 스펙 `docs/spec/MSG-498.md` (2026-08-28). 위치 영역 형식은 기존 시드 areaRects와 동일. MSG-498 구현 (2026-08-29 — `POST /api/org/event-submissions` + presign, V49 스키마 4테이블, 81칸 합집합 판정, 신청 번호 FM-{연도}-{4자리} 전역 시퀀스, 대표 격자 서버 계산) | | FR-EVENT-14 | 행사 운영자는 자기 신청의 목록과 상태별 건수, 상세(상태 변경 이력과 반려 사유 포함)를 조회할 수 있다. 다른 운영자의 신청은 존재를 은닉하는 단일 실패 응답이다. 반려된 신청만 수정해 재제출할 수 있고 재제출하면 심사 중 상태로 돌아간다 | 구현됨 | PRD `docs/prd/event-submission.md` v2.1 (2026-08-28 승인) · 스펙 `docs/spec/MSG-498.md`. MSG-498 구현 (2026-08-29 — 내 목록 `GET .../my`(상태별 건수는 목록에서 파생), 상세(이력·반려 사유 동봉), 존재 은닉 13430 단일 응답, 재제출은 REJECTED 한정 조건부 UPDATE 원자 전이. 반려 행 쓰기는 MSG-500이 처음 실행) | -| FR-EVENT-15 | 관리자는 신청을 상태별로 조회하고 상세에서 위치 사각형과 노출 영역을 검토해 승인하거나 반려할 수 있다. 반려에는 항목 코드(행사 기간, 위치 영역, 홍보 이미지, 행사 정보 중 1개 이상)와 자유 서술이 필수다. 승인 반영은 유형에 따라 갈린다. 지역축제 승인분은 축제 미션으로, 팝업스토어 승인분은 팝업 미션으로 등재되어 지도 홈의 기존 축제·팝업 칩 목록에 실리고, 이벤트 참여형 승인분은 부모 이벤트 아래 행사 위치로 반영되며 운영 주체·소개·공개 기간·참여 방식·커버 이미지가 위치의 부가 정보로 함께 노출된다. 승인에는 승인 번호가 부여되고, 승인 산출물은 기존 산출물과 같은 규칙(대표 격자 결정, 회차 내 격자 단일 귀속, 생명주기)을 따르며 승인 즉시 지도 조회에 반영된다 | 진행 중 | PRD `docs/prd/event-submission.md` v2.2 (2026-08-29 정민 확정 — 노출 방식 유형별 분기, MSG-502 브랜치 반영 중). 스펙 `docs/spec/MSG-500.md`. MSG-500이 심사 큐·상세·반려와 지역축제·팝업 승인(미션 등재·커밋 후 스냅숏 무효화)을 구현(2026-08-30, 검증 테스트 다수). **잔여는 이벤트 참여형 승인의 위치 반영과 참여 속성 노출로, MSG-502(V50) 머지 후 착수한다**(M6). 승인 후 일정 수정 정책은 유보 확정(운영팀 문의 안내) | +| FR-EVENT-15 | 관리자는 신청을 상태별로 조회하고 상세에서 위치 사각형과 노출 영역을 검토해 승인하거나 반려할 수 있다. 반려에는 항목 코드(행사 기간, 위치 영역, 홍보 이미지, 행사 정보 중 1개 이상)와 자유 서술이 필수다. 승인 반영은 유형에 따라 갈린다. 지역축제 승인분은 축제 미션으로, 팝업스토어 승인분은 팝업 미션으로 등재되어 지도 홈의 기존 축제·팝업 칩 목록에 실리고, 이벤트 참여형 승인분은 부모 이벤트 아래 행사 위치로 반영되며 운영 주체·소개·공개 기간·참여 방식·커버 이미지가 위치의 부가 정보로 함께 노출된다. 승인에는 승인 번호가 부여되고, 승인 산출물은 기존 산출물과 같은 규칙(대표 격자 결정, 회차 내 격자 단일 귀속, 생명주기)을 따르며 승인 즉시 지도 조회에 반영된다 | 구현됨 | PRD `docs/prd/event-submission.md` v2.2 (2026-08-29 정민 확정 — 노출 방식 유형별 분기). 스펙 `docs/spec/MSG-500.md`. MSG-500 구현 (2026-08-30 — 심사 큐·상세·반려, 지역축제·팝업 승인은 미션 등재와 커밋 후 스냅숏 무효화, 이벤트 참여형 승인은 MSG-502 머지 후 같은 날 부모 회차 아래 위치 전개·참여 속성 6종 복사·노출 영역 합집합 확장으로 완결. 격자 겹침은 회차 비관 잠금 아래 두 방향 사전 검사 13452. 검증 테스트 다수). 승인 후 일정 수정 정책은 유보 확정(운영팀 문의 안내) | | FR-EVENT-16 | 행사 운영자는 등록 유형 "이벤트"에서 참여할 승인 이벤트 목록을 조회할 수 있다. 목록은 이벤트 카테고리(지역축제와 팝업스토어를 제외한 큰 행사)의 종료 전 회차만 담고, 시·도 필터와 이벤트 이름 검색과 시·도별 건수를 제공하며, 항목마다 이름·기간·장소 라벨·시·도가 담긴다. 노출 시작 전 예정 회차도 이 콘솔 목록에는 보인다 | 구현됨 | MSG-501 구현 (2026-08-28, `GET /api/org/events` — 검증 테스트 25건 동반). PRD `docs/prd/event-submission.md` FR-26 (2026-08-28 최소 개정), v2.1 [행사 운영자 3-1] 이벤트 선택 모달. 부모 범위는 이벤트 카테고리만으로 사용자 확정(2026-08-28, 시안의 지역축제/팝업스토어 배지는 구판 잔재). FR-EVENT-13의 유형 서술(행사방)을 v2.1(이벤트 참여형)로 갱신하는 것은 MSG-498 레인 몫이라 여기서 손대지 않는다 | | FR-EVENT-17 | 행사 운영자는 등록 유형 "이벤트"에서 승인 이벤트 회차 하나를 골라 그 아래 참여를 신청할 수 있다. 참여 방식과 참여할 회차는 이 유형 전용 필수 항목이고, 위치는 대표 위치 정확히 한 곳이다(신청 하나에 위치 여러 개를 허용하는 규칙의 예외). 위치의 사각형 영역·81칸 상한·대표 격자 서버 계산은 다른 유형과 같은 규칙이다. 참여할 회차는 존재해야 하고 이미 종료된 회차(업로드 유예·아카이브 포함)에는 신청할 수 없으며, 종료 판정 경계는 승인 이벤트 목록의 노출 조건과 정확히 반대라 목록에 보이는 회차와 신청이 되는 회차가 항상 같다. 반려본을 고쳐 다시 낼 때 등록 유형과 참여할 회차는 바뀌지 않고, 그 시점에 회차가 끝나 있으면 재제출도 거부한다. 접수 뒤 흐름(심사 중 상태, 신청 번호, 내 목록·상세·재제출)은 다른 유형과 같은 경로다. **이 요구의 범위는 신청 접수까지이고**, 승인 시 참여할 회차 아래 행사 위치로 반영하는 것은 FR-EVENT-15 몫이다 | 구현됨 | PRD `docs/prd/event-submission.md` v2.2 FR-7 이벤트 참여형 · FR-8의 대표 위치 1곳 예외 · 스펙 `docs/spec/MSG-502.md`. MSG-502 구현 (2026-08-29 — 신규 엔드포인트 없이 MSG-498의 신청 API 5개에 EVENT 유형 편입, V50 컬럼 2개(참여 방식·참여할 회차 FK) + type CHECK 재정의 + 유형과 부모의 짝을 강제하는 CHECK, 부모 검증 13440(없는 회차 404)·13441(종료 회차 409, `ends_at <= now` — FR-EVENT-16 노출 조건 `ends_at > now`의 여집합), 대표 위치 2곳 이상은 13439·0곳은 기존 13431, 재제출은 저장된 부모로 종료만 재검증. 검증 테스트 22건 동반). 승인 반영은 MSG-500(FR-EVENT-15)이라 이 행의 범위 밖이다 | -| FR-EVENT-18 | 관리자는 승인된 행사의 노출을 사유와 함께 중지할 수 있다. 중지하면 그 승인 산출물이 사용자 대면 조회와 판정 경로 전부(지도 칩 목록, 격자 선택, 영상 목록, 이미 알고 있는 식별자로의 직접 접근 포함)에서 즉시 사라지고, 행사 운영자에게 사유가 통지된다. 이미 완료한 사용자의 진행 기록과 뱃지 지표는 회수되지 않고, 관리자 화면에서는 중지된 행사의 기록이 계속 조회된다 | 진행 중 | PRD `docs/prd/event-submission.md` FR-20 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-3·D-5. MSG-500이 미션 경로 중지를 구현(2026-08-30 — hidden_at 숨김, 소비 경로 전수 필터·피드 404 수렴, 커밋 후 스냅숏 무효화, 사유 메일. 검증 테스트 다수). **잔여는 참여형 위치 숨김과 부모 회차 노출 영역 재계산으로 MSG-502 머지 후**(M6) — 그 전까지 "승인 산출물 전부"가 절반이다. 중지 해제(재노출)는 미결정이라 범위 밖 | +| FR-EVENT-18 | 관리자는 승인된 행사의 노출을 사유와 함께 중지할 수 있다. 중지하면 그 승인 산출물이 사용자 대면 조회와 판정 경로 전부(지도 칩 목록, 격자 선택, 영상 목록, 이미 알고 있는 식별자로의 직접 접근 포함)에서 즉시 사라지고, 행사 운영자에게 사유가 통지된다. 이미 완료한 사용자의 진행 기록과 뱃지 지표는 회수되지 않고, 관리자 화면에서는 중지된 행사의 기록이 계속 조회된다 | 구현됨 | PRD `docs/prd/event-submission.md` FR-20 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-3·D-5. MSG-500 구현 (2026-08-30 — 미션 경로는 hidden_at 숨김·소비 경로 전수 필터·피드 404 수렴·커밋 후 스냅숏 무효화·사유 메일, 참여형 경로는 MSG-502 머지 후 같은 날 위치 숨김과 부모 회차 노출 영역의 남은 가시 위치 재계산으로 완결. 영상 직링크·댓글·도움돼요까지 단일 초크포인트 가드로 차단. 검증 테스트 다수). 중지 해제(재노출)는 미결정이라 범위 밖 | | FR-EVENT-19 | 관리자는 승인된 행사를 노출 중, 예정, 종료 상태별로 조회할 수 있고 상태별 건수가 함께 제공된다. 상태는 조회 시점과 행사 기간으로 판정하며, 중지된 행사는 목록에서 중지로 식별된다 | 구현됨 | PRD `docs/prd/event-submission.md` FR-25 (2026-08-28 승인). 스펙 `docs/spec/MSG-500.md` D-11. MSG-500 구현 (2026-08-30 — `GET /api/admin/events`, 탭 상태는 KST 오늘로 파생하고 필터·항목·건수가 같은 CASE 식 한 벌을 쓴다. 경계일 검증 포함 테스트 6건) | ### ROUTE: AI 경로 추천 From 1d6595909bb3fd48af3a8e632ae915df17359942 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:39:48 +0900 Subject: [PATCH 34/35] =?UTF-8?q?MSG-500=20fix:=20=EC=8B=9C=EB=8D=94?= =?UTF-8?q?=EB=8F=84=20=ED=9A=8C=EC=B0=A8=20=EC=9E=A0=EA=B8=88=EC=9D=84=20?= =?UTF-8?q?=EC=9E=A1=EC=95=84=20=EC=8A=B9=EC=9D=B8=C2=B7=EC=A4=91=EC=A7=80?= =?UTF-8?q?=EC=99=80=20=EC=83=81=ED=98=B8=20=EB=B0=B0=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=84=B1=EB=A6=BD=EC=8B=9C=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상호 배제는 양쪽이 다 잠가야 성립한다. 기동 시딩이 계약 밖이면 스테일 합집합이 방금 승인된 확장을 되쓰거나 시드 격자 삽입이 커밋 시점 500이 된다. Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/spec/MSG-500.md | 17 ++++++++++++++++- .../com/msg/fillmap/event/seed/EventSeeder.java | 7 +++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/spec/MSG-500.md b/docs/spec/MSG-500.md index ff0af0bf..214776c0 100644 --- a/docs/spec/MSG-500.md +++ b/docs/spec/MSG-500.md @@ -859,9 +859,10 @@ MSG-502가 develop에 들어오면서 M5 시점에 남겨 둔 컴파일 가드 |---|---|---| | 참여형 승인 | 회차(`findWithLockById`) → 위치 INSERT | 회차 → 위치 | | 참여형 중지 | 회차(`findWithLockById`) → 위치 벌크 UPDATE | 회차 → 위치 | +| 재시드(`EventSeeder`) | 회차(`findWithLockById`, 기존 회차만) → 위치·격자 동기화 | 회차 → 위치 | | 행사 영상 업로드 | 위치(`findWithLockById`)만 | 위치 (회차는 **잠그지 않는다**) | -세 경로가 순환을 만들지 않는다. **업로드 경로의 회차 접근은 읽기 전용인 것이 계약이다** — +네 경로가 순환을 만들지 않는다. **업로드 경로의 회차 접근은 읽기 전용인 것이 계약이다** — `occurrenceRepository.findById` + `entityManager.refresh(occurrence)`는 둘 다 잠금 없는 SELECT이고, 여기에 나중에 회차 잠금을 (위치 잠금 **뒤에**) 추가하면 순서가 위치 → 회차가 되어 승인·중지와 AB-BA 데드락이 된다. 업로드에 회차 잠금이 필요해지면 위치 잠금보다 **앞에** 잡아야 한다. @@ -911,3 +912,17 @@ participationStartsOn·participationEndsOn·participationMethod·imageUrl). `ima ② 유령 칩(가시 위치가 0인 회차가 옛 영역으로 계속 노출된다는 지적): **도달 불가**다. 시드 회차는 시드 위치를 최소 하나 갖고 그 위치는 숨김 대상이 아니다(중지는 `sub-` 접두 위치만 숨긴다). 가시 위치 0은 시드 문서에서 위치를 통째로 지운 경우뿐이고, 그때는 회차 자체가 노출 대상이 아니다. + +**시더를 회차 잠금 계약에 편입** (같은 리뷰의 마지막 지적) + +`EventSeeder.upsertOccurrence`가 잠금 없이 회차 노출 영역과 `event_location_grids`를 갱신하고 있었다. +**상호 배제는 양쪽이 다 잠가야 성립한다** — 승인·중지만 잠그고 시더가 계약 밖이면 잠금이 그 조합에서는 +아무것도 막지 못한다. 롤링 배포 중 기동 시딩과 관리자 승인이 겹칠 때 두 가지가 난다: ① 시더가 잠금 없이 +읽은 스테일 합집합이 방금 승인된 확장을 되쓰거나(영역 유실 — 위 재시드 보존 수정이 무력화된다), +② 승인 사전 검사를 통과한 격자 삽입이 시더의 격자 삽입과 커밋 시점에 부딪혀(`uq_event_grid_per_occ`가 +지연 제약이라) 500이 된다. 기존 회차를 `findWithLockById`로 잡고 시작하도록 고쳤고(신규 회차는 잠글 행이 +없어 그대로 — 같은 키의 동시 삽입은 자연키 UNIQUE가 막는다), 잠금 순서가 승인·중지와 같은 회차 → 위치라 +데드락 축은 새로 생기지 않는다(위 표에 시더 행 추가). + +동시성 재현 테스트는 두지 않았다 — 기동 시딩과 관리자 승인이 겹치는 창은 프로세스 경계라 비결정이고, +잠금 획득 자체는 표의 순서 대조로 지킨다. 기존 시더 테스트 전량이 green 임을 회귀로 삼는다. 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 9380ca11..16c5513e 100644 --- a/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java +++ b/src/main/java/com/msg/fillmap/event/seed/EventSeeder.java @@ -155,7 +155,14 @@ private EventOccurrence upsertOccurrence(EventSeries series, EventSeed.Occurrenc EventSeed.Rect exposure = seed.exposure(); validateRect(exposure, seed.occurrenceKey() + " 노출 영역"); + // 기존 회차는 비관 잠금으로 잡고 시작한다 (MSG-500 D-9 계약 편입). 상호 배제는 양쪽이 다 + // 잠가야 성립하는데 시더만 계약 밖이었다 — 롤링 배포 중 기동 시딩과 관리자 승인이 겹치면, 시더가 + // 잠금 없이 읽은 스테일 합집합이 방금 승인된 확장을 되쓰거나(영역 유실), 승인 사전 검사를 통과한 + // 격자 삽입이 시더의 격자 삽입과 커밋 시점에 부딪혀(uq_event_grid_per_occ 지연 제약) 500 이 된다. + // 잠금 순서는 승인·중지와 같은 회차 → 위치·격자라 데드락 축이 새로 생기지 않는다. + // 신규 회차는 잠글 행 자체가 없어 그대로 만든다(같은 키의 동시 삽입은 자연키 UNIQUE 가 막는다). EventOccurrence occurrence = occurrenceRepository.findByOccurrenceKey(seed.occurrenceKey()) + .map(found -> occurrenceRepository.findWithLockById(found.getId()).orElse(found)) .orElseGet(() -> new EventOccurrence(series, seed.occurrenceKey())); releaseSubscriptionsIfEnded(occurrence, now); From f9ff635787ee83a2653ff4559d654a2c2a906b4f Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sun, 30 Aug 2026 15:50:34 +0900 Subject: [PATCH 35/35] =?UTF-8?q?MSG-500=20docs:=20=EC=8B=9C=EB=8D=94=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A7=88=EC=BB=A4=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EC=A0=81=20=EB=A7=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=EC=8A=A4=EB=A5=BC=20=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W1NYGqr7zNx6u2iNFvLodV --- docs/rtm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rtm.md b/docs/rtm.md index 389d85fc..74882021 100644 --- a/docs/rtm.md +++ b/docs/rtm.md @@ -201,7 +201,7 @@ | FR-EVENT-12 | 구현됨 | MissionAwardQueryTest, MissionProgressQueryTest, MissionVideoCountQueryTest, MissionVideoListQueryTest, MissionVisitedGridQueryTest | | FR-EVENT-13 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest, EventSubmissionImageStoreTest, EventSubmissionValidationTest | | FR-EVENT-14 | 구현됨 | EventSubmissionCommitBoundaryTest, EventSubmissionControllerTest | -| FR-EVENT-15 | 구현됨 | AdminEventParticipationApprovalTest, AdminEventSubmissionControllerTest, AdminEventSubmissionReviewTest, EventParticipationApprovalConcurrencyTest, EventSubmissionApprovalConcurrencyTest, EventSubmissionImageStoreTest, EventSubmissionReviewSchemaTest, MissionQueryServiceCacheTest | +| FR-EVENT-15 | 구현됨 | AdminEventParticipationApprovalTest, AdminEventSubmissionControllerTest, AdminEventSubmissionReviewTest, EventParticipationApprovalConcurrencyTest, EventSeederTest, EventSubmissionApprovalConcurrencyTest, EventSubmissionImageStoreTest, EventSubmissionReviewSchemaTest, MissionQueryServiceCacheTest | | FR-EVENT-16 | 구현됨 | EventOccurrenceRepositoryTest, OrgEventControllerTest, OrgEventQueryServiceTest | | FR-EVENT-17 | 구현됨 | EventSubmissionControllerTest, EventSubmissionValidationTest | | FR-EVENT-18 | 구현됨 | AdminEventParticipationApprovalTest, AdminEventUnpublishTest, EventSubmissionApprovalConcurrencyTest |