From 58f32863801c7c79c5d3625c8b95000eddeac48a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 28 Oct 2026 16:19:55 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(notice):=20v2=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=ED=99=9C=EC=84=B1=20=EA=B3=B5=EC=A7=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/configs/SecurityConfig.kt | 1 + .../adapter/in/web/LatestNoticeController.kt | 14 ++ .../notice/application/LatestNoticeService.kt | 13 ++ .../LatestNoticeControllerIntegrationTest.kt | 205 ++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index f6947191..bd55f234 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -191,6 +191,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/notices/latest").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt new file mode 100644 index 00000000..ae6bfed8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.v2.api.notice.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.notice.application.LatestNoticeService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/notices") +class LatestNoticeController(private val service: LatestNoticeService) { + @GetMapping("/latest") + fun getLatestNotices() = ApiResponse.ok(service.getLatestNotices()) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt new file mode 100644 index 00000000..cda88bd2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.api.notice.application + +import kr.co.vividnext.sodalive.notice.NoticeItem +import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) { + fun getLatestNotices(): List = repository.getNoticeList(PageRequest.of(0, 3)) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt new file mode 100644 index 00000000..3c0260a3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.api.notice + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.notice.ServiceNotice +import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import java.sql.Timestamp +import java.time.LocalDateTime + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.datasource.url=jdbc:h2:mem:latest-notice-controller-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class LatestNoticeControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val repository: ServiceServiceNoticeRepository, + private val jdbcTemplate: JdbcTemplate, + private val objectMapper: ObjectMapper +) { + @BeforeEach + fun setUp() { + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ) + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ) + repository.deleteAllInBatch() + } + + @AfterEach + fun tearDown() { + repository.deleteAllInBatch() + } + + @Test + @DisplayName("익명 요청은 작성 시각과 무관하게 활성 공지 id 역순 최대 3개와 전체 필드를 반환한다") + fun shouldReturnLatestThreeActiveNoticesByIdWithUnchangedFields() { + saveNotice("제외될 활성 공지", "오래된 id", "2026-10-30T12:00:00") + val second = saveNotice("두 번째 공지", "본문 2", "2026-10-29T12:00:00") + val third = saveNotice("세 번째 공지", "본문 3", "2026-10-28T12:00:00") + val fourth = saveNotice( + "최근 서비스 공지", + "서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"", + "2026-10-27T15:00:00" + ) + saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false) + + assertGet( + "/api/v2/notices/latest", + """ + {"success":true,"message":null,"data":[ + {"id":$fourth,"title":"최근 서비스 공지", + "content":"서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"","date":"2026-10-28"}, + {"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"}, + {"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @ParameterizedTest + @CsvSource("2026-10-27T14:59:59, 2026-10-27", "2026-10-27T15:00:00, 2026-10-28") + @DisplayName("UTC 시각은 기존 KST 자정 경계에 맞춰 날짜 문자열로 반환한다") + fun shouldPreserveExistingDateAtKstMidnight(createdAt: String, expectedDate: String) { + val noticeId = saveNotice("날짜 경계", "날짜 본문", createdAt) + + assertGet( + "/api/v2/notices/latest", + """ + {"success":true,"message":null,"data":[ + {"id":$noticeId,"title":"날짜 경계","content":"날짜 본문","date":"$expectedDate"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("공지가 없으면 null 대신 빈 배열을 반환한다") + fun shouldReturnEmptyArrayWhenThereAreNoNotices() { + assertGet( + "/api/v2/notices/latest", + """{"success":true,"message":null,"data":[],"errorProperty":null}""" + ) + } + + @Test + @DisplayName("활성 공지 1개는 원래 필드 그대로 한 항목 배열로 반환한다") + fun shouldReturnOneNoticeWithoutPadding() { + val noticeId = saveNotice("단일 공지", "단일 본문", "2026-10-27T12:00:00") + + assertGet( + "/api/v2/notices/latest", + """ + {"success":true,"message":null,"data":[ + {"id":$noticeId,"title":"단일 공지","content":"단일 본문","date":"2026-10-27"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("활성 공지 2개는 중복 없이 id 역순으로 반환한다") + fun shouldReturnTwoNoticesWithoutPadding() { + val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00") + val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00") + + assertGet( + "/api/v2/notices/latest", + """ + {"success":true,"message":null,"data":[ + {"id":$second,"title":"다음 공지","content":"다음 본문","date":"2026-10-27"}, + {"id":$first,"title":"첫 공지","content":"첫 본문","date":"2026-10-28"} + ],"errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("비활성 공지만 있으면 빈 배열을 반환한다") + fun shouldReturnEmptyArrayWhenAllNoticesAreInactive() { + saveNotice("비활성 1", "본문 1", "2026-10-27T12:00:00", false) + saveNotice("비활성 2", "본문 2", "2026-10-28T12:00:00", false) + + assertGet( + "/api/v2/notices/latest", + """{"success":true,"message":null,"data":[],"errorProperty":null}""" + ) + } + + @Test + @DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다") + fun shouldKeepLegacyLatestNoticeAsAnObject() { + saveNotice("이전 공지", "이전 본문", "2026-10-29T12:00:00") + val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00") + saveNotice("비활성", "미공개 본문", "2026-10-30T12:00:00", false) + + assertGet( + "/notice/latest", + """ + {"success":true,"message":null,"data": + {"id":$latest,"title":"기존 최근 공지","content":"기존 전체 본문","date":"2026-10-28"}, + "errorProperty":null} + """.trimIndent() + ) + } + + @Test + @DisplayName("기존 v1 최근 공지는 데이터가 없으면 null을 유지한다") + fun shouldKeepLegacyLatestNoticeNullWhenEmpty() { + assertGet("/notice/latest", """{"success":true,"message":null,"data":null,"errorProperty":null}""") + } + + @ParameterizedTest + @CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private") + @DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다") + fun shouldRejectAnonymousRequestsOutsideTheExactGetRoute(method: HttpMethod, path: String) { + val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java) + + println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } + + private fun assertGet(path: String, expectedJson: String) { + val response = restTemplate.getForEntity(path, String::class.java) + + println("HTTP GET $path -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(response.body)) + } + + private fun saveNotice(title: String, content: String, createdAt: String, isActive: Boolean = true): Long { + val noticeId = requireNotNull(repository.saveAndFlush(ServiceNotice(title, content, isActive)).id) + assertEquals( + 1, + jdbcTemplate.update( + "UPDATE service_notice SET created_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDateTime.parse(createdAt)), + noticeId + ) + ) + return noticeId + } +} From b9cccda1a9f35b02a4befd72283a9c3ec774d0a3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 28 Oct 2026 16:20:06 +0900 Subject: [PATCH 02/13] =?UTF-8?q?docs(notice):=20v2=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=EC=9D=84=20=EC=A0=95=EC=9D=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261028_최근공지사항_v2/api-contract.md | 65 +++++++++++++++++++ .../20261028_최근공지사항_v2/client-prompt.md | 22 +++++++ docs/20261028_최근공지사항_v2/prd.md | 48 ++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 docs/20261028_최근공지사항_v2/api-contract.md create mode 100644 docs/20261028_최근공지사항_v2/client-prompt.md create mode 100644 docs/20261028_최근공지사항_v2/prd.md diff --git a/docs/20261028_최근공지사항_v2/api-contract.md b/docs/20261028_최근공지사항_v2/api-contract.md new file mode 100644 index 00000000..552c2ab4 --- /dev/null +++ b/docs/20261028_최근공지사항_v2/api-contract.md @@ -0,0 +1,65 @@ +# 최근 공지사항 v2 API 계약 + +## 1. 요청 + +| 항목 | 계약 | +|---|---| +| Method / Path | `GET /api/v2/notices/latest` | +| 접근 | 인증 없이 허용, Authorization 필수 아님 | +| 입력 | path 변수·query·body 없음 | +| 성공 | HTTP 200, JSON `ApiResponse>` | +| 페이지네이션 | 없음. 항상 최대 3개, page/size/total/hasNext 없음 | + +## 2. 데이터 규칙 + +`isActive=true`인 공지만 id 내림차순으로 최대 3개 반환한다. createdAt 최신순이 아니다. 기존 `NoticeItem`과 목록 쿼리를 재사용하며 아래 필드는 변경하지 않는다. + +| 필드 | 타입 | 의미 | +|---|---|---| +| id | JSON number / Kotlin Long | 기존 공지 식별자 | +| title | string | 원래 제목 전체 | +| content | string | 원래 본문 전체, 축약·변환하지 않음 | +| date | string | 기존 UTC createdAt을 Asia/Seoul로 변환한 `yyyy-MM-dd` | + +UTC `2026-10-27 15:00:00`은 `2026-10-28`이다. 날짜 formatter·timezone·필드 이름·타입을 새로 정의하지 않는다. + +## 3. 성공 envelope와 예시 + +공통 `ApiResponse`는 success/message/data/errorProperty 네 필드를 모두 갖는다. 성공의 `success=true`, data는 null이 아닌 0~3개 배열이며 `message:null`, `errorProperty:null`이 생략되지 않고 포함된다. 2026-10-28 focused 실제 HTTP 테스트 11개에서 전체 JSON tree 비교로 확인했다. 전역 Jackson 설정은 변경하지 않는다. + +```json +{ + "success": true, + "message": null, + "data": [ + {"id": 104, "title": "서비스 안내", "content": "공지 본문 전체\n두 번째 줄", "date": "2026-10-28"}, + {"id": 103, "title": "점검 안내", "content": "점검 안내 전체", "date": "2026-10-27"}, + {"id": 102, "title": "업데이트 안내", "content": "업데이트 안내 전체", "date": "2026-10-26"} + ], + "errorProperty": null +} +``` + +활성 공지가 없으면 (전부 비활성인 경우 포함): + +```json +{"success":true,"message":null,"data":[],"errorProperty":null} +``` + +활성 공지가 1개 또는 2개면 실제 개수만 반환하며 채우거나 반복하지 않는다. 예시 id는 설명용이며 고정 데이터나 실제 배포 응답이 아니다. + +## 4. 호환성과 오류 + +- 기존 `GET /notice/latest`는 기존 단일 객체 또는 `data:null` 계약을 유지한다. 홈 응답도 그대로다. +- 새 permitAll은 정확한 GET 경로 하나에만 적용한다. 실제 HTTP 검증에서 익명 `POST /api/v2/notices/latest`와 `GET /api/v2/notices/private`는 HTTP 401이었다. 기존 보안 오류 응답은 아래와 같이 성공 envelope와 다르다. timestamp 값은 요청마다 달라진다. + +```json +{"timestamp":"2026-10-28T06:28:16.011+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/latest"} +``` + +- 기존 공통 오류 처리의 `ApiResponse.error`는 `success=false`다. 일부 공통 예외는 HTTP 200에도 오류 envelope가 오므로 클라이언트는 HTTP 상태와 success를 함께 확인한다. HTTP/보안 오류가 모두 같은 envelope라고 가정하지 않는다. +- 조회 실패를 정상 `[]`로 바꾸지 않는다. 새 오류 코드·status·전역 예외 정책·자동 fallback은 추가하지 않는다. + +## 5. 제공 상태 + +구현 및 focused 실제 HTTP 검증 완료(공지 11개와 기존 배포 엔드포인트 4개, 총 15 tests, failures/errors/skipped 모두 0). `bootJar`와 전체 `ktlintCheck`는 통과했으나 전체 `build --continue`는 test 단계에서 20분 제한으로 종료되어 최종 Gate는 미완료다. 실응답·명령 결과와 독립 리뷰 상태는 [plan-task.md](plan-task.md)에 기록했다. 운영 배포는 수행하지 않았으며 배포 완료로 간주하지 않는다. diff --git a/docs/20261028_최근공지사항_v2/client-prompt.md b/docs/20261028_최근공지사항_v2/client-prompt.md new file mode 100644 index 00000000..fe38790a --- /dev/null +++ b/docs/20261028_최근공지사항_v2/client-prompt.md @@ -0,0 +1,22 @@ +# 클라이언트 전달용 프롬프트 + +아래 내용을 클라이언트 작업자에게 전달한다. 서버 코드 구현·로컬 검증과 실제 배포는 별개다. + +```text +기존 공지 UI, 레이아웃, 스타일, 진입점, 상세 이동과 내비게이션을 유지하면서 최근 공지 데이터만 아래 새 계약으로 연결해 주세요. UI 재설계나 캐러셀 추가는 하지 마세요. + +API: 익명 GET /api/v2/notices/latest +요청 body/query와 인증 필수 요구는 없습니다. +응답: ApiResponse> +NoticeItem: id(number), title(string), content(string), date(string, yyyy-MM-dd). +data는 활성 공지 중 id 내림차순 최대 3개입니다. 순서를 다시 정렬하지 말고 서버 순서를 사용하세요. date는 이미 기존 UTC→Asia/Seoul 규칙으로 포맷된 문자열이므로 다시 timezone 변환하지 마세요. title/content는 전체 문자열을 유지하세요. +활성 공지가 없으면 data:[]이며 null이 아닙니다. 1/2개면 해당 개수만 표시하고 3개를 채우려고 중복하지 마세요. 기존 단일 공지 v1과 홈 응답 스키마는 변경되지 않습니다. + +서버 배포가 확인되기 전에는 개발 환경의 명시적 mock 모드로만 작업하세요. 이 서버 작업은 운영 배포를 수행하지 않습니다. 실제 404/오류를 mock이나 v1로 자동 fallback하지 말고, 배포 후 명시적으로 API 연결을 전환하세요. production mock은 금지합니다. + +공통 envelope의 success를 HTTP 상태와 함께 검사하세요. 성공 응답에는 message:null과 errorProperty:null이 생략 없이 포함됩니다(실제 HTTP 검증 완료). 기존 정책상 HTTP 200에도 success:false가 올 수 있습니다. 조회 실패를 정상 빈 목록으로 숨기지 말고 기존 화면의 오류 처리와 로딩 상태를 유지하세요. 보안/HTTP 오류가 모두 동일한 envelope라고 가정하지 마세요. + +0/1/2/3개 fixture를 각각 테스트하세요. 0개일 때 기존 빈 상태, 1/2개일 때 중복 없음, 3개일 때 id 역순 유지, 상세 이동 시 동일 id/전체 본문 보존을 확인하세요. 긴 제목·여러 줄 본문·KST 날짜 경계도 기존 UI 안에서 확인하세요. +범위 밖: 캐러셀, 자동 fallback, 캐싱, 페이지네이션, 홈 응답 변경, 기존 내비게이션 변경. +계약: docs/20261028_최근공지사항_v2/api-contract.md +``` diff --git a/docs/20261028_최근공지사항_v2/prd.md b/docs/20261028_최근공지사항_v2/prd.md new file mode 100644 index 00000000..ab9901b3 --- /dev/null +++ b/docs/20261028_최근공지사항_v2/prd.md @@ -0,0 +1,48 @@ +# 최근 공지사항 v2 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 상태 | 구현·HTTP 15개·패키징·lint 검증 완료, 전체 회귀 시간 초과로 최종 Gate 미완료, 미배포 | +| 작성일 | 2026-10-28 | +| 결정권자 | 요청 사용자 | +| 계약 | [api-contract.md](api-contract.md) | +| 구현 계획 | [plan-task.md](plan-task.md) | +| 클라이언트 전달 | [client-prompt.md](client-prompt.md) | + +## 목표와 문제 + +기존 `/notice/latest`는 공지 하나 또는 null을 반환한다. 이 계약과 홈 응답을 유지하면서 클라이언트가 최근 활성 공지 최대 3개를 익명으로 조회할 수 있는 별도 API를 제공한다. 최신은 작성 시각이 아니라 기존 공지 정책인 id 내림차순이다. + +## 확정 요구사항과 수용 기준 + +| ID | 요구사항 | 수용 기준 | 추적 | +|---|---|---|---| +| NOTICE-001 | 익명 `GET /api/v2/notices/latest` | HTTP 200, `ApiResponse>` | 계약 §1, P1-T1 | +| NOTICE-002 | 활성 공지 중 id 내림차순 최대 3개 | 활성 4개 이상과 더 큰 비활성 id fixture에서 정확한 3개 반환, createdAt 정렬 금지 | 계약 §2, P1-T1 | +| NOTICE-003 | 기존 필드와 날짜 보존 | id/title/content/date 전체 일치, 본문 생략 없음, UTC→Asia/Seoul `yyyy-MM-dd` 날짜 경계 검증 | 계약 §2, P1-T1 | +| NOTICE-004 | 적은 데이터도 배열 | 0/1/2개 및 전부 비활성 시 각각 0/1/2개와 빈 배열 | 계약 §3, P1-T1 | +| NOTICE-005 | 기존 동작·권한 보존 | v1 객체/null 유지, 익명 POST와 인접 보호 경로는 401 | 계약 §4, P1-T1 | + +## 구현 제약과 제외 + +- Kotlin 1.6.21, Java 17, Spring Boot 2.7.14 및 기존 의존성을 사용한다. +- 신규 v2 controller와 읽기 전용 service가 기존 `ServiceServiceNoticeRepository.getNoticeList(PageRequest.of(0, 3))`를 호출한다. 기존 `NoticeItem`과 `ApiResponse`를 재사용한다. +- 공개 보안 허용은 정확히 새 GET 한 경로만 추가한다. 공지 본문은 기존 활성 공지의 공개 데이터이며 새 사용자·인증 데이터는 취급하지 않는다. +- v1 공지 함수, 홈 응답, 기존 repository 쿼리, DB 스키마, 공통 JSON·오류 정책과 의존성은 변경하지 않는다. +- UI·캐러셀·자동 fallback·캐시·페이지네이션·신규 DTO·port·범용 facade·배포는 제외한다. + +## 검증과 배포 상태 + +실제 SecurityConfig, RANDOM_PORT HTTP 서버, TestRestTemplate, 실제 repository, 로컬 H2와 embedded Redis로 통합 검증한다. 새 구현 클래스 참조 없이 HTTP로 먼저 실패를 확인한다. H2는 기존 MySQL 날짜 함수 alias로 쿼리를 실행하며 운영 MySQL 실검증은 별도다. + +공지 HTTP 11개와 기존 배포 엔드포인트 HTTP 4개가 통과했고, `bootJar`와 전체 `ktlintCheck`가 성공했다. 전체 `build --continue`는 test 단계에서 20분 제한으로 종료되어 전체 회귀는 미검증이다. 최종 Gate 상태와 독립 리뷰는 구현 계획에 기록한다. 구현·로컬 검증은 배포 완료를 뜻하지 않으며 클라이언트는 배포 확인 전 명시적 mock을 사용한다. + +## Decision Log + +| 날짜 | ID | 결정 | 근거 | +|---|---|---|---| +| 2026-10-28 | NOTICE-D1 | 기존 목록 쿼리를 page 0/size 3으로 호출 | 기존 쿼리에 활성 필터·id 역순·offset/limit·날짜 projection이 존재 | +| 2026-10-28 | NOTICE-D2 | 기존 v1과 홈은 전혀 수정하지 않음 | 사용자 승인 범위 및 객체/null 호환성 | +| 2026-10-28 | NOTICE-D3 | 전체 Gate는 부모 실행자가 수행 | 명시적 작업 분담, 이 작업은 focused HTTP 검증까지만 수행 | From 8345cc25ad4f73ab0ec5303b6ca7028517c2fea1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 28 Oct 2026 16:20:15 +0900 Subject: [PATCH 03/13] =?UTF-8?q?docs(notice):=20v2=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EA=B5=AC=ED=98=84=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EA=B3=BC=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=EA=B8=B0=EB=A1=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 --- docs/20261028_최근공지사항_v2/plan-task.md | 165 ++++++++++++++++++ .../reviews/implementation-review.md | 62 +++++++ 2 files changed, 227 insertions(+) create mode 100644 docs/20261028_최근공지사항_v2/plan-task.md create mode 100644 docs/20261028_최근공지사항_v2/reviews/implementation-review.md diff --git a/docs/20261028_최근공지사항_v2/plan-task.md b/docs/20261028_최근공지사항_v2/plan-task.md new file mode 100644 index 00000000..bf670e5a --- /dev/null +++ b/docs/20261028_최근공지사항_v2/plan-task.md @@ -0,0 +1,165 @@ +# 최근 공지사항 v2 구현 계획 + +> 실행 방식: 승인된 설계를 기존 작업 디렉터리에서 TDD로 구현하고 독립 검토한다. 새 worktree·commit·배포는 수행하지 않는다. 전체 테스트는 최종 Gate에서 실행한다. + +| 항목 | 내용 | +|---|---| +| 작성일 | 2026-10-28 | +| 상태 | P1-T1 완료, P1-GATE 전체 회귀 시간 초과로 미완료, 미배포 | +| 요구사항 | [prd.md](prd.md) | +| API 기준 | [api-contract.md](api-contract.md) | +| 현재 Phase / 활성 Goal | Phase 1 / P1-GATE 전체 회귀 결과 확보 필요 | + +**Goal:** 기존 v1·홈을 유지하면서 누구나 최근 활성 공지를 최대 3개 조회한다. + +**Architecture:** 새 web controller → 읽기 전용 application service → 기존 repository 목록 쿼리. 쿼리·DTO·공통 JSON 정책은 유지한다. + +**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JUnit 5, H2, embedded Redis, TestRestTemplate. + +## 전역 제약과 리뷰 초점 + +- 새 GET 한 경로만 permitAll로 허용한다. 주변 경로나 다른 메서드를 공개하지 않는다. +- `NoticeItem(id: Long, title: String, content: String, date: String)` 및 UTC→Asia/Seoul `yyyy-MM-dd`를 보존한다. +- createdAt이 id 순서와 반대여도 id로 정렬하며 비활성 최신 id를 제외한다. +- 빈 배열, 1/2개 배열, 전체 본문, KST 자정, v1 객체/null을 HTTP로 검증한다. +- 외부 서비스·운영 앱·스키마·의존성·UI·캐시·신규 추상화는 범위 밖이다. + +### Phase 1: 최근 공지 v2 수직 슬라이스 + +- [x] **Task 1.1: P1-T1 실제 HTTP 조회 구현** + +**Objective:** 익명 GET의 배열 계약과 기존 호환성을 실제 HTTP/DB 왕복으로 검증한다. + +**시작 조건:** 사용자 승인 설계, PRD와 이 계획 작성 완료. + +**완료 증거:** 의도한 RED, focused GREEN, 실응답 관찰, 계약과 검증 기록, 소유 임시 리소스 정리. + +**범위 밖:** 전체 테스트·빌드·독립 리뷰는 P1-GATE 소유이며 배포는 전체 작업 범위 밖이다. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`의 기존 notice 규칙 바로 옆 GET matcher 한 줄 +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt` +- Docs: 이 폴더의 `prd.md`, `plan-task.md`, `api-contract.md`, `client-prompt.md` + +**Interfaces:** + +- Consumes: `ServiceServiceNoticeRepository.getNoticeList(pageable: Pageable): List` +- Produces: `LatestNoticeService.getLatestNotices(): List`는 `PageRequest.of(0, 3)` 사용 +- HTTP: `LatestNoticeController.getLatestNotices()`는 `ApiResponse.ok(service.getLatestNotices())` 반환 + +- [x] **RED:** RANDOM_PORT/TestRestTemplate/실제 repository·SecurityConfig, 고유 H2, 기존 날짜 alias, EmbeddedRedisInitializer, AFTER_CLASS DirtiesContext로 테스트 작성. 아직 없는 controller 타입은 참조하지 않는다. +- [x] **RED 확인:** 아래 focused 명령으로 미허용·미구현 GET의 `expected 200, actual 401` 같은 HTTP assertion 실패를 확인하고 기록한다. 컴파일/인프라 실패는 RED로 세지 않는다. +- [x] **GREEN:** production 2개 파일과 정확한 GET matcher 한 줄만 추가한다. +- [x] **GREEN 확인:** 동일 focused 명령으로 4+활성·비활성·id 정렬·본문·KST 경계·0/1/2·v1·권한 테스트가 통과하는지 확인한다. +- [x] **REFACTOR:** 불필요한 정리 없이 focused 회귀와 diff 점검, HTTP 출력 및 문서 정합성 기록을 남긴다. + +```bash +./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest +./gradlew --no-daemon tasks --all +git diff --check +``` + +**Expected:** focused 테스트 실패 0, HTTP 200 + data 배열, 익명 POST/인접 경로 401, v1 객체/null, 명령 유효성 검증 exit 0. + +#### Phase 1 Gate: P1-GATE + +**Objective:** 최종 전체 회귀·빌드·독립 리뷰로 전달 가능 상태를 판정한다. + +**시작 조건:** P1-T1 증거와 리소스 정리 완료. + +**완료 증거:** 전체 테스트·빌드 exit 0, 독립 리뷰 결과와 잔여 위험 기록. + +**범위 밖:** 실패를 숨기는 test 삭제·완화, 무관한 수정·배포. + +- [ ] **[blocked] 전체 회귀 완료:** `./gradlew --no-daemon build --continue`의 전체 `test`가 20분 제한으로 종료됐다. 충분한 실행 시간이 확보된 환경에서 전체 테스트 exit 0을 확인하기 전 Gate는 완료하지 않는다. +- [x] `./gradlew --no-daemon bootJar ktlintCheck`로 패키징과 전체 lint를 검증한다. +- [x] 공지 HTTP 11개와 기존 배포 엔드포인트 HTTP 4개를 함께 재실행해 15개 통과를 확인한다. +- [x] [독립 리뷰](reviews/implementation-review.md)의 규격 적합·코드/테스트 품질 승인과 확정 발견 사항 없음을 기록한다. 이는 전체 회귀 Gate 통과나 배포 승인이 아니다. +- [x] 미배포임을 명시한다. 배포 자체는 작업 범위 밖이다. + +## 실행 자원과 정리 의무 + +- 소유 임시 root: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-v2-impl.thzdr5` +- 모든 shell은 `TMPDIR`를 위 root로, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`로 export한다. +- Gradle은 `--no-daemon`을 사용한다. 테스트 context는 AFTER_CLASS에 닫고 embedded Redis는 기존 initializer의 JVM 종료 hook으로 종료한다. +- [x] 보고 전 이 실행의 소유 프로세스가 종료됐는지 확인하고 위 root만 삭제한다. 공유 캐시·다른 실행자의 파일과 프로세스는 건드리지 않는다. +- LSP 도구는 현재 host에 없어 실행할 수 없다. focused Kotlin 컴파일 결과를 기록하되 LSP 검증으로 표현하지 않는다. + +## Progress + +### P1-T1 시작: 2026-10-28 + +- `test` 브랜치 초기 `git status --short --branch`: 변경 없음. +- 루트 AGENTS, 필수 agent guides, PRD/계획 샘플, TDD `writing-good-tests.md` 및 기존 쿼리·v1·홈·HTTP 테스트를 확인했다. +- 첫 baseline: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest`는 120초 tool timeout으로 종료. 테스트 결과 XML이 없어 성공/RED로 판정하지 않는다. 원인 관찰을 위해 `--info`와 600초 제한으로 재시도한다. +- 이 단계에서 production 코드 변경은 없다. + +### P1-T1 baseline 및 RED 작성: 2026-10-28 + +- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest --info`: exit 0, 4 tests / 0 failures / 0 errors, 2m33s. 기존 HTTP/보안 테스트 기반 동작 확인. +- 관찰: 기존 translation/charge 스케줄러가 H2에서 `for update skip locked` 문법 오류를 로그로 남긴다. baseline에서도 발생했으며 이 범위에서 수정하지 않는다. +- fixture는 실제 repository로 저장·flush한 뒤 별도 JDBC 갱신으로 createdAt을 고정한다. BaseEntity의 PrePersist가 현재 시각으로 덮어쓰므로 이 순서가 필요하다. 서버의 조회는 별도 HTTP thread/DB 왕복이다. +- 전체 JSON tree를 literal 기대값과 비교해 null 필드 포함 여부·필드 이름·본문·날짜까지 확인한다. 생성된 id만 실제 저장 결과를 사용한다. + +### P1-T1 RED 확인: 2026-10-28 + +- production 코드 추가 전 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest` 실행: exit 1, 3m42s, 11 tests / 7 failures / 0 errors / 0 skipped. +- 7개 v2 GET 검증이 모두 `expected: <200 OK> but was: <401 UNAUTHORIZED>` HTTP assertion으로 실패했다. Kotlin 컴파일과 로컬 H2/Redis/서버 초기화는 성공했다. +- 기존 v1 객체/null 및 익명 POST/인접 경로 거부의 나머지 4개 검증은 통과했다. +- RED 실응답: `HTTP GET /api/v2/notices/latest -> 401 {"timestamp":"2026-10-28T06:18:18.170+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/latest"}`. +- 당시 XML: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest.xml`. 다음 Gradle test 실행이 덮어쓰므로 실패 수와 핵심 응답을 이 문서에 보존한다. + +### P1-T1 GREEN 및 HTTP 응답 관찰: 2026-10-28 + +- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest`: exit 0, 9m11s, 11 tests / 0 failures / 0 errors / 0 skipped. production/test Kotlin 컴파일도 실행·성공했다. 이 결과는 전체 `build` 성공을 뜻하지 않는다. +- 실제 HTTP 출력은 테스트의 `println`과 XML ``에 남긴다. 확인한 artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest.xml`, HTML: `build/reports/tests/test/index.html`. 다음 테스트 실행으로 덮어쓸 수 있어 아래 핵심 응답을 보존한다. +- 익명 GET의 3개 응답은 id 12/11/10이며 date는 2026-10-28/2026-10-28/2026-10-29여서 createdAt 최신순이 아님을 확인했다. 더 큰 비활성 id는 제외됐다. HTML 모양 텍스트·인용부호·줄바꿈을 포함한 본문도 전체 일치했다. +- UTC 14:59:59/15:00:00의 KST 날짜, 0/1/2개, 전부 비활성, v1 객체/null, 익명 POST/인접 GET의 401을 모두 실서버 응답으로 확인했다. message/errorProperty의 명시적 null 포함도 JSON tree 완전 비교로 확정했다. +- 코드 정리로 바꿀 중복이 없어 REFACTOR production 수정은 하지 않았다. `git diff`에서 기존 notice/home 변경은 없고 SecurityConfig는 GET matcher 한 줄뿐이다. + +```text +HTTP GET /api/v2/notices/latest -> 200 {"success":true,"message":null,"data":[{"id":12,"title":"최근 서비스 공지","content":"서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"","date":"2026-10-28"},{"id":11,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"},{"id":10,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"}],"errorProperty":null} +HTTP GET /api/v2/notices/latest -> 200 {"success":true,"message":null,"data":[],"errorProperty":null} +HTTP GET /notice/latest -> 200 {"success":true,"message":null,"data":null,"errorProperty":null} +HTTP POST /api/v2/notices/latest -> 401 {"timestamp":"2026-10-28T06:28:16.011+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/latest"} +HTTP GET /api/v2/notices/private -> 401 {"timestamp":"2026-10-28T06:28:16.112+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/private"} +``` + +## 검증 기록 + +전체 테스트·빌드·독립 리뷰는 사용자 지정 역할 분담에 따라 이 실행에서 생략하고 P1-GATE에 남긴다. 테스트 클래스 추가에 대한 단일 실행 예시는 위 focused 명령을 사용한다. + +- `./gradlew --no-daemon tasks --all`: exit 0, 31s. 문서의 `test`/`build`/관련 Gradle 명령이 등록됐음을 확인했다. +- `git diff --check`: exit 0. 신규 파일은 read 도구로 확인하고 변경 범위 목록을 `git status --short --untracked-files=all`로 점검했다. +- PRD의 NOTICE-001~005는 모두 P1-T1의 HTTP 검증으로 연결된다. v1/home는 코드 diff가 없고 v1은 HTTP 회귀도 확인했다. 홈 전체 응답을 호출하는 별도 회귀는 이 실행에서 수행하지 않았다. +- LSP 진단 도구 미제공, lint·전체 테스트·전체 빌드·독립 리뷰·운영 MySQL·배포 검증은 미실행이다. +- 기존 관찰 사항: H2에서 translation/charge 스케줄러의 `skip locked` 오류 로그가 GREEN에도 남는다(테스트 실패는 0). 기존 다른 테스트의 불필요 `!!`/unchecked cast 컴파일 경고, Netty macOS resolver 및 Gradle deprecation 경고도 이 범위에서 수정하지 않았다. + +### P1-T1 정리 영수증 및 인계: 2026-10-28 + +- GREEN XML에서 Tomcat 종료와 HikariPool shutdown completed를 확인했다. Gradle test JVM도 종료됐고 프로세스 목록에 이 실행의 test worker, Redis, Kotlin compile daemon, Gradle 8.1.1 daemon은 남아 있지 않다. +- 별도 실행자의 기존 Gradle 8.14.3 daemon(pid 49992)은 소유 대상이 아니므로 종료하지 않았다. +- 소유 root가 빈 디렉터리임을 확인하고 `rmdir "$TMPDIR" && test ! -e "$TMPDIR"` 실행: exit 0. 삭제한 root는 위 `notice-v2-impl.thzdr5` 하나뿐이며 공유 캐시는 변경하지 않았다. +- P1-T1은 focused 증거와 자원 정리까지 완료했다. 전체 테스트·빌드·독립 리뷰와 운영 배포 판단은 부모 실행자의 P1-GATE로 남긴다. + +### P1-GATE 실행: 2026-10-28 + +- `./gradlew --no-daemon build --continue`: `bootJar`/`assemble` 완료 후 전체 `:test` 단계에서 도구의 1,200,000ms 제한으로 종료됐다. 전체 테스트 수·성공 여부를 판정할 최종 결과는 없으며 전체 build 성공으로 표시하지 않는다. 원인이 특정 테스트 실패인지 단순 소요 시간인지 이 실행으로 확정하지 못했다. +- `./gradlew --no-daemon bootJar ktlintCheck`: exit 0, `BUILD SUCCESSFUL in 59s`. 산출물은 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`이며 main/test/script 전체 ktlint를 통과했다. +- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest`: exit 0, `BUILD SUCCESSFUL in 3m 29s`. 두 XML에서 11+4=15 tests, failures/errors/skipped 모두 0을 확인했다. +- 최신 공지 HTTP XML timestamp는 `2026-10-28T06:57:06`, 기존 배포 HTTP XML timestamp는 `2026-10-28T06:56:23`이다. `TestRestTemplate`가 RANDOM_PORT 실서버에 요청했으며, 공지 응답의 id 12/11/10 순서·전체 필드·빈 배열·KST 날짜와 v1 객체/null, 익명 POST/인접 GET 401을 출력 및 전체 JSON 비교로 재검증했다. +- XML에서 세 테스트 서버의 `Stopping service [Tomcat]`를 확인했다. 최종 소유 자원 부재 확인은 아래 정리 기록에 남긴다. +- LSP 도구는 제공되지 않아 실행하지 못했다. Kotlin 컴파일·ktlint 성공을 LSP 실행으로 대신 표기하지 않는다. 운영 MySQL·배포·클라이언트 앱 검증은 범위 밖이다. +- 최초 독립 리뷰는 완료 알림 후 결과 조회 오류와 세션 미발견으로 판정 내용을 복구하지 못했다. 근거 없는 승인으로 처리하지 않고, 대체 독립 리뷰를 파일로 기록하도록 요청했다. +- 대체 독립 리뷰 결과: 규격 적합, 코드/테스트 품질 승인, 확정 발견 사항 없음. [implementation-review.md](reviews/implementation-review.md)에 검토 근거와 한계를 기록했다. 이후 production/test 변경은 없으며 위 15개 HTTP 재실행 결과를 이 계획에 보완했다. +- 최종 `git diff --check`: exit 0. 전체 회귀 제한을 숨기기 위한 테스트 변경·삭제·skip은 없다. commit/push/worktree 생성/배포는 수행하지 않았다. + +### P1-GATE 소유 자원 정리: 2026-10-28 + +- 최신 HTTP XML에서 Tomcat 종료를 확인했고, 실행 후 프로세스 목록에 이 작업의 Gradle/test worker/Redis는 남아 있지 않았다. 별도 실행자의 기존 Java 프로세스는 종료하지 않았다. +- 빈 하위 `opencode/`와 검증 root `notice-v2.xbE00p`를 `rmdir`로 삭제했다. `test ! -e`로 검증 root, 구현 root `notice-v2-impl.thzdr5`, 작업 노트의 부재를 확인했다(exit 0). +- 승인된 임시 상위 디렉터리를 다시 읽어 `notice-v2` 실행/리뷰 root가 남아 있지 않음을 확인했다. 공유 캐시와 다른 작업의 디렉터리는 보존했다. 대체 리뷰어는 shell/임시 자원을 생성하지 않았다. +- 최종 상태: 구현과 로컬 HTTP 15개·패키징·lint·독립 코드 리뷰는 완료. **전체 회귀는 시간 초과로 미검증이며 P1-GATE는 보류**다. 재개 시 이미 완료한 구현을 반복하지 말고 충분한 실행 시간의 환경에서 전체 테스트 종료 결과를 확보한다. diff --git a/docs/20261028_최근공지사항_v2/reviews/implementation-review.md b/docs/20261028_최근공지사항_v2/reviews/implementation-review.md new file mode 100644 index 00000000..e822aaa5 --- /dev/null +++ b/docs/20261028_최근공지사항_v2/reviews/implementation-review.md @@ -0,0 +1,62 @@ +# 최근 공지사항 v2 구현 리뷰 + +## 1. 리뷰 정보와 범위 + +| 항목 | 내용 | +|---|---| +| 대상 | 2026-10-28 작업 트리의 P1-T1 구현 및 P1-GATE 독립 리뷰 부분 | +| 리뷰어 / 상태 | 독립 리뷰 에이전트 / 판정 완료 | +| 기준 | [PRD](../prd.md)의 NOTICE-001~005, [계획](../plan-task.md), [API 계약](../api-contract.md), [클라이언트 전달문](../client-prompt.md), [리뷰 샘플](../../sample/sample-review.md) | +| 방법 | 신규 controller/service, SecurityConfig, 통합 테스트, 기존 repository/DTO/v1 및 홈 호출 경로를 읽기 전용 대조 | +| 제외 | 코드·테스트 수정, 명령·테스트·실서버 재실행, 운영 MySQL·배포 검증 | + +이전 리뷰 결과는 회수되지 않아 재사용하지 않았다. 이번 판정은 현재 파일을 직접 읽은 정적 검토와 부모 실행자가 전달한 실행 증거를 구분한다. Git SHA/diff는 이번 리뷰에서 독립 확인하지 않았으며, 기존 파일의 변경 없음은 부모의 diff 관찰을 함께 근거로 삼았다. + +## 2. 판정 기준과 계약 대조 + +확정 계약 위반·보안 노출·주요 회귀는 수정 필요 항목으로 판정한다. 재현 조건과 코드·테스트 근거가 없는 추측이나 공개된 환경 한계는 코드 결함으로 올리지 않는다. 아래 경로는 저장소 루트 기준이며 숫자는 줄 번호다. + +| 기준 | 판정 및 근거 | +|---|---| +| NOTICE-001: 익명 GET 및 배열 envelope | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt:10`의 경로와 `:13`의 `ApiResponse.ok`가 목록을 직접 반환한다. `src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt:3`의 기존 네 필드를 재사용한다. | +| NOTICE-002: 활성·id DESC·DB 최대 3개 | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt:12`가 `getNoticeList(PageRequest.of(0, 3))`를 호출한다. `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt:44`의 활성 필터, `:45`의 offset, `:46`의 DB limit, `:47`의 id 역순을 그대로 사용한다. 전체 조회 후 메모리 절단이나 totalCount 조회가 없다. | +| NOTICE-003: 필드·전체 본문·KST | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/notice/GetNoticeResponse.kt:10`의 `NoticeItem(id: Long, title: String, content: String, date: String)`을 재사용한다. `ServiceServiceNoticeRepository.kt:36`은 원래 제목·본문을 projection하고 `:67`은 UTC→Asia/Seoul 및 `%Y-%m-%d`를 유지한다. 신규 변환·축약이 없다. | +| NOTICE-004: 0/1/2/3+ 및 전부 비활성 | 적합. service의 non-null 목록을 그대로 반환하므로 빈 목록도 `[]`이고 패딩·반복이 없다. 통합 테스트 `:59`, `:101`, `:110`, `:125`, `:142`가 활성 4개와 더 큰 비활성 id, 0/1/2개, 전부 비활성을 검증한다. | +| NOTICE-005: v1·홈·권한 보존 | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt:39` → `ServiceNoticeService.kt:51` → `ServiceServiceNoticeRepository.kt:51`은 단일/null 경로다. `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt:52`와 `GetContentMainTabHomeResponse.kt:13`도 기존 `NoticeItem?`를 유지한다. `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt:194`는 정확한 GET 한 경로만 허용하고 `:214`의 나머지 인증 규칙을 유지한다. | + +## 3. 코드·테스트 품질 + +- **품질 판정: 적합.** 읽기 전용 트랜잭션 service와 얇은 controller만 추가하며 기존 repository·DTO·envelope를 재사용한다. 신규 port·facade·캐시·페이지네이션·의존성·오류 fallback 등 불필요한 추상화가 없다. +- 테스트 기준 파일은 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt`다. `:26`의 RANDOM_PORT, `:33`의 embedded Redis, `:35`의 실제 repository/JDBC와 TestRestTemplate을 사용하며 controller/service를 mock하지 않는다. 파라미터 확장 포함 11건이다. +- `:193`은 실제 저장·flush 후 JDBC로 고정 시각을 저장한다. 이는 `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:29`의 PrePersist 덮어쓰기를 피하고 별도 HTTP 요청이 DB를 읽게 한다. `:185`는 상태와 전체 JSON tree를 비교하므로 null 필드 누락·추가 필드·본문 변형·배열 순서도 검출한다. +- `:59`의 id와 createdAt 역순 fixture, `:84`의 UTC 14:59:59/15:00:00 KST 경계, `:154`/`:171`의 v1 객체/null, `:176`의 POST·인접 GET 거부가 핵심 회귀를 덮는다. H2 날짜 alias는 기존 `src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt:10`을 사용하므로 운영 MySQL 검증과는 구분한다. +- 클라이언트 전달문도 배열·서버 순서·날짜 재변환 금지·미배포 시 명시적 mock·자동 fallback 금지를 계약과 일치하게 안내한다. + +## 4. 실행 증거와 검증 한계 + +아래 실행 결과는 **부모 실행자가 실제 관찰하여 전달한 증거**다. 리뷰어는 명령, XML 또는 HTTP 호출을 재검증하지 않았다. + +| 전달된 검증 | 결과와 한계 | +|---|---| +| focused RED | 11 tests / 7 failures. 미구현·미허용 v2 GET이 HTTP 401로 실패했으며 컴파일·인프라 실패를 RED로 세지 않았다. | +| focused GREEN XML | timestamp `2026-10-28T06:28:11`, 11 tests / 0 failures / 0 errors / 0 skipped. 전체 테스트 성공의 증거는 아니다. | +| 실제 HTTP 관찰 | v2 HTTP 200의 id 12/11/10 배열과 `[]`, v1 null/객체, 익명 POST 및 인접 GET의 401을 확인했다. 전체 필드·KST 경계는 위 focused 테스트와 계획의 응답 기록으로 연결된다. | +| `./gradlew --no-daemon bootJar ktlintCheck` | exit 0, 59초. 패키징·lint 성공이며 전체 `build` 성공으로 확대하지 않는다. | +| `./gradlew --no-daemon build --continue` | `:test`에서 1,200,000ms 후 타임아웃. **전체 테스트 및 전체 build 검증은 pending/blocked이며 P1-GATE 완료 불가.** 타임아웃 후 focused HTTP 재실행은 부모 담당 예정으로, 이 리뷰 시점에는 결과 미확인이다. | + +LSP 도구는 미제공이다. 운영 MySQL의 실제 timezone 처리·배포 및 홈 전체 HTTP 응답 회귀는 미검증이다. 이 한계만을 이유로 무관한 코드 수정을 요구하지 않는다. 이번 리뷰는 shell·런타임 자원·임시 root를 생성하지 않았다. + +## 5. 발견 사항과 후속 전환 + +**확정 발견 사항 없음.** 승인 계약과 검토 코드·테스트에서 수정이 필요한 결함을 확인하지 못했다. 신규 회귀 수정 Task/goal 전환 항목 없음. + +## 6. 리뷰 종료 판정 + +**규격 판정: 적합. 코드·테스트 품질 판정: 승인(검토 범위 내). 독립 리뷰는 완료하되 최종 P1-GATE는 전체 회귀·전체 build 미확인으로 보류한다. 배포 승인을 뜻하지 않는다.** + +남은 담당은 부모 실행자다. 타임아웃 후 focused HTTP 재실행 결과와 전체 회귀·전체 build의 종료 결과를 별도로 기록하고, 기존 문서의 Gate 상태·역할 표현을 최종 증거에 맞춰 정리한다. 이 리뷰에서는 다른 파일이나 완료 체크박스를 변경하지 않는다. + +## 7. 실행자 후속 증거 + +- 2026-10-28: 리뷰와 독립적으로 공지·기존 배포 엔드포인트 HTTP 테스트를 함께 재실행했다. `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest`가 exit 0, 3m29s로 끝났으며 XML은 11+4=15 tests, failures/errors/skipped 모두 0이다. +- production/test 수정 없이 문서의 현재 상태를 갱신했다. 전체 회귀의 시간 초과와 P1-GATE 보류는 그대로 유지한다. 이 기록은 리뷰어의 재실행 주장이 아니라 실행자의 추가 증거다. From b758a818347127f275e02ef384504bde760532c8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 11:02:45 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat(event):=20v2=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/configs/SecurityConfig.kt | 1 + .../adapter/in/web/EventDetailController.kt | 20 + .../event/application/EventDetailService.kt | 55 +++ .../EventDetailControllerIntegrationTest.kt | 350 ++++++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index bd55f234..b55e35f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -188,6 +188,7 @@ class SecurityConfig( .antMatchers("/live/recommend/channel").permitAll() .antMatchers(HttpMethod.GET, "/live/room").permitAll() .antMatchers(HttpMethod.GET, "/event").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/events/*").permitAll() .antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt new file mode 100644 index 00000000..8efc945d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.v2.api.event.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.event.application.EventDetailService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/events") +class EventDetailController(private val service: EventDetailService) { + @GetMapping("/{id}") + fun getEvent( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = ApiResponse.ok(service.getEvent(id, member)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt new file mode 100644 index 00000000..bc76a596 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.api.event.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.event.EventItem +import kr.co.vividnext.sodalive.event.EventRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.AuthRepository +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class EventDetailService( + private val repository: EventRepository, + private val authRepository: AuthRepository, + private val memberContentPreferenceService: MemberContentPreferenceService, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional(readOnly = true) + fun getEvent(id: Long, member: Member?): EventItem { + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") + + val event = repository.findByIdOrNull(id) + ?: throw SodaException(messageKey = "common.error.invalid_request") + val now = LocalDateTime.now() + val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO + + if (!event.isActive || event.startDate > now || event.endDate < now || event.lang != lang) { + throw SodaException(messageKey = "common.error.invalid_request") + } + + if (member?.role != MemberRole.ADMIN) { + val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false + if (event.isAdult != null && event.isAdult != isAdult) { + throw SodaException(messageKey = "common.error.invalid_request") + } + } + + return EventItem( + id = id, + thumbnailImageUrl = imageUrl(event.thumbnailImage), + detailImageUrl = event.detailImage?.let { imageUrl(it) }, + popupImageUrl = event.popupImage?.let { imageUrl(it) }, + link = event.link + ) + } + + private fun imageUrl(image: String): String = if (image.startsWith("https://")) image else "$cloudFrontHost/$image" +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt new file mode 100644 index 00000000..11f4cc36 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt @@ -0,0 +1,350 @@ +package kr.co.vividnext.sodalive.v2.api.event + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.event.Event +import kr.co.vividnext.sodalive.event.EventRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.auth.AuthRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import java.time.LocalDateTime + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "cloud.aws.s3.bucket=test-bucket", + "spring.datasource.url=jdbc:h2:mem:event-detail-controller-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class EventDetailControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val mockMvc: MockMvc, + private val eventRepository: EventRepository, + private val memberRepository: MemberRepository, + private val authRepository: AuthRepository, + private val objectMapper: ObjectMapper +) { + @MockBean + private lateinit var s3Uploader: S3Uploader + + @BeforeEach + @AfterEach + fun cleanUp() { + eventRepository.deleteAllInBatch() + authRepository.deleteAllInBatch() + memberRepository.deleteAllInBatch() + } + + @Test + @DisplayName("익명 상세는 기존 목록과 같은 5개 필드와 외부 링크를 반환하고 엔티티를 변경하지 않는다") + fun shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity() { + val event = saveEvent( + activeEvent().apply { + detailImage = null + popupImage = null + link = "https://external.test/promotion?from=soda&target=event#detail" + } + ) + + val detail = httpGet("/api/v2/events/${event.id}") + + assertEquals( + objectMapper.readTree( + """{"success":true,"message":null,"data":{ + "id":${event.id},"thumbnailImageUrl":"https://cdn.test/thumbnail.png", + "detailImageUrl":null,"popupImageUrl":null, + "link":"https://external.test/promotion?from=soda&target=event#detail" + },"errorProperty":null}""" + ), + detail + ) + assertEquals(httpGet("/event")["data"]["eventList"].single(), detail["data"]) + val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow() + assertEquals("thumbnail.png", persisted.thumbnailImage) + assertEquals(null, persisted.detailImage) + assertEquals(null, persisted.popupImage) + assertEquals(event.link, persisted.link) + } + + @ParameterizedTest + @CsvSource( + "https://images.test/image.png, https://images.test/image.png", + "relative/image.png, https://cdn.test/relative/image.png", + "http://images.test/image.png, https://cdn.test/http://images.test/image.png" + ) + @DisplayName("세 이미지 필드는 HTTPS만 보존하고 상대 경로와 HTTP는 기존 CDN 규칙을 따른다") + fun shouldMapAllImageFieldsLikeTheLegacyList(image: String, expectedImage: String) { + val event = saveEvent( + activeEvent().apply { + thumbnailImage = image + detailImage = image + popupImage = image + link = null + } + ) + + val detail = httpGet("/api/v2/events/${event.id}")["data"] + + assertEquals(5, detail.size()) + assertEquals(event.id, detail["id"].asLong()) + assertEquals(expectedImage, detail["thumbnailImageUrl"].asText()) + assertEquals(expectedImage, detail["detailImageUrl"].asText()) + assertEquals(expectedImage, detail["popupImageUrl"].asText()) + assertEquals(true, detail["link"].isNull) + assertEquals(httpGet("/event")["data"]["eventList"].single(), detail) + val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow() + assertEquals(image, persisted.thumbnailImage) + assertEquals(image, persisted.detailImage) + assertEquals(image, persisted.popupImage) + } + + @ParameterizedTest + @CsvSource( + "DEFAULT, KO", + "KR, KO", + "JP, JA", + "US, KO", + nullValues = ["DEFAULT"] + ) + @DisplayName("익명 상세의 국가별 언어와 비노출 응답은 기존 목록과 일치한다") + fun shouldApplyTheSameCountryLanguageAsTheLegacyList(country: String?, expectedLang: Lang) { + val events = listOf(Lang.KO, Lang.JA, Lang.EN).map { lang -> + saveEvent(activeEvent().apply { this.lang = lang }) + } + val expected = events.single { it.lang == expectedLang } + val legacyItems = httpGet("/event", country)["data"]["eventList"] + assertEquals(listOf(expected.id), legacyItems.map { it["id"].asLong() }) + + events.forEach { event -> + val detail = httpGet("/api/v2/events/${event.id}", country) + if (event.id == expected.id) { + assertEquals(true, detail["success"].asBoolean()) + assertEquals(legacyItems.single(), detail["data"]) + } else { + assertInvalidRequest(detail) + } + } + } + + @Test + @DisplayName("성인 null false true의 익명 미인증 인증 관리자 노출은 KO와 JA 모두 기존 목록과 같다") + fun shouldMatchTheLegacyAdultMatrixForAnonymousUnverifiedVerifiedAndAdminViewers() { + val unverified = saveMember("unverified") + val verified = saveMember("verified", verified = true) + val admin = saveMember("admin", role = MemberRole.ADMIN) + assertEquals(null, authRepository.getAuthIdByMemberId(requireNotNull(unverified.id))) + assertEquals(1L, authRepository.count()) + + listOf("KR" to Lang.KO, "JP" to Lang.JA).forEach { (country, lang) -> + val general = saveEvent(activeEvent().apply { this.lang = lang }) + val nonAdult = saveEvent( + activeEvent().apply { + this.lang = lang + isAdult = false + } + ) + val adult = saveEvent( + activeEvent().apply { + this.lang = lang + isAdult = true + } + ) + val events = listOf(general, nonAdult, adult) + val viewers = listOf( + null to setOf(general.id, nonAdult.id), + unverified to setOf(general.id, nonAdult.id), + verified to setOf(general.id, adult.id), + admin to setOf(general.id, nonAdult.id, adult.id) + ) + + viewers.forEach { (member, expectedIds) -> + val legacyItems = viewerGet("/event", member, country)["data"]["eventList"] + assertEquals(expectedIds, legacyItems.map { it["id"].asLong() }.toSet()) + events.forEach { event -> + val detail = viewerGet("/api/v2/events/${event.id}", member, country) + if (event.id in expectedIds) { + assertEquals(true, detail["success"].asBoolean()) + assertEquals(legacyItems.single { it["id"].asLong() == event.id }, detail["data"]) + } else { + assertInvalidRequest(detail) + } + } + } + } + } + + @Test + @DisplayName("관리자도 활성 기간 언어 조건을 우회하지 못하고 비노출 상세는 미존재와 같다") + fun shouldRejectAllHiddenStatesEvenForAdminWhileMatchingTheLegacyList() { + val visible = saveEvent(activeEvent()) + val hidden = listOf( + activeEvent().apply { isActive = false }, + activeEvent().apply { + startDate = LocalDateTime.now().plusDays(1) + endDate = startDate.plusDays(1) + }, + activeEvent().apply { + endDate = LocalDateTime.now().minusDays(1) + startDate = endDate.minusDays(1) + }, + activeEvent().apply { lang = Lang.JA }, + activeEvent().apply { lang = Lang.EN } + ).map { saveEvent(it) } + val unverified = saveMember("hidden-unverified") + val verified = saveMember("hidden-verified", verified = true) + val admin = saveMember("hidden-admin", role = MemberRole.ADMIN) + val missing = httpGet("/api/v2/events/${Long.MAX_VALUE}") + assertInvalidRequest(missing) + + listOf(null, unverified, verified, admin).forEach { member -> + val legacyItems = viewerGet("/event", member)["data"]["eventList"] + assertEquals(listOf(visible.id), legacyItems.map { it["id"].asLong() }) + hidden.forEach { event -> + assertEquals(missing, viewerGet("/api/v2/events/${event.id}", member)) + } + } + } + + @ParameterizedTest + @CsvSource("0", "-1", "9223372036854775807") + @DisplayName("양수가 아니거나 미존재인 ID는 HTTP 200 invalid_request 실패 응답을 반환한다") + fun shouldRejectNonpositiveAndMissingIdsWithTheCommonInvalidRequest(id: String) { + assertInvalidRequest(httpGet("/api/v2/events/$id")) + val result = mockGet("/api/v2/events/$id") + assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey) + } + + @ParameterizedTest + @CsvSource("not-a-number", "9223372036854775808") + @DisplayName("숫자가 아니거나 Long 범위를 넘는 ID는 기존 바인딩과 공통 예외 응답을 유지한다") + fun shouldKeepTheGlobalBindingErrorResponseForMalformedIds(id: String) { + val response = httpGet("/api/v2/events/$id") + assertEquals( + objectMapper.readTree( + """{"success":false,"message":"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "data":null,"errorProperty":null}""" + ), + response + ) + assertEquals( + MethodArgumentTypeMismatchException::class.java, + mockGet("/api/v2/events/$id").resolvedException?.javaClass + ) + } + + @ParameterizedTest + @CsvSource( + "POST, /api/v2/events/1", "PUT, /api/v2/events/1", "DELETE, /api/v2/events/1", + "GET, /api/v2/events", "POST, /api/v2/events", "GET, /api/v2/events/1/extra", + "GET, /api/v2/events/1/extra/deeper", "GET, /event/popup", "GET, /admin/event/banner", + "POST, /event", "PUT, /event", "DELETE, /event/1", "GET, /admin/event/banner/1" + ) + @DisplayName("익명 쓰기 collection 하위 경로 기존 팝업 관리자 API는 계속 인증이 필요하다") + fun shouldKeepAnonymousRequestsOutsideTheDetailGetProtected(method: HttpMethod, path: String) { + val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java) + println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } + + private fun httpGet(path: String, country: String? = null): JsonNode { + val headers = HttpHeaders().apply { + country?.let { set("CloudFront-Viewer-Country", it) } + } + val response = restTemplate.exchange(path, HttpMethod.GET, HttpEntity(headers), String::class.java) + println("HTTP GET $path country=$country -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + return objectMapper.readTree(response.body) + } + + private fun viewerGet(path: String, member: Member?, country: String? = null): JsonNode { + if (member == null) return httpGet(path, country) + return objectMapper.readTree(mockGet(path, member, country).response.contentAsByteArray) + } + + private fun mockGet(path: String, member: Member? = null, country: String? = null): MvcResult { + val request = get(path).apply { + country?.let { header("CloudFront-Viewer-Country", it) } + member?.let { + val adapter = MemberAdapter(it) + with(authentication(UsernamePasswordAuthenticationToken(adapter, null, adapter.authorities))) + } + } + val result = mockMvc.perform(request).andReturn() + println( + "MockMvc GET $path role=${member?.role} country=$country -> ${result.response.status} " + + result.response.getContentAsString(Charsets.UTF_8) + ) + assertEquals(200, result.response.status) + return result + } + + private fun assertInvalidRequest(response: JsonNode) { + assertEquals( + objectMapper.readTree( + """{"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}""" + ), + response + ) + } + + private fun saveMember(seed: String, role: MemberRole = MemberRole.USER, verified: Boolean = false): Member { + val member = memberRepository.saveAndFlush(Member(password = "test-password", nickname = seed, role = role)) + if (verified) { + val auth = authRepository.saveAndFlush( + Auth("test-member", "19900101", "test-ci-$seed", "test-di-$seed", 1).apply { this.member = member } + ) + assertEquals(auth.id, authRepository.getAuthIdByMemberId(requireNotNull(member.id))) + } + return member + } + + private fun saveEvent(event: Event): Event = eventRepository.saveAndFlush(event) + + private fun activeEvent(): Event = Event( + thumbnailImage = "thumbnail.png", + detailImage = "detail.png", + popupImage = "popup.png", + link = "https://external.test/event", + title = "not-returned-title", + isAdult = null, + isPopup = false, + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1) + ) +} From 3a4fff5bac588e476f70e047fa47b7bdc31b7127 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 11:03:19 +0900 Subject: [PATCH 05/13] =?UTF-8?q?docs(event):=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EA=B3=BC=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EB=8F=99=EC=9D=84=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api-client-prompt.md | 75 +++++++++++++++++++ docs/20261028_일반이벤트상세조회/prd.md | 54 +++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 docs/20261028_일반이벤트상세조회/api-client-prompt.md create mode 100644 docs/20261028_일반이벤트상세조회/prd.md diff --git a/docs/20261028_일반이벤트상세조회/api-client-prompt.md b/docs/20261028_일반이벤트상세조회/api-client-prompt.md new file mode 100644 index 00000000..6a336bea --- /dev/null +++ b/docs/20261028_일반이벤트상세조회/api-client-prompt.md @@ -0,0 +1,75 @@ +# 일반용 이벤트 상세 API 클라이언트 구현 프롬프트 + +아래 프롬프트는 이번에 구현한 서버 계약이다. 운영 배포 완료를 의미하지 않는다. + +```text +기존 앱의 네트워크 계층과 화면 패턴을 사용해 일반 사용자용 이벤트 상세 조회를 연결해 주세요. +관리자 상세 API를 만들거나 기존 이벤트 목록/팝업 API를 변경하지 마세요. + +[요청] +GET {기존 API_BASE_URL}/api/v2/events/{id} +- id: 기존 이벤트 목록/배너의 이벤트 id. 양수 64비트 정수이며 필수입니다. +- 요청 본문과 query parameter는 없습니다. +- 비로그인 호출이 가능합니다. 비로그인에는 Authorization 헤더를 보내지 마세요. +- 로그인 시 기존 Authorization: Bearer {accessToken}을 사용하세요. +- 오류 메시지 언어는 기존 Accept-Language 정책을 따릅니다. +- 이벤트 언어는 Accept-Language가 아니라 서버의 국가 판정에 따릅니다. + CloudFront-Viewer-Country 및 기존 서버 보정 정책을 사용하므로 국가를 임의로 바꾸지 마세요. + +[성공: HTTP 200, application/json] +{ + "success": true, + "message": null, + "data": { + "id": 123, + "thumbnailImageUrl": "https://cdn.example.com/event/thumbnail.png", + "detailImageUrl": "https://cdn.example.com/event/detail.png", + "popupImageUrl": null, + "link": "https://example.com/promotion" + }, + "errorProperty": null +} +- data는 배열이 아닌 이벤트 단일 객체입니다. +- id: 정수, thumbnailImageUrl: 문자열. +- detailImageUrl, popupImageUrl, link: 문자열 또는 null. +- title, content, startDate, endDate, lang, isAdult, isPopup, totalCount는 반환하지 않습니다. +- 이미지 URL은 서버가 구성한 값을 사용하고 CDN 주소를 다시 붙이지 마세요. +- link는 이미지 URL과 별개인 이동 목적지이며 저장값 그대로 반환합니다. +- 상세 이미지가 null이어도 link가 있는 정상 이벤트일 수 있습니다. +- 이미지와 link가 모두 있을 때의 우선순위나 자동 이동은 API가 결정하지 않습니다. + 기존 앱의 표시/이동 정책을 유지하고, 임의로 자동 리다이렉트를 추가하지 마세요. +- 서버는 link의 URL 형식이나 허용 스킴을 검증하지 않습니다. + 링크 열기 시 기존 앱의 안전한 외부 URL 처리 정책을 적용하세요. + +[조회 불가: HTTP 200] +{ + "success": false, + "message": "잘못된 요청입니다.", + "data": null, + "errorProperty": null +} +- 미존재 ID, 0/음수 ID, 비활성, 시작 전/종료 후, 언어 불일치, 성인 조건 불일치가 동일한 응답입니다. +- 숫자가 아니거나 Long 범위를 넘는 id는 HTTP 200 실패 응답과 기존 공통 오류 메시지를 반환합니다. +- HTTP 200만으로 성공 판정하지 말고 success=true와 유효한 data를 확인하세요. +- message는 지역화되므로 문구로 오류 유형을 분기하지 마세요. +- 서버 내부 messageKey는 응답에 없으며 미존재와 비노출 사유를 구별할 수 없습니다. +- 이 API의 조회 불가가 HTTP 404라고 가정하지 마세요. +- 네트워크 오류와 비정상 HTTP 상태/인증 오류는 기존 앱의 공통 처리 정책을 유지하세요. + 모든 오류 응답이 위 JSON 형태라고 가정하지 마세요. + +[서버 노출 정책: 기존 GET /event와 동일] +- 활성이고 시작 시각 <= 현재 시각 <= 종료 시각인 이벤트만 조회됩니다. +- 서버 판정 국가가 JP이면 JA 이벤트, 그 외에는 KO 이벤트만 조회됩니다. +- 비로그인/성인 미인증 회원: isAdult=null 또는 false 이벤트. +- 성인 인증 회원: isAdult=null 또는 true 이벤트. 기존 정책상 false 이벤트는 제외됩니다. +- 관리자 계정: 성인 조건만 생략하며 활성/기간/언어 조건은 유지됩니다. +- 위 정책은 서버가 적용합니다. 클라이언트가 목록과 다른 우회 조회를 구현하지 마세요. +- 로그인 상태/국가가 바뀌면 이전 조회 결과를 무조건 재사용하지 마세요. + +[클라이언트 수용 기준] +- 이벤트 id로 상세 API를 호출해 loading/success/unavailable/network-error를 구분합니다. +- nullable 이미지/link 및 링크만 있는 이벤트를 정상 처리합니다. +- HTTP 200 success=false에서도 기존 데이터를 성공 화면으로 표시하지 않습니다. +- 로그인 여부에 따른 헤더와 실제 서버 결과를 사용하고, 응답에 없는 제목/본문/날짜를 만들어 내지 않습니다. +- 기존 목록 GET /event, 팝업 GET /event/popup, 관리자 API는 그대로 유지합니다. +``` diff --git a/docs/20261028_일반이벤트상세조회/prd.md b/docs/20261028_일반이벤트상세조회/prd.md new file mode 100644 index 00000000..45288cc6 --- /dev/null +++ b/docs/20261028_일반이벤트상세조회/prd.md @@ -0,0 +1,54 @@ +# 일반용 이벤트 상세 조회 PRD + +## 문서 정보 +- 작성일: 2026-10-28 +- 상태: 구현 및 검증 완료 (운영 배포 제외) +- 결정권자: 사용자; 구현 담당: Sisyphus +- 구현 계획: [plan-task.md](plan-task.md) +- 클라이언트 전달 계약: [api-client-prompt.md](api-client-prompt.md) +- 최종 리뷰: [reviews/event-detail-review.md](reviews/event-detail-review.md), APPROVE + +## 목표와 범위 +이벤트 ID만으로 일반용 이벤트 정보를 조회할 수 있도록 `GET /api/v2/events/{id}`를 추가한다. +기존 목록과 동일한 노출 조건으로 상세 이미지와 외부 이동 링크를 반환한다. +관리자 상세 API, 기존 `/event` 계약 변경, 프론트엔드, DB 변경, 신규 의존성은 제외한다. + +## 확정 요구사항 +| ID | 요구사항 | 수용 기준 | +|---|---|---| +| EVT-001 | 비로그인 가능한 v2 단건 조회 | 정상 ID 요청은 HTTP 200, `success=true`, 단일 EventItem 반환 | +| EVT-002 | 기존 목록과 동일한 노출 정책 | 활성, 시작/종료 시각 포함 범위, 국가별 언어, 성인 인증 조건 일치 | +| EVT-003 | 기존 응답 필드 재사용 | id, thumbnailImageUrl, detailImageUrl, popupImageUrl, link 보존 | +| EVT-004 | 미존재와 비노출 동일 처리 | HTTP 200, `success=false`, `data=null`, 기존 invalid_request 메시지 | +| EVT-005 | 인접 API 보안 유지 | 익명 쓰기·하위 추가 경로·기존 팝업·관리자 요청은 계속 보호 | + +## API 계약 +- Method/path: `GET /api/v2/events/{id}`. ID는 양수 Long. +- 인증: 선택적 기존 JWT. 인증이 없으면 비성인 기준. 잘못된 JWT 처리는 기존 보안 정책 유지. +- 성공: `ApiResponse.ok(EventItem)`. 응답 필드는 기존 목록 항목과 동일하며 제목/기간 필드를 추가하지 않는다. +- 이미지: `https://`로 시작하면 그대로, 나머지 문자열은 기존 CloudFront 호스트와 `/`를 앞에 붙인다. nullable 이미지는 null 유지. +- `link`: 저장값 그대로 반환하고 서버에서 이동하거나 URL을 요청하지 않는다. +- 양수가 아닌 ID, 미존재, 비노출: `SodaException(messageKey = "common.error.invalid_request")`로 기존 HTTP 200 실패 envelope 사용. +- 숫자가 아니거나 Long 범위를 넘는 ID: 기존 Spring 바인딩과 공통 오류 처리 유지. 새 parser/handler 없음. + +## 노출 정책 +- `isActive == true`, `startDate <= now <= endDate`. +- `MemberContentPreferenceService.resolveCountryCode(member)` 결과가 JP면 JA, 그 외 KO. +- 관리자 역할은 성인 조건만 생략하며 활성/기간/언어는 생략하지 않는다. +- 비로그인 및 인증 기록 없는 일반 회원: 이벤트 `isAdult`가 null 또는 false. +- 인증 기록 있는 일반 회원: 이벤트 `isAdult`가 null 또는 true. 기존 목록처럼 false 이벤트는 제외한다. +- ID로 단건 조회하며 전체 목록을 가져와 검색하지 않는다. + +## 검증 시나리오 +| 시나리오 | 이진 성공 조건 | 실제 검증 표면 | +|---|---|---| +| S1 정상 | 익명 KO/JP, null/HTTPS/상대 이미지, 외부 link가 기존 항목과 동일 | RANDOM_PORT + TestRestTemplate 실제 HTTP 및 H2 | +| S2 경계/권한 | 미존재·비활성·예정·종료·언어 불일치·성인 제한에서 데이터 미노출; 일반/인증/관리자 정책 일치 | 실제 HTTP + 인증 MockMvc + 실제 DB | +| S3 회귀 | 기존 목록 응답 유지, 익명 팝업/관리자/POST·PUT·DELETE·추가 하위 경로 거부 | 실제 HTTP, 기존 event/admin 통합 테스트 | + +## Decision Log +- 사용자 확정: 관리자 상세 불필요, 일반 상세만 추가; v2 경로 `/api/v2/events/{id}` 승인; 나머지 기존 정책 적용. +- 구현 선택: 기존 EventItem/엔티티/repository 재사용, v2 controller와 read-only service만 추가. 단건 로드 후 정책 확인으로 불필요한 port/DTO 계층을 만들지 않는다. +- 구현 선택: 미조회 오류는 기존 이벤트 수정/삭제와 공통 예외 관례를 따른다. 별도 HTTP 404 계약을 만들지 않는다. +- 사용자의 ulw 지시에 따라 실제 HTTP 증거, 전체 테스트/빌드와 독립 리뷰를 수행한다. 런타임/도구 제한은 숨기지 않는다. +- 후속 사용자 요청: 최종 결과에 클라이언트 개발자가 사용할 API 계약을 프롬프트 형태로 제공한다. 구현된 서버 계약만 명시하고 새로운 화면/서버 기능을 추가하지 않는다. From 948c032e30d388e9d79c722b5dcff59ef2517987 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 11:03:29 +0900 Subject: [PATCH 06/13] =?UTF-8?q?docs(event):=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261028_일반이벤트상세조회/plan-task.md | 152 ++++++++++++++++++ .../reviews/event-detail-review.md | 62 +++++++ 2 files changed, 214 insertions(+) create mode 100644 docs/20261028_일반이벤트상세조회/plan-task.md create mode 100644 docs/20261028_일반이벤트상세조회/reviews/event-detail-review.md diff --git a/docs/20261028_일반이벤트상세조회/plan-task.md b/docs/20261028_일반이벤트상세조회/plan-task.md new file mode 100644 index 00000000..a8543046 --- /dev/null +++ b/docs/20261028_일반이벤트상세조회/plan-task.md @@ -0,0 +1,152 @@ +# 일반용 이벤트 상세 조회 구현 계획 + +**Goal:** 일반 사용자가 기존 노출 정책을 통과한 이벤트 한 건을 v2 API로 조회한다. +**Architecture:** 기존 EventRepository의 ID 조회와 EventItem을 재사용한다. 신규 controller와 read-only service는 v2 아래 배치하고 기존 controller/service는 변경하지 않는다. +**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JPA, JUnit 5, H2. +**Spec:** [prd.md](prd.md) +**실행:** subagent-driven-development; 커밋/푸시는 별도 승인 없이 하지 않는다. + +## 현재 상태 +- 상태: 구현 및 검증 완료 (운영 배포 제외) +- 현재 Phase: 1 +- 활성/다음 Goal: 없음 (P1-T1, P1-GATE 완료) +- 순서: P1-T1 → P1-GATE. 동시에 하나만 실행한다. + +## Global Constraints +- 승인된 GET `/api/v2/events/{id}`만 추가. 관리자 상세/legacy 변경/DB·의존성 변경 제외. +- 기존 성인/언어 정책 및 이미지 변환을 그대로 유지한다. +- 모든 신규 API/하위 구현은 v2 패키지에 둔다. 불필요한 추상화/port는 만들지 않는다. +- 기존 테스트 삭제·완화, 비밀값 기록 금지. 소유 임시 자원만 정리한다. + +## Review Focus +- 인증된 성인에게 `isAdult=false` 이벤트를 추가로 노출하지 않는지 확인한다. +- 관리자도 언어·기간·활성 조건을 지키는지 확인한다. +- 익명 허용이 쓰기/collection/추가 하위 경로로 확장되지 않는지 확인한다. +- null 이미지, 외부 HTTPS 이미지, 외부 link를 변경하거나 관리 엔티티를 수정하지 않는지 확인한다. +- 미존재와 비노출 오류가 동일하고 상세 정보를 누출하지 않는지 확인한다. + +### Phase 1: v2 단건 조회 + +- [x] **Task 1.1: 일반 이벤트 상세 조회와 보안 회귀 검증** + +**Goal P1-T1:** 기존 목록과 같은 노출 정책을 적용한 단일 EventItem을 반환한다. +- 시작 조건: PRD 확정, 기존 event/admin focused test 성공. +- 완료 증거: RED/GREEN 기록, S1~S3 테스트 성공, 실제 HTTP 응답 증거. +- 범위 밖: 전체 빌드/독립 리뷰는 P1-GATE; legacy 리팩터링과 추가 기능. + +**Files:** +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt` +- 필요한 서비스 경계 테스트는 같은 v2/api/event 패키지에 통합 테스트로 추가한다. + +**Interfaces:** +- Consumes: `EventRepository`, `AuthRepository.getAuthIdByMemberId`, `MemberContentPreferenceService.resolveCountryCode`, `EventItem`. +- Produces: `EventDetailService.getEvent(id: Long, member: Member?): EventItem`. +- Controller: `/api/v2/events`, `@GetMapping("/{id}")`, nullable `@AuthenticationPrincipal`. +- Security: GET `/api/v2/events/*`만 permitAll; `/**` 금지. + +- [x] **RED:** 먼저 실제 HTTP 정상 요청 테스트를 작성하고, 조회 불가·성인 3상태·언어·관리자·응답 변환·인접 보안 시나리오를 추가한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain`에서 미구현 route/security로 정상 요청이 실패함을 기록한다. +- [x] **GREEN:** ID 단건 로드, 양수 검사, 활성/현재 시각/언어/성인 필터, EventItem 변환, 좁은 보안 허용을 구현한다. +- [x] **GREEN 확인:** 동일 focused test로 모든 시나리오 통과를 확인한다. +- [x] **REFACTOR:** 새 중복만 필요 시 정리하고 기존 event/admin 회귀를 다시 실행한다. + +**검증 세부:** +- 실제 DB에 저장한 KO/JA 및 nullable adult 필드를 비교한다. 인증 일반 회원의 auth 기록은 실제 DB로 준비한다. +- `isAdult` null/false/true 각각을 익명·미인증·인증·관리자에 대해 기존 `/event`와 비교한다. +- 미래/종료 테스트는 현재 시각에서 충분히 떨어진 값을 사용해 flake를 피한다. 기간 조건의 양끝 포함은 코드 및 가능한 기존 seam으로 검증한다. +- 이미지/링크 null, 상대 경로, HTTPS, 기존 http 문자열 보정 및 모든 응답 필드를 확인한다. +- missing/zero/negative/nonnumeric/overflow ID, non-GET, 하위 추가 경로, legacy 목록/팝업/admin 접근을 검증한다. +- RANDOM_PORT/TestRestTemplate의 상태/본문을 test output으로 남긴다. 인증은 동일 컨텍스트 MockMvc로도 검증 가능하다. + +### Phase 1 Gate +- [x] **Goal P1-GATE: 동작·회귀·빌드·리뷰·정리 완료** +- 시작 조건: P1-T1 완료. +- 완료 증거: 아래 명령과 HTTP 증거, 독립 reviewer 승인, 자원 정리 확인. +- 범위 밖: 기존 전체 테스트 실패/린트 위반의 무관한 수정. +- TDD 예외 사유: 읽기 전용 완료 검증이며 테스트 추가가 아닌 실행·리뷰 단계다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain +./gradlew test --console=plain +./gradlew bootJar ktlintCheck --console=plain +./gradlew tasks --all --console=plain +``` + +- LSP 도구가 노출되어 있으면 변경 파일 진단, 없으면 Kotlin compile/ktlint 결과와 미실행 사실을 기록한다. +- 사용자 ulw 지시 및 보안 matcher 추가로 전체 테스트를 실행한다. 실패가 기존 상태에서도 재현되면 별도 제한으로 기록한다. +- 고위험 reviewer 1명이 목표·S1~S3·diff·실행 증거를 확인한다. 범위 내 blocker만 수정한다. +- 소유 임시 폴더/QA 프로세스가 정리되었음을 확인한다. 공유 Gradle 캐시는 삭제하지 않는다. +- 후속 사용자 요청의 전달물: `api-client-prompt.md`에 구현된 요청/응답/오류/노출 정책을 기록하고 최종 응답에도 프롬프트로 제공한다. 문서 작업의 TDD는 해당 없으며 실제 HTTP 응답 및 코드와 대조한다. + +## Progress +### 사전 검증 — 2026-10-28 +- worktree: clean. +- `./gradlew test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain`: BUILD SUCCESSFUL, 1m 1s. 구현 전 기존 목록/팝업/관리자 테스트 기준선 확보. +- 부모 세션에는 lsp_diagnostics 도구가 노출되지 않았다. 진단 성공으로 보고하지 않는다. + +### P1-T1 RED — 2026-10-28 +- 새 production 타입을 참조하지 않는 RANDOM_PORT/TestRestTemplate + 같은 컨텍스트 MockMvc 통합 테스트를 먼저 작성했다. S3 외 노출 정책·repository·인증 기록은 mock 없이 실제 H2로 검증한다. +- 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`. +- 첫 실행은 도구의 120초 제한으로 test compile 중 중단되어 RED 근거로 사용하지 않았다. 같은 명령을 600초 제한으로 재실행: BUILD FAILED, 8m 59s, 28 tests completed / 15 failed / 0 errors. compile 성공 후 실제 HTTP assertion에서 expected 200 OK / actual 401 UNAUTHORIZED로 실패했다. 기존 보호 경로 13건은 통과했다. +- 정상 요청 RED 원문: `HTTP GET /api/v2/events/19 country=null -> 401 {"timestamp":"2026-10-28T09:03:37.443+00:00","status":401,"error":"Unauthorized","path":"/api/v2/events/19"}`. `shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity`의 HTTP 상태 assertion 실패이며 missing-class compile 실패가 아니다. +- RED 원본은 소유 임시 폴더의 `red-retry.log` 및 당시 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`에서 확인했다. XML은 다음 실행으로 갱신되므로 핵심 결과와 응답을 이 기록에 보존한다. +- 실행 자원: `--no-daemon`과 in-process Kotlin compile을 사용했다. 소유 임시 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-detail-p1-t1.xH3EUF`이며 작업 종료 시 삭제하고 공유 캐시/다른 세션 프로세스는 건드리지 않는다. + +### P1-T1 GREEN 및 인접 회귀 — 2026-10-28 +- 변경 파일은 신규 `EventDetailController.kt`, `EventDetailService.kt`, 신규 `EventDetailControllerIntegrationTest.kt`, `SecurityConfig.kt`의 GET matcher 한 줄, 이 계획 문서뿐이다. 기존 엔티티/서비스/컨트롤러와 PRD는 변경하지 않았다. +- `EventRepository.findByIdOrNull`로 단건을 로드하고 read-only 서비스에서 기존 목록과 동일한 정책을 확인한다. 전체 목록 조회, 새 port/DTO/의존성, 별도 parser/exception handler는 추가하지 않았다. 관리자 우회는 성인 조건에만 적용한다. +- focused GREEN 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process` → BUILD SUCCESSFUL, 8m 26s, 28 tests / 0 failures / 0 errors / 0 skipped. +- 인접 회귀 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process` → BUILD SUCCESSFUL, 3m 45s. XML 합계 46 tests / 0 failures / 0 errors / 0 skipped: 신규 28, 기존 EventController 5 / EventRepository 5 / EventService 4 / AdminEventBannerController 4. +- S1: RANDOM_PORT 실제 HTTP로 익명 KO/JP/기타 국가/헤더 누락, 5개 필드 전체, nullable 이미지·link, HTTPS 보존, 상대/http 이미지 CDN 접두사, 외부 link 보존을 확인했다. `/event` 항목과 상세를 완전 비교하고 DB 재조회로 이미지 필드가 변하지 않음을 확인했다. +- S2: 실제 H2 auth 기록이 있는/없는 일반 회원과 관리자는 같은 컨텍스트 MockMvc로 검증했다. KO/JA × adult null/false/true × 익명/미인증/인증/관리자 24조합의 상세와 기존 목록을 비교했다. 인증 성인은 false를 제외하고 관리자는 false/true/null을 허용한다. 비활성/미래/종료/언어 불일치는 관리자에게도 미존재와 동일한 실패 응답이다. 양수 검사·미존재는 HTTP 200 invalid_request 및 실제 SodaException messageKey, malformed/overflow는 기존 MethodArgumentTypeMismatchException과 공통 unknown 응답을 확인했다. +- 기간은 현재 시각에서 하루 이상 떨어진 값으로 테스트했다. 정확히 같은 시각의 포함 경계는 기존 주입 seam이 없어 시계를 새로 추상화하지 않고 코드 비교로 확인했다: legacy `startDate.loe(now)` / `endDate.goe(now)`, 신규는 `startDate > now` / `endDate < now`만 거부하여 양끝을 포함한다. +- S3: 실제 HTTP 보안 13건에서 익명 POST/PUT/DELETE, collection, 추가 하위 경로, legacy 팝업/쓰기, 관리자 목록/단건 경로 모두 401을 확인했다. 익명 기존 `/event` 목록은 계속 200이며 기존 목록/팝업/관리자 회귀도 통과했다. +- 검증 중 제한: 첫 GREEN은 테스트 소스 compile 중 600초 도구 제한, 첫 인접 회귀는 180초 제한으로 종료되어 통과 근거로 사용하지 않았다. 제한을 늘려 재실행했다. 중간 GREEN의 2건 실패는 MockMvc `contentAsString`의 기본 문자셋으로 한글을 잘못 해석한 테스트 문제였다. JSON은 원본 bytes로 파싱하고 로그만 명시적 UTF-8로 읽도록 수정했으며 기대값/정책 assertion은 완화하지 않았다. +- 진단: 이 세션에도 `lsp_diagnostics` 도구가 없어 미실행. Gradle의 production/test Kotlin compile은 성공했다. 변경 파일에서 compiler 경고는 없었고 기존 다른 파일의 불필요한 `!!`/unchecked cast/미사용 인자 및 Gradle deprecated 경고는 수정하지 않았다. 전체 suite, bootJar, ktlintCheck, tasks, 독립 리뷰는 부모 담당 P1-GATE로 남겨 실행하지 않았다. +- HTTP 원본 artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`의 `system-out`에 상태/본문을 기록했다. HTML은 `build/reports/tests/test/classes/kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.html`. 다음 Gradle test가 덮어쓸 수 있어 핵심 응답을 아래 보존한다. + +```text +HTTP GET /api/v2/events/22 country=null -> 200 {"success":true,"message":null,"data":{"id":22,"thumbnailImageUrl":"https://cdn.test/thumbnail.png","detailImageUrl":null,"popupImageUrl":null,"link":"https://external.test/promotion?from=soda&target=event#detail"},"errorProperty":null} +HTTP GET /api/v2/events/17 country=JP -> 200 {"success":true,"message":null,"data":{"id":17,"thumbnailImageUrl":"https://cdn.test/thumbnail.png","detailImageUrl":"https://cdn.test/detail.png","popupImageUrl":"https://cdn.test/popup.png","link":"https://external.test/event"},"errorProperty":null} +HTTP GET /api/v2/events/0 country=null -> 200 {"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null} +HTTP GET /api/v2/events/1/extra -> 401 {"timestamp":"2026-10-28T09:37:20.055+00:00","status":401,"error":"Unauthorized","path":"/api/v2/events/1/extra"} +HTTP GET /event/popup -> 401 {"timestamp":"2026-10-28T09:37:20.121+00:00","status":401,"error":"Unauthorized","path":"/event/popup"} +``` + +### P1-T1 Teardown Receipt — 2026-10-28 +- 마지막 테스트 executor PID 18510 종료와 소유 root에 열린 파일 없음(`lsof -nP +D`)을 확인했다. mktemp root 소유 표식을 읽고 허용 parent의 canonical path·직접 하위 이름·비심볼릭링크 조건을 확인한 뒤 이 세션의 `event-detail-p1-t1.xH3EUF`만 삭제했다. macOS `/var`의 canonical path가 `/private/var`여서 첫 경로 가드는 삭제를 거부했고, 양쪽 parent를 동일하게 정규화한 후 삭제했다. +- 실제 반환: `TEARDOWN OK: owned event-detail-p1-t1.xH3EUF removed; test executor exited; no open files; shared caches untouched`. RED/GREEN의 핵심 결과와 HTTP 응답은 위에 보존했으며 Gradle XML/HTML은 worktree build 폴더에 남아 있다. +- P1-T1만 완료했다. P1-GATE 체크박스·기준·상단 실행 상태는 부모 세션 소유로 유지하며 커밋/푸시/worktree/다른 에이전트/전체 suite·build·review는 실행하지 않았다. + +### P1-GATE 부모 검증 — 2026-10-28 +- 부모가 새 controller/service와 전체 통합 테스트, SecurityConfig의 GET matcher 한 줄을 직접 검토했다. 기존 API 변경은 없다. +- `./gradlew bootJar --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 1m 9s. +- 최초 ktlintCheck는 새 테스트 CsvSource의 줄바꿈 3건을 지적했다. 데이터/기대값은 유지하고 각 인자를 별도 줄로 배치했다. +- `./gradlew ktlintCheck --console=plain --no-daemon`: BUILD SUCCESSFUL, 1m 33s. 전체 Kotlin main/test/script 검사 통과. +- 첫 전체 테스트는 사용자 중단으로 끝났으며 성공 근거에서 제외했다. 재개 후 `./gradlew test --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 19m 29s. +- 최종 XML 집계: 326 suites, 2,584 tests, failures=0, errors=0, skipped=0. 새 EventDetailControllerIntegrationTest 28건과 기존 event/admin 테스트도 포함한다. +- 실제 HTTP 표면은 RANDOM_PORT의 TestRestTemplate 호출이다. 부모 전체 테스트 실행에서도 성공/비노출/인접 보안 응답을 확인했다. 인증 사용자 행렬은 실제 DB fixture와 MockMvc로 검증했다. +- 최종 HTTP artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`, timestamp `2026-10-28T10:19:56`. +- 대표 응답: `/api/v2/events/22` → HTTP 200, success=true, id=22, thumbnailImageUrl=https://cdn.test/thumbnail.png, detailImageUrl=null, popupImageUrl=null, 외부 link 보존. `/api/v2/events/0` → HTTP 200, success=false, data=null. `/event/popup` 익명 → HTTP 401. +- `./gradlew tasks --all --console=plain`: BUILD SUCCESSFUL. 문서의 Gradle 명령 존재 확인. +- 이전 리뷰 작업은 완료 알림이 있었으나 중단 후 background_output/session_read 및 정확한 로컬 session ID 조회로 결과를 회수하지 못했다. 이를 승인으로 간주하지 않고 동일 범위의 대체 리뷰 하나만 요청했다. +- `lsp_diagnostics`는 부모/구현 세션 모두 도구 미제공으로 미실행. Kotlin 컴파일·ktlint 성공을 대체 증거로 기록한다. 운영 배포와 운영 DB 호출은 수행하지 않았다. + +### P1-GATE 최종 판정 및 전달 — 2026-10-28 +- `./gradlew bootJar ktlintCheck tasks --all --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: 최종 재확인 BUILD SUCCESSFUL, 17s. `git diff --check`도 통과했다. +- 독립 대체 리뷰: [reviews/event-detail-review.md](reviews/event-detail-review.md), APPROVE, 확정 발견 사항 없음. 부모가 리뷰 파일을 읽고 실제 코드·HTTP·전체 테스트 근거와 대조했다. 리뷰 환경은 임시 자원/프로세스 생성 0건이다. +- 클라이언트용 [api-client-prompt.md](api-client-prompt.md)를 실제 endpoint, 5개 응답 필드, nullable 값, HTTP 200 실패 envelope, JWT/언어·국가 처리와 대조했다. 기존 API와 관리자 범위는 그대로다. +- 부모 최종 `pgrep`에서 GradleWrapperMain/Gradle Test Executor/소유 root 관련 프로세스가 없었다. 부모 root는 빈 opencode 폴더만 남아 `rmdir`로 제거했다. 소유 notepad와 구현 root도 부재를 확인했다. +- 정리 read-back: `TEARDOWN OK: parent root, implementation root and notepad absent; shared caches untouched`. 공유 캐시나 다른 세션의 파일/프로세스는 삭제하지 않았다. +- 완료 범위: 승인 API·문서·시나리오 S1~S3·전체 테스트·빌드·린트·리뷰·소유 자원 정리. 제한: LSP 미제공으로 미실행, 기간 동일 시각 경계는 코드 대조, 인증 viewer는 MockMvc 검증. 운영 배포·운영 DB 검증·커밋·푸시는 하지 않았다. + +### 커밋 전 재검증 — 2026-10-29 +- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' bootJar ktlintCheck --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 3m 55s. 테스트 5 suites / 46 tests / failures=0 / errors=0 / skipped=0. bootJar·ktlintCheck는 변경 없음으로 UP-TO-DATE다. +- `git diff --check` 및 구현·테스트의 `git diff --staged --check` 통과. 커밋 대상의 비밀값·credential artifact 포함 여부를 확인했다. +- 전체 회귀는 P1-GATE의 2,584건 성공 이후 구현 변경이 없어 재실행하지 않았다. 대신 신규 상세·기존 이벤트·관리자 배너 회귀를 실행했다. + +## 종료 기준 +승인 endpoint의 정상/경계/회귀가 실제 요청으로 검증되고, 테스트·빌드·사용 가능한 진단 및 독립 리뷰를 마치고 소유 QA 자원이 정리되면 종료한다. 실행 불가능하거나 기존 실패인 항목은 근거와 함께 명시하고 완료로 가장하지 않는다. diff --git a/docs/20261028_일반이벤트상세조회/reviews/event-detail-review.md b/docs/20261028_일반이벤트상세조회/reviews/event-detail-review.md new file mode 100644 index 00000000..a8640f09 --- /dev/null +++ b/docs/20261028_일반이벤트상세조회/reviews/event-detail-review.md @@ -0,0 +1,62 @@ +# 일반 이벤트 상세 조회 최종 코드 리뷰 + +**판정: APPROVE. 확정 발견 사항 없음.** + +## 1. 리뷰 정보와 범위 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | P1-T1 구현 및 P1-GATE의 코드·검증 증거 | +| 기준 상태 | 현재 working tree의 신규 파일 직접 열람 및 SecurityConfig의 GET matcher | +| 리뷰 일자 / 리뷰어 | 2026-10-28 / Sisyphus-Junior | +| 기준 문서 | [prd.md](../prd.md) EVT-001~005, [plan-task.md](../plan-task.md) P1-T1·P1-GATE | +| 리뷰 상태 | 판정 완료. 호스트 중단 전 리뷰의 판정은 승계하지 않음 | + +변경 내용: 익명 접근 가능한 `GET /api/v2/events/{id}`가 기존 목록의 노출 정책을 적용해 단일 `EventItem`을 반환한다. 관리자 상세나 추가 응답 필드는 만들지 않는다. + +신규 controller/service/test, 보안 matcher와 공통 인증·예외 경로, 기존 event Service/Repository/DTO 및 인접 회귀 결과를 검토했다. 제품·테스트·계획 문서 수정, 테스트·빌드 재실행, 별도 HTTP 실행, 에이전트·커밋은 제외했다. 다중 사용자 요청을 전제로 단건 조회와 상태 변경 여부를 확인했으며 부하 측정은 하지 않았다. + +## 2. 요구사항 대조 + +| 기준 | 판정 및 코드·테스트 근거 | +|---|---| +| EVT-001: 일반용 단건 GET만 추가 | [EventDetailController.kt:12](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt:12)의 단일 GET mapping, nullable principal, `ApiResponse.ok` 확인. 관리자 endpoint는 추가하지 않음. [EventDetailService.kt:25](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:25)는 read-only이며 ID로 조회하고 전체 목록을 검색하지 않음. | +| EVT-002: 활성·기간·언어 | [EventDetailService.kt:31](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:31)의 `startDate > now` / `endDate < now` 거부는 [EventRepository.kt:21](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt:21)의 `loe` / `goe`와 동일하게 양끝을 포함함. [EventService.kt:92](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt:92)와 같은 country resolver로 JP→JA, 그 외→KO. 활성·기간·언어 검사는 관리자 분기보다 먼저 수행됨. | +| EVT-002: 성인 3상태 및 관리자 우회 | [EventDetailService.kt:38](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:38)는 실제 auth 기록으로 판단함. 익명·미인증은 null/false, 인증 일반 회원은 null/true만 허용하므로 인증 성인에게 false를 노출하지 않음. 관리자는 성인 조건만 생략함. [EventService.kt:56](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt:56)와 [EventRepository.kt:26](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt:26)에 일치. 신규 테스트의 KO/JA × 3상태 × 4종 viewer 비교 및 관리자 비노출 검증 확인. | +| EVT-003: 정확히 5개 필드·이미지·link | [EventDetailService.kt:45](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:45)는 [GetEventResponse.kt:13](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/GetEventResponse.kt:13)의 `id`, `thumbnailImageUrl`, `detailImageUrl`, `popupImageUrl`, `link`만 반환함. HTTPS 보존, 그 외 CDN 접두사는 기존 Service와 같고 nullable 이미지·link를 보존함. 외부 link에 HTTP 요청·리다이렉트하지 않으며 엔티티를 수정하지 않음. | +| EVT-004·005: 실패 envelope·보안 경계 | 양수 아님·미존재·비노출은 같은 `SodaException(common.error.invalid_request)`를 사용하며 [SodaExceptionHandler.kt:26](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt:26)의 HTTP 200 실패 envelope 유지. 비숫자·Long 초과는 기존 바인딩·공통 오류 처리 유지. [SecurityConfig.kt:191](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt:191)는 GET과 단일 segment `*`만 허용하며 쓰기·collection·추가 하위 경로·팝업·관리자 경로는 기존 인증 규칙 적용. JWT 필터를 생략하는 설정이 아니므로 기존 잘못된 JWT 처리 경로도 유지됨. | + +핵심 테스트: [EventDetailControllerIntegrationTest.kt:74](/Users/klaus/Develop/sodalive/Server/sodalive_/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt:74)의 응답 완전 비교·DB 재조회, `:165`의 성인 행렬, `:212`의 관리자 비노출, `:244`의 ID 오류, `:271`의 인접 보안 검증을 읽었다. RANDOM_PORT/TestRestTemplate은 실제 HTTP, 인증 MockMvc는 동일 컨텍스트와 실제 H2 member/auth/event fixture를 사용한다. 서비스·repository·인증 기록을 mock으로 대체하지 않으며 S3Uploader만 mock이다. + +## 3. 검토한 실행 증거 + +아래는 기존 실행 결과의 확인이며 이 리뷰에서 실행한 테스트가 아니다. + +| 출처 | 결과 및 확인 범위 | +|---|---| +| [신규 suite XML:2](/Users/klaus/Develop/sodalive/Server/sodalive_/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml:2) 직접 열람 | 2026-10-28T10:19:56, 28 tests, failures/errors/skipped 모두 0. testcase 목록과 실제 HTTP·MockMvc `system-out` 확인. | +| 같은 XML의 실제 응답 직접 열람 | `:10378` 익명 200·정확한 5필드·nullable 이미지·외부 link 보존. `:6484`, `:7407` 인증 일반 회원의 false 이벤트 거부. `:13745` 이후 관리자 비활성·미래·종료·언어 불일치 모두 동일 실패. `:10026`~`:10326`의 인접 보안 13건 모두 401. `:4568` 양수 아닌 ID도 HTTP 200, `success=false`, `data=null`, 기존 메시지 확인. | +| 기존 회귀 XML 직접 열람 | `EventControllerIntegrationTest` 5, `EventRepositoryTest` 5, `EventServiceTest` 4, `AdminEventBannerControllerIntegrationTest` 4건. 각 `build/test-results/test/TEST-kr.co.vividnext.sodalive.<클래스 전체 이름>.xml`의 testsuite/testcase를 확인했으며 합계 18건 모두 failures/errors/skipped 0. | +| 부모 세션 제공 전체 suite 증거 | `./gradlew test --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 19m 29s, 326 suites / 2584 tests / failures/errors/skips 0. 전체 집계는 부모 제공 증거이며 이 리뷰에서 재집계하지 않음. | +| 부모 세션 제공 빌드·린트 증거 | `bootJar` 성공, `ktlintCheck`는 CsvSource 줄바꿈 수정 후 성공. Kotlin production/test compile 성공을 대체 진단 증거로 사용하며 LSP 성공으로 표현하지 않음. | + +## 4. 발견 사항과 검증 한계 + +**확정 발견 사항 없음.** 승인된 EVT-001~005를 위반하는 구체 사례나 차단할 회귀를 발견하지 못했다. 회귀 수정 Task/goal 전환 항목 없음. + +- 정확히 같은 시각의 기간 경계는 고정 시계 실행 테스트가 아니라 기존 QueryDSL 포함 비교와 신규 거부 조건의 코드 대조로 확인했다. 계획에서 허용한 검증 방식이며 현재 비교 연산에 결함은 없다. +- 인증 viewer는 JWT 발급·전송을 포함한 별도 실제 HTTP가 아니라 인증 MockMvc와 실제 DB fixture로 검증됐다. 이는 승인된 S2 검증 표면이며 기존 JWT 필터 경로는 코드로 확인했다. +- `lsp_diagnostics`는 부모와 이 리뷰 환경에 도구가 없어 미실행이다. 이는 진단 도구의 제한이지 확인된 코드 결함이 아니다. 테스트·빌드·린트 성공은 위에 출처를 구분해 기록했으며 재실행하지 않았다. + +## 5. 리뷰 종료 판정과 정리 + +| 항목 | 결과 | +|---|---| +| 승인 요구사항·구현·보안·테스트 범위 확인 | 충족 | +| 발견 사항 판정 / plan 전환 | 확정 발견 사항 없음 / 해당 없음 | +| 실행 증거와 미검증 부분 구분 | 충족 | +| 최종 결론 | **APPROVE**. 이 구현의 반영을 차단할 항목 없음 | + +이 판정은 구현과 제공된 증거에 대한 승인이다. 부모 소유의 P1-GATE 체크박스·상단 상태나 부모 QA 자원의 최종 정리 상태를 대신 갱신·인증하지 않는다. + +**Teardown Receipt:** 이 리뷰에서 shell·테스트·빌드·서버·에이전트를 실행하지 않았고 소유 임시 root/프로세스를 생성하지 않았다. 정리 대상 0, 남은 리뷰 소유 자원 0. 공유 캐시 삭제 없음. 저장소 변경은 이 리뷰 파일뿐이다. From 5f6b8d4cf5f735738437737c41eb26c170662110 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:20:21 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat(subbanner):=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EB=8F=84=EB=A9=94=EC=9D=B8=EA=B3=BC=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EB=B6=88=EB=B3=80=EC=8B=9D=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/subbanner/domain/SubBanner.kt | 20 +++++++ .../v2/subbanner/domain/SubBannerPlacement.kt | 6 +++ .../v2/subbanner/domain/SubBannerTest.kt | 53 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt new file mode 100644 index 00000000..c60a081a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.v2.subbanner.domain + +import java.time.LocalDateTime + +data class SubBanner( + val id: Long? = null, + val placement: SubBannerPlacement, + val imagePath: String, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String?, + val startDate: LocalDateTime?, + val endDate: LocalDateTime? +) { + init { + require(!webUrl.isNullOrBlank() || !androidDeepLink.isNullOrBlank() || !iosDeepLink.isNullOrBlank()) { + "At least one link must be nonblank" + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt new file mode 100644 index 00000000..c562ea1f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.v2.subbanner.domain + +enum class SubBannerPlacement { + HOME_RECOMMENDATION, + CONTENT_RECOMMENDATION +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt new file mode 100644 index 00000000..e7905a8c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt @@ -0,0 +1,53 @@ +package kr.co.vividnext.sodalive.v2.subbanner.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class SubBannerTest { + @Test + @DisplayName("세 링크 중 하나 이상이 있는 7개 조합은 허용하고 모두 null이면 거부한다") + fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations() { + for (mask in 1..7) { + val webUrl = if (mask and 1 != 0) "https://example.com/banner?next=%2Fhome" else null + val androidDeepLink = if (mask and 2 != 0) "sodalive://content/123?from=banner" else null + val iosDeepLink = if (mask and 4 != 0) "sodalive-ios://content/123#detail" else null + + val banner = banner(webUrl, androidDeepLink, iosDeepLink) + + assertEquals(webUrl, banner.webUrl) + assertEquals(androidDeepLink, banner.androidDeepLink) + assertEquals(iosDeepLink, banner.iosDeepLink) + } + + assertThrows(IllegalArgumentException::class.java) { banner(null, null, null) } + } + + @Test + @DisplayName("null과 빈 문자열 및 공백만으로 된 모든 조합을 거부한다") + fun shouldRejectAllBlankLinksAndAllowAnyNonblankLink() { + val emptyLinks = listOf(null, "", " ", "\t\n") + for (webUrl in emptyLinks) { + for (androidDeepLink in emptyLinks) { + for (iosDeepLink in emptyLinks) { + assertThrows(IllegalArgumentException::class.java) { banner(webUrl, androidDeepLink, iosDeepLink) } + } + } + } + + assertEquals("https://example.com", banner("https://example.com", " ", "").webUrl) + assertEquals("sodalive://home", banner(" ", "sodalive://home", "").androidDeepLink) + assertEquals("sodalive-ios://home", banner("", " ", "sodalive-ios://home").iosDeepLink) + } + + private fun banner(webUrl: String?, androidDeepLink: String?, iosDeepLink: String?): SubBanner = SubBanner( + placement = SubBannerPlacement.HOME_RECOMMENDATION, + imagePath = "sub-banner/image.png", + webUrl = webUrl, + androidDeepLink = androidDeepLink, + iosDeepLink = iosDeepLink, + startDate = null, + endDate = null + ) +} From aa6863f4f98ef9a0720e0f1a82983afb25c2db11 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:20:38 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat(subbanner):=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=86=8C=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-sub-banner.sql | 15 + .../persistence/DefaultSubBannerRepository.kt | 73 ++++ .../out/persistence/SubBannerEntity.kt | 45 ++ .../subbanner/port/out/SubBannerRepository.kt | 13 + .../persistence/SubBannerPersistenceTest.kt | 400 ++++++++++++++++++ 5 files changed, 546 insertions(+) create mode 100644 docs/20261029_가로형서브배너/create-sub-banner.sql create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt diff --git a/docs/20261029_가로형서브배너/create-sub-banner.sql b/docs/20261029_가로형서브배너/create-sub-banner.sql new file mode 100644 index 00000000..6b2247dd --- /dev/null +++ b/docs/20261029_가로형서브배너/create-sub-banner.sql @@ -0,0 +1,15 @@ +CREATE TABLE sub_banner ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '서브 배너 ID', + placement VARCHAR(32) NOT NULL COMMENT '노출 위치: HOME_RECOMMENDATION, CONTENT_RECOMMENDATION', + image_path LONGTEXT NOT NULL COMMENT '배너 이미지 S3 object key', + web_url LONGTEXT NULL COMMENT '웹 이동 URL', + android_deep_link LONGTEXT NULL COMMENT 'Android 이동 딥링크', + ios_deep_link LONGTEXT NULL COMMENT 'iOS 이동 딥링크', + start_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 시작 시각 (UTC 초 단위, NULL이면 시작 제한 없음)', + end_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 종료 시각 (UTC 초 단위, NULL이면 종료 제한 없음)', + is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부 (false이면 삭제)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + INDEX idx_sub_banner_placement_id (placement, id) +) COMMENT = '가로형 서브 배너'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt new file mode 100644 index 00000000..75dc42f6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt @@ -0,0 +1,73 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.QSubBannerEntity.subBannerEntity +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import javax.persistence.EntityManager + +@Repository +class DefaultSubBannerRepository( + private val entityManager: EntityManager, + private val queryFactory: JPAQueryFactory +) : SubBannerRepository { + override fun findById(id: Long): SubBanner? = entityManager.find(SubBannerEntity::class.java, id) + ?.takeIf { it.isActive }?.toDomain() + + override fun findAllByPlacement(placement: SubBannerPlacement): List = queryFactory + .selectFrom(subBannerEntity) + .where(subBannerEntity.placement.eq(placement), subBannerEntity.isActive.isTrue) + .orderBy(subBannerEntity.id.asc()) + .fetch() + .map { it.toDomain() } + + override fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List { + val visibleAt = now.truncatedTo(ChronoUnit.SECONDS) + return queryFactory + .selectFrom(subBannerEntity) + .where( + subBannerEntity.placement.eq(placement), + subBannerEntity.isActive.isTrue, + subBannerEntity.startDate.isNull.or(subBannerEntity.startDate.loe(visibleAt)), + subBannerEntity.endDate.isNull.or(subBannerEntity.endDate.goe(visibleAt)) + ) + .orderBy(subBannerEntity.id.asc()) + .fetch() + .map { it.toDomain() } + } + + override fun save(subBanner: SubBanner): SubBanner { + val startDate = subBanner.startDate?.truncatedTo(ChronoUnit.SECONDS) + val endDate = subBanner.endDate?.truncatedTo(ChronoUnit.SECONDS) + val entity = if (subBanner.id == null) { + SubBannerEntity( + subBanner.placement, + subBanner.imagePath, + subBanner.webUrl, + subBanner.androidDeepLink, + subBanner.iosDeepLink, + startDate, + endDate + ).also { entityManager.persist(it) } + } else { + entityManager.find(SubBannerEntity::class.java, subBanner.id).apply { + placement = subBanner.placement + imagePath = subBanner.imagePath + webUrl = subBanner.webUrl + androidDeepLink = subBanner.androidDeepLink + iosDeepLink = subBanner.iosDeepLink + this.startDate = startDate + this.endDate = endDate + } + } + return entity.toDomain() + } + + override fun deleteById(id: Long) { + entityManager.find(SubBannerEntity::class.java, id).isActive = false + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt new file mode 100644 index 00000000..fbb32014 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import org.hibernate.annotations.DynamicUpdate +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.Index +import javax.persistence.Table + +@Entity +@DynamicUpdate +@Table(name = "sub_banner", indexes = [Index(name = "idx_sub_banner_placement_id", columnList = "placement, id")]) +class SubBannerEntity( + @Enumerated(EnumType.STRING) + @Column(name = "placement", nullable = false, length = 32) + var placement: SubBannerPlacement, + + @Column(name = "image_path", nullable = false, columnDefinition = "LONGTEXT") + var imagePath: String, + + @Column(name = "web_url", columnDefinition = "LONGTEXT") + var webUrl: String?, + + @Column(name = "android_deep_link", columnDefinition = "LONGTEXT") + var androidDeepLink: String?, + + @Column(name = "ios_deep_link", columnDefinition = "LONGTEXT") + var iosDeepLink: String?, + + @Column(name = "start_date", columnDefinition = "TIMESTAMP") + var startDate: LocalDateTime?, + + @Column(name = "end_date", columnDefinition = "TIMESTAMP") + var endDate: LocalDateTime?, + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true +) : BaseEntity() { + fun toDomain(): SubBanner = SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt new file mode 100644 index 00000000..7325b1ac --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.subbanner.port.out + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import java.time.LocalDateTime + +interface SubBannerRepository { + fun findById(id: Long): SubBanner? + fun findAllByPlacement(placement: SubBannerPlacement): List + fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List + fun save(subBanner: SubBanner): SubBanner + fun deleteById(id: Long) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt new file mode 100644 index 00000000..d351194e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt @@ -0,0 +1,400 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.nio.file.Files +import java.nio.file.Path +import java.sql.DriverManager +import java.sql.SQLException +import java.time.LocalDateTime +import java.util.stream.Stream +import javax.persistence.EntityManager +import javax.persistence.EntityManagerFactory + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig::class, DefaultSubBannerRepository::class) +class SubBannerPersistenceTest @Autowired constructor( + private val entityManager: EntityManager, + private val repository: SubBannerRepository, + private val entityManagerFactory: EntityManagerFactory, + private val jdbcTemplate: JdbcTemplate +) { + @BeforeEach + fun setSecondPrecisionColumns() { + jdbcTemplate.execute("alter table sub_banner alter column start_date timestamp(0)") + jdbcTemplate.execute("alter table sub_banner alter column end_date timestamp(0)") + } + + @ParameterizedTest + @ValueSource(ints = [0, 123456789, 999999999]) + @DisplayName("nullable UTC 기간은 저장 결과와 DB 재조회 모두 초 정밀도를 정확히 보존한다") + fun shouldRoundTripNullableDatesAtSecondPrecision(nano: Int) { + val start = LocalDateTime.of(2026, 10, 29, 23, 59, 0) + val end = LocalDateTime.of(2026, 10, 29, 23, 59, 59) + val midnight = LocalDateTime.of(2026, 10, 30, 0, 0, 0) + for ((startDate, endDate) in listOf(null to null, start to null, null to end, start to end, midnight to midnight)) { + val input = banner().copy(startDate = startDate?.withNano(nano), endDate = endDate?.withNano(nano)) + val saved = repository.save(input) + flushAndClear() + + val stored = entityManager.find(SubBannerEntity::class.java, saved.id) + assertNotNull(stored) + assertEquals(startDate, stored.startDate) + assertEquals(endDate, stored.endDate) + val expected = input.copy(id = saved.id, startDate = startDate, endDate = endDate) + assertEquals(expected, repository.findById(requireNotNull(saved.id))) + assertEquals(expected, saved) + } + } + + @Test + @DisplayName("이미지 key와 긴 원문 URI 및 7개 링크 조합을 DB 왕복에서도 자르거나 변형하지 않는다") + fun shouldRoundTripLongImageKeysAndSevenLinkCombinations() { + val suffix = "a".repeat(300) + for (mask in 1..7) { + val input = banner().copy( + imagePath = "sub-banner/$suffix.png", + webUrl = if (mask and 1 != 0) "https://example.com/$suffix?next=%2Fhome#detail" else null, + androidDeepLink = if (mask and 2 != 0) "sodalive://content/$suffix?next=%2Fhome" else null, + iosDeepLink = if (mask and 4 != 0) "sodalive-ios://content/$suffix#detail" else null + ) + val saved = repository.save(input) + flushAndClear() + + assertNotNull(saved.id) + assertEquals(input.copy(id = saved.id), repository.findById(requireNotNull(saved.id))) + assertEquals( + "HOME_RECOMMENDATION", + entityManager.createNativeQuery("select placement from sub_banner where id = :id") + .setParameter("id", saved.id).singleResult + ) + } + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("관리자 목록은 다른 placement를 제외하고 모든 기간의 20개 초과 행을 id 오름차순으로 반환한다") + fun shouldReturnAllPlacementRowsInIdOrderWithoutLimit(placement: SubBannerPlacement) { + val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59) + assertEquals(emptyList(), repository.findAllByPlacement(placement)) + assertEquals(emptyList(), repository.findVisibleByPlacement(placement, now)) + val otherPlacement = SubBannerPlacement.values().first { it != placement } + val saved = (1..25).map { index -> + repository.save(banner(otherPlacement)) + repository.save(banner(placement).copy(imagePath = "sub-banner/$index.png")) + } + val future = repository.save(banner(placement).copy(startDate = now.plusSeconds(1))) + val expired = repository.save(banner(placement).copy(endDate = now.minusSeconds(1))) + flushAndClear() + + assertEquals(saved + future + expired, repository.findAllByPlacement(placement)) + assertEquals(saved, repository.findVisibleByPlacement(placement, now)) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("visibilityCases") + @DisplayName("공개 목록은 nullable 기간의 양끝과 소수초 now 및 날짜 경계를 초 단위로 판정한다") + fun shouldApplyInclusiveNullablePeriodAtSecondPrecision( + name: String, + now: LocalDateTime, + startDate: LocalDateTime?, + endDate: LocalDateTime?, + visible: Boolean + ) { + val candidate = repository.save(banner().copy(startDate = startDate, endDate = endDate)) + repository.save(banner(SubBannerPlacement.CONTENT_RECOMMENDATION)) + flushAndClear() + + assertEquals( + if (visible) listOf(candidate) else emptyList(), + repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, now), + name + ) + } + + @Test + @DisplayName("수정은 같은 id와 생성 시각을 보존하며 모든 필드 교체와 nullable 기간 해제를 저장한다") + fun shouldUpdateSameRowAndClearNullableFields() { + val saved = repository.save(banner().copy(startDate = LocalDateTime.of(2026, 10, 29, 23, 59, 0))) + flushAndClear() + val id = requireNotNull(saved.id) + val createdAt = entityManager.find(SubBannerEntity::class.java, id).createdAt + assertNotNull(createdAt) + val replacement = saved.copy( + placement = SubBannerPlacement.CONTENT_RECOMMENDATION, + imagePath = "sub-banner/replacement.png", + webUrl = null, + androidDeepLink = "sodalive://replacement", + iosDeepLink = "sodalive-ios://replacement", + startDate = null, + endDate = LocalDateTime.of(2026, 10, 30, 0, 0, 59, 999999999) + ) + val updated = repository.save(replacement) + flushAndClear() + + val expected = replacement.copy(endDate = LocalDateTime.of(2026, 10, 30, 0, 0, 59)) + assertEquals(expected, updated) + assertEquals(expected, repository.findById(id)) + assertEquals(emptyList(), repository.findAllByPlacement(SubBannerPlacement.HOME_RECOMMENDATION)) + assertEquals(listOf(expected), repository.findAllByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION)) + val stored = entityManager.find(SubBannerEntity::class.java, id) + assertEquals(createdAt, stored.createdAt) + assertNotNull(stored.updatedAt) + + val cleared = expected.copy( + webUrl = "https://example.com/replacement", + androidDeepLink = null, + iosDeepLink = null, + endDate = null + ) + assertEquals(cleared, repository.save(cleared)) + flushAndClear() + assertEquals(cleared, repository.findById(id)) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("소프트 삭제는 모든 기간의 행과 생성 정보를 보존하고 모든 조회에서 제외한다") + fun shouldRetainSoftDeletedRowsAndExcludeThemFromEveryRead(placement: SubBannerPlacement) { + assertNull(repository.findById(Long.MAX_VALUE)) + val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59) + val periods = listOf(null to null, now.minusMinutes(1) to now, now.plusSeconds(1) to null, null to now.minusSeconds(1)) + val deleted = periods.map { (start, end) -> + repository.save( + banner(placement).copy( + androidDeepLink = "sodalive://content/123?from=banner", + iosDeepLink = "sodalive-ios://content/123#detail", + startDate = start, + endDate = end + ) + ) + } + val active = periods.map { (start, end) -> repository.save(banner(placement).copy(startDate = start, endDate = end)) } + val other = repository.save(banner(SubBannerPlacement.values().first { it != placement })) + flushAndClear() + val createdAt = deleted.associate { it.id to entityManager.find(SubBannerEntity::class.java, it.id).createdAt } + + deleted.forEach { repository.deleteById(requireNotNull(it.id)) } + flushAndClear() + + deleted.forEach { original -> + val stored = entityManager.find(SubBannerEntity::class.java, original.id) + assertNotNull(stored, "Soft delete must retain the original row") + assertEquals(original, stored.toDomain()) + assertEquals(createdAt[original.id], stored.createdAt) + assertNotNull(stored.updatedAt) + assertEquals( + false, + entityManager.createNativeQuery("select is_active from sub_banner where id = :id") + .setParameter("id", original.id).singleResult + ) + assertNull(repository.findById(requireNotNull(original.id))) + } + active.forEach { assertEquals(it, repository.findById(requireNotNull(it.id))) } + assertEquals(active, repository.findAllByPlacement(placement)) + assertEquals(active.take(2), repository.findVisibleByPlacement(placement, now)) + assertEquals(other, repository.findById(requireNotNull(other.id))) + assertEquals(listOf(other), repository.findAllByPlacement(other.placement)) + assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, now)) + } + + @Test + @DisplayName("삭제 전 도메인 스냅샷 저장도 비활성 행을 다시 활성화하지 않는다") + fun shouldNotReactivateDeletedRowWhenSavingStaleDomain() { + val saved = repository.save(banner()) + flushAndClear() + val id = requireNotNull(saved.id) + repository.deleteById(id) + flushAndClear() + assertNotNull(entityManager.find(SubBannerEntity::class.java, id), "Soft delete must retain the original row") + + repository.save(saved.copy(webUrl = "https://example.com/changed")) + flushAndClear() + + assertEquals( + false, + entityManager.createNativeQuery("select is_active from sub_banner where id = :id") + .setParameter("id", id).singleResult + ) + assertNull(repository.findById(id)) + assertEquals(emptyList(), repository.findAllByPlacement(saved.placement)) + assertEquals(emptyList(), repository.findVisibleByPlacement(saved.placement, LocalDateTime.now())) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("삭제 전 활성 managed entity를 유지한 수정 트랜잭션도 먼저 커밋된 소프트 삭제를 되돌리지 않는다") + fun shouldNotReactivateDeletedRowWhenStaleManagedEntityCommits() { + val now = LocalDateTime.of(2026, 10, 29, 23, 59, 0) + val updateContext = entityManagerFactory.createEntityManager() + val deleteContext = entityManagerFactory.createEntityManager() + val readContext = entityManagerFactory.createEntityManager() + var seededId: Long? = null + try { + val deleteRepository = DefaultSubBannerRepository(deleteContext, JPAQueryFactory(deleteContext)) + deleteContext.transaction.begin() + val saved = deleteRepository.save(banner().copy(startDate = now, endDate = now.plusSeconds(59))) + val id = requireNotNull(saved.id) + seededId = id + deleteContext.transaction.commit() + + updateContext.transaction.begin() + val updateRepository = DefaultSubBannerRepository(updateContext, JPAQueryFactory(updateContext)) + val existing = requireNotNull(updateRepository.findById(id)) + val staleEntity = updateContext.find(SubBannerEntity::class.java, id) + val createdAt = staleEntity.createdAt + assertTrue(staleEntity.isActive) + + deleteContext.transaction.begin() + deleteRepository.deleteById(id) + println("CONCURRENCY EVIDENCE delete commit") + deleteContext.transaction.commit() + + assertTrue(staleEntity.isActive, "The update context must retain its stale active entity") + assertSame(staleEntity, updateContext.find(SubBannerEntity::class.java, id)) + val replacement = existing.copy(webUrl = "https://example.com/concurrent-update") + assertEquals(replacement, updateRepository.save(replacement)) + println("CONCURRENCY EVIDENCE stale payload commit") + updateContext.transaction.commit() + + readContext.transaction.begin() + val stored = readContext.find(SubBannerEntity::class.java, id) + assertNotNull(stored, "Concurrent update must preserve the soft-deleted row") + val active = readContext.createNativeQuery("select is_active from sub_banner where id = :id") + .setParameter("id", id).singleResult + println( + "CONCURRENCY DB EVIDENCE id=$id is_active=$active " + + "payload=${stored.toDomain()} created_at=${stored.createdAt}" + ) + assertEquals(false, active, "Stale managed payload update must not reactivate a committed soft delete") + assertEquals(replacement, stored.toDomain()) + assertEquals(createdAt, stored.createdAt) + val readRepository = DefaultSubBannerRepository(readContext, JPAQueryFactory(readContext)) + assertNull(readRepository.findById(id)) + assertEquals(emptyList(), readRepository.findAllByPlacement(saved.placement)) + assertEquals(emptyList(), readRepository.findVisibleByPlacement(saved.placement, now)) + readContext.transaction.commit() + } finally { + listOf(updateContext, deleteContext, readContext).forEach { context -> + if (context.transaction.isActive) context.transaction.rollback() + context.close() + } + seededId?.let { id -> + val removed = jdbcTemplate.update("delete from sub_banner where id = ?", id) + println("CONCURRENCY EVIDENCE fixture cleanup rows=$removed") + } + } + } + + @Test + @DisplayName("ORM 생성 기본값은 true이며 활성 컬럼은 null을 허용하지 않는다") + fun shouldCreateActiveRowsWithNonNullableFlag() { + val saved = repository.save(banner()) + flushAndClear() + + val columns = entityManager.createNativeQuery( + "select count(*) from INFORMATION_SCHEMA.COLUMNS " + + "where TABLE_NAME = 'sub_banner' and COLUMN_NAME = 'is_active' and IS_NULLABLE = 'NO'" + ).singleResult as Number + assertEquals(1, columns.toInt(), "ORM schema must contain a non-nullable is_active column") + assertEquals( + true, + entityManager.createNativeQuery("select is_active from sub_banner where id = :id") + .setParameter("id", saved.id).singleResult + ) + } + + @Test + @DisplayName("작성한 CREATE DDL은 실제 실행 시 활성 기본값 true와 NOT NULL을 보장한다") + fun shouldApplyActiveDefaultAndNotNullFromAuthoredCreateDdl() { + DriverManager.getConnection( + "jdbc:h2:mem:sub-banner-soft-delete-ddl;MODE=MySQL;DATABASE_TO_UPPER=false" + ).use { connection -> + connection.createStatement().use { statement -> + statement.execute(Files.readString(Path.of("docs/20261029_가로형서브배너/create-sub-banner.sql"))) + connection.metaData.getColumns(null, null, "sub_banner", "is_active").use { columns -> + assertTrue(columns.next(), "CREATE DDL must contain is_active") + assertEquals("NO", columns.getString("IS_NULLABLE")) + } + statement.executeUpdate( + "insert into sub_banner (placement, image_path, web_url) " + + "values ('HOME_RECOMMENDATION', 'sub-banner/image.png', 'https://example.com/banner')" + ) + statement.executeQuery("select is_active from sub_banner").use { rows -> + assertTrue(rows.next()) + assertTrue(rows.getBoolean("is_active")) + } + assertThrows(SQLException::class.java) { + statement.executeUpdate("update sub_banner set is_active = null") + } + } + } + } + + private fun banner(placement: SubBannerPlacement = SubBannerPlacement.HOME_RECOMMENDATION): SubBanner = SubBanner( + placement = placement, + imagePath = "sub-banner/image.png", + webUrl = "https://example.com/banner", + androidDeepLink = null, + iosDeepLink = null, + startDate = null, + endDate = null + ) + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } + + companion object { + @JvmStatic + fun visibilityCases(): Stream { + val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59) + val before = LocalDateTime.of(2026, 10, 29, 23, 59, 58) + val midnight = LocalDateTime.of(2026, 10, 30, 0, 0, 0) + return Stream.of( + Arguments.of("unbounded", now, null, null, true), + Arguments.of("start-before", now, before, null, true), + Arguments.of("start-exact", now, now, null, true), + Arguments.of("start-after", now, midnight, null, false), + Arguments.of("end-before", now, null, before, false), + Arguments.of("end-exact", now, null, now, true), + Arguments.of("end-after", now, null, midnight, true), + Arguments.of("inside", now, before, midnight, true), + Arguments.of("before-range", now, midnight, midnight.plusSeconds(1), false), + Arguments.of("after-range", now, before.minusSeconds(1), before, false), + Arguments.of("both-exact", now, now, now, true), + Arguments.of("end-second-123456789", now.withNano(123456789), before, now, true), + Arguments.of("end-second-999999999", now.withNano(999999999), before, now, true), + Arguments.of("next-second-not-started", now.withNano(999999999), midnight, null, false), + Arguments.of("midnight-start-exact", midnight, midnight, null, true), + Arguments.of("midnight-expired", midnight, null, now, false) + ) + } + } +} From a30196e0c94bd298a1a4be41b27365d3beb00732 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:22:04 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(subbanner):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20CRUD=EC=99=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=C2=B7?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/AdminSubBannerController.kt | 59 ++ .../subbanner/application/SubBannerService.kt | 191 ++++++ .../subbanner/dto/AdminSubBannerResponse.kt | 36 ++ .../v2/subbanner/dto/SubBannerRequest.kt | 12 + ...AdminSubBannerControllerIntegrationTest.kt | 596 ++++++++++++++++++ 5 files changed, 894 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt new file mode 100644 index 00000000..972be30b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt @@ -0,0 +1,59 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.AdminSubBannerResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +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.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/admin/sub-banners") +@PreAuthorize("hasRole('ADMIN')") +class AdminSubBannerController( + private val service: SubBannerService, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String +) { + @GetMapping + fun list(@RequestParam(required = false) placement: String?): ApiResponse> { + val selected = SubBannerPlacement.values().firstOrNull { it.name == placement } + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner placement") + return ApiResponse.ok(service.findAllByPlacement(selected).map { AdminSubBannerResponse.from(it, cloudFrontHost) }) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: String + ): ApiResponse = ApiResponse.ok( + AdminSubBannerResponse.from(service.create(image, request), cloudFrontHost) + ) + + @PutMapping("/{id}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun update( + @PathVariable id: Long, + @RequestPart("image", required = false) image: MultipartFile?, + @RequestPart("request") request: String + ): ApiResponse = ApiResponse.ok( + AdminSubBannerResponse.from(service.update(id, image, request), cloudFrontHost) + ) + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ApiResponse { + service.delete(id) + return ApiResponse.ok() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt new file mode 100644 index 00000000..7d77e617 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt @@ -0,0 +1,191 @@ +package kr.co.vividnext.sodalive.v2.subbanner.application + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.utils.validateImage +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerRequest +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import java.net.URI +import java.net.URISyntaxException +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.format.ResolverStyle +import java.time.temporal.ChronoUnit + +@Service +class SubBannerService( + private val repository: SubBannerRepository, + private val s3Uploader: S3Uploader, + private val objectMapper: ObjectMapper, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } +) { + @Transactional(readOnly = true) + fun findAllByPlacement(placement: SubBannerPlacement): List = repository.findAllByPlacement(placement) + + @Transactional(readOnly = true) + fun findVisibleByPlacement(placement: SubBannerPlacement): List { + val now = nowProvider().truncatedTo(ChronoUnit.SECONDS) + return repository.findVisibleByPlacement(placement, now) + } + + @Transactional + fun create(image: MultipartFile, request: String): SubBanner { + val fields = readFields(request) + val input = readRequest(fields) + val startDate = parseDate(input.startDate, 0) + val endDate = parseDate(input.endDate, 59) + validateRange(startDate, endDate) + val imagePath = uploadImage(image) + return repository.save( + SubBanner( + placement = input.placement, + imagePath = imagePath, + webUrl = input.webUrl, + androidDeepLink = input.androidDeepLink, + iosDeepLink = input.iosDeepLink, + startDate = startDate, + endDate = endDate + ) + ) + } + + @Transactional + fun update(id: Long, image: MultipartFile?, request: String): SubBanner { + val existing = findExisting(id) + val fields = readFields(request) + val input = readRequest(fields) + val startDate = if (fields.has("startDate")) parseDate(input.startDate, 0) else existing.startDate + val endDate = if (fields.has("endDate")) parseDate(input.endDate, 59) else existing.endDate + validateRange(startDate, endDate) + val imagePath = image?.let { uploadImage(it) } ?: existing.imagePath + return repository.save( + existing.copy( + placement = input.placement, + imagePath = imagePath, + webUrl = input.webUrl, + androidDeepLink = input.androidDeepLink, + iosDeepLink = input.iosDeepLink, + startDate = startDate, + endDate = endDate + ) + ) + } + + @Transactional + fun delete(id: Long) { + findExisting(id) + repository.deleteById(id) + } + + private fun findExisting(id: Long): SubBanner = repository.findById(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Sub-banner not found") + + private fun readFields(request: String): JsonNode { + val fields = try { + objectMapper.readerFor(JsonNode::class.java) + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readValue(request) + } catch (_: JsonProcessingException) { + invalidRequest() + } + if (!fields.isObject || !fields.path("placement").isTextual || + SubBannerPlacement.values().none { it.name == fields["placement"].textValue() } + ) { + invalidRequest() + } + listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field -> + val value = fields[field] + if (value == null || (!value.isNull && !value.isTextual)) invalidRequest() + } + listOf("startDate", "endDate").forEach { field -> + val value = fields[field] + if (value != null && !value.isNull && !value.isTextual) invalidRequest() + } + return fields + } + + private fun readRequest(fields: JsonNode): SubBannerRequest { + val input = objectMapper.treeToValue(fields, SubBannerRequest::class.java) + val normalized = input.copy( + webUrl = input.webUrl?.trim()?.takeIf { it.isNotEmpty() }, + androidDeepLink = input.androidDeepLink?.trim()?.takeIf { it.isNotEmpty() }, + iosDeepLink = input.iosDeepLink?.trim()?.takeIf { it.isNotEmpty() } + ) + if (listOf(normalized.webUrl, normalized.androidDeepLink, normalized.iosDeepLink).all { it == null }) invalidRequest() + validateUri(normalized.webUrl, web = true) + validateUri(normalized.androidDeepLink, web = false) + validateUri(normalized.iosDeepLink, web = false) + return normalized + } + + private fun validateUri(value: String?, web: Boolean) { + if (value == null) return + val uri = try { + URI(value) + } catch (_: URISyntaxException) { + invalidRequest() + } + if (!uri.isAbsolute || listOf("javascript", "data", "file", "content").any { it.equals(uri.scheme, ignoreCase = true) }) { + invalidRequest() + } + if (web && ((!uri.scheme.equals("http", true) && !uri.scheme.equals("https", true)) || uri.host.isNullOrBlank())) { + invalidRequest() + } + } + + private fun parseDate(value: String?, second: Int): LocalDateTime? { + if (value == null) return null + if (value.length != 16) invalidRequest() + val utc = try { + val local = LocalDateTime.parse(value, DATE_FORMAT).withSecond(second) + val zone = ZoneId.of("Asia/Seoul") + if (zone.rules.getValidOffsets(local).isEmpty()) invalidRequest() + local.atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + } catch (_: DateTimeParseException) { + invalidRequest() + } + if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP) invalidRequest() + return utc + } + + private fun validateRange(startDate: LocalDateTime?, endDate: LocalDateTime?) { + if (startDate != null && endDate != null && startDate > endDate) invalidRequest() + } + + private fun uploadImage(image: MultipartFile): String { + if (image.isEmpty) invalidRequest() + try { + validateImage(image, gifAllowed = true) + } catch (_: SodaException) { + invalidRequest() + } + return image.inputStream.use { s3Uploader.upload(it, bucket, "sub_banner/${generateFileName()}") } + } + + private fun invalidRequest(): Nothing = throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner request") + + companion object { + private val DATE_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT) + private val MIN_TIMESTAMP = LocalDateTime.of(1970, 1, 1, 0, 0, 1) + private val MAX_TIMESTAMP = LocalDateTime.of(2038, 1, 19, 3, 14, 7) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt new file mode 100644 index 00000000..4febc4b5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.v2.subbanner.dto + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +data class AdminSubBannerResponse( + val id: Long, + val placement: SubBannerPlacement, + val imageUrl: String, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String?, + val startDate: String?, + val endDate: String? +) { + companion object { + fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse( + id = requireNotNull(banner.id), + placement = banner.placement, + imageUrl = "$cloudFrontHost/${banner.imagePath}", + webUrl = banner.webUrl, + androidDeepLink = banner.androidDeepLink, + iosDeepLink = banner.iosDeepLink, + startDate = formatDate(banner.startDate), + endDate = formatDate(banner.endDate) + ) + + private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC) + ?.withZoneSameInstant(ZoneId.of("Asia/Seoul")) + ?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt new file mode 100644 index 00000000..a87ad083 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.v2.subbanner.dto + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement + +data class SubBannerRequest( + val placement: SubBannerPlacement, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String?, + val startDate: String? = null, + val endDate: String? = null +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt new file mode 100644 index 00000000..0ab3142e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt @@ -0,0 +1,596 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.util.LinkedMultiValueMap +import java.io.InputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Base64 +import java.util.UUID +import javax.persistence.EntityManager + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "cloud.aws.s3.bucket=test-bucket", + "spring.datasource.url=jdbc:h2:mem:admin-sub-banner-http;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AdminSubBannerControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val repository: SubBannerRepository, + private val jdbcTemplate: JdbcTemplate, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository +) { + @LocalServerPort + private var port: Int = 0 + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private lateinit var adminToken: String + private val memberIds = mutableListOf() + + @BeforeEach + fun setUp() { + jdbcTemplate.update("delete from sub_banner") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)") + adminToken = tokenFor(MemberRole.ADMIN) + Mockito.`when`( + s3Uploader.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull()) + ).thenAnswer { invocation -> + assertEquals("test-bucket", invocation.getArgument(1)) + assertTrue(invocation.getArgument(2).matches(Regex("sub_banner/[A-Za-z0-9_=-]+"))) + assertEquals(PNG.toList(), invocation.getArgument(0).readBytes().toList()) + invocation.getArgument(2) + } + } + + @AfterEach + fun cleanUp() { + jdbcTemplate.update("delete from sub_banner") + tokenRepository.deleteAll() + memberRepository.deleteAllById(memberIds) + memberIds.clear() + } + + @Test + @DisplayName("실제 JWT HTTP CRUD는 관리자 8필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다") + fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() { + val createdResponse = send(HttpMethod.POST) + evidence("POST", createdResponse) + val created = success(createdResponse) + assertAdminFields(created) + val id = created["id"].asLong() + val stored = reload(id) + assertEquals("https://cdn.test/${stored.imagePath}", created["imageUrl"].asText()) + assertEquals(WEB, stored.webUrl) + assertNull(stored.startDate) + assertNull(stored.endDate) + assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + + val updatedResponse = send( + HttpMethod.PUT, + "/$id", + baseFields() + mapOf("placement" to "CONTENT_RECOMMENDATION", "webUrl" to null, "iosDeepLink" to "custom:promo") + + dates("2026-10-28 00:00", "2026-10-29 23:59") + ) + evidence("PUT", updatedResponse) + val updated = success(updatedResponse) + assertAdminFields(updated) + assertEquals(id, updated["id"].asLong()) + assertTrue(updated["webUrl"].isNull) + assertEquals("custom:promo", updated["iosDeepLink"].asText()) + assertNotEquals(stored.imagePath, reload(id).imagePath) + assertEquals(emptyList(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + assertEquals(listOf(updated), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList()) + + val beforeDelete = storedRows().single() + Mockito.clearInvocations(s3Uploader) + val deleted = send(HttpMethod.DELETE, "/$id") + evidence("DELETE", deleted) + assertTrue(success(deleted, allowNullData = true).isNull) + assertNull(transactionTemplate.execute { repository.findById(id) }) + assertEquals(1L, rowCount(), "Soft delete must retain the original row") + val retained = storedRows().single() + assertEquals(true, beforeDelete["is_active"]) + assertEquals(false, retained["is_active"]) + assertEquals(beforeDelete - setOf("is_active", "updated_at"), retained - setOf("is_active", "updated_at")) + assertNotEquals(beforeDelete["updated_at"], retained["updated_at"]) + println("DB EVIDENCE soft-delete before=$beforeDelete after=$retained") + assertEquals(emptyList(), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList()) + Mockito.verifyNoInteractions(s3Uploader) + listOf( + send(HttpMethod.DELETE, "/$id"), + send(HttpMethod.PUT, "/$id", baseFields() + ("isActive" to true)), + send(HttpMethod.PUT, "/$id", rawRequest = "{") + ).forEach { response -> + evidence("inactive mutation", response) + assertStatus(response, HttpStatus.NOT_FOUND) + assertEquals(listOf(retained), storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7]) + @DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다") + fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations(mask: Int) { + val fields = baseFields() + mapOf( + "webUrl" to if (mask and 1 != 0) " $WEB " else " \t ", + "androidDeepLink" to if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null, + "iosDeepLink" to if (mask and 4 != 0) "other-app:promo" else "" + ) + if (mask == 0) { + assertRejectedWithoutMutation(fields) + return + } + val created = success(send(HttpMethod.POST, fields = fields)) + val id = created["id"].asLong() + val stored = reload(id) + assertEquals(if (mask and 1 != 0) WEB else null, stored.webUrl) + assertEquals(if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null, stored.androidDeepLink) + assertEquals(if (mask and 4 != 0) "other-app:promo" else null, stored.iosDeepLink) + assertEquals(created, success(send(HttpMethod.PUT, "/$id", fields, image = null))) + } + + @Test + @DisplayName("raw JSON의 필수 기본 필드·실제 타입·객체·정확한 placement를 업로드 전에 검증한다") + fun shouldRejectMissingBaseFieldsAndNonStringLinksBeforeUpload() { + baseFields().keys.forEach { missing -> assertRejectedWithoutMutation(baseFields() - missing) } + listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field -> + listOf(1, true, emptyList(), emptyMap()).forEach { value -> + assertRejectedWithoutMutation(baseFields() + (field to value)) + } + } + listOf(null, 0, true, emptyList(), "", "home_recommendation", " HOME_RECOMMENDATION", "OTHER").forEach { + assertRejectedWithoutMutation(baseFields() + ("placement" to it)) + } + listOf("{", "[]", "null", "42", "\"string\"", "{}", "{} {}").forEach { raw -> + assertRejectedWithoutMutation(rawRequest = raw) + } + assertRejectedWithoutMutation(baseFields() + mapOf("webUrl" to null, "androidDeepLink" to null, "iosDeepLink" to null)) + } + + @Test + @DisplayName("위험 scheme·상대 URI·실제 host 없는 웹 주소와 문법 오류를 생성·수정 전에 거부한다") + fun shouldRejectUnsafeOrMalformedUrisBeforeUploadOrPersistence() { + evidence("invalid URI", send(HttpMethod.POST, fields = baseFields() + ("webUrl" to "javascript:alert(1)"))) + val unsafe = listOf("javascript:alert(1)", "JaVaScRiPt:alert(1)", "DATA:text/plain,x", "file:///tmp/a", "CoNtEnT://a") + listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field -> + (unsafe + listOf("relative/path", "//example.test/path", "app://bad path", "app://bad%zz")).forEach { value -> + assertRejectedWithoutMutation(baseFields() + (field to value)) + } + } + listOf("ftp://example.test", "https:/missing-host", "http:///path", "https://", "https://bad_host/a").forEach { + assertRejectedWithoutMutation(baseFields() + ("webUrl" to it)) + } + } + + @Test + @DisplayName("주소는 trim 외 대소문자·인코딩·순서·fragment·긴 문자열을 변경하지 않는다") + fun shouldPreserveLinkUriAfterTrimOnly() { + val links = mapOf( + "webUrl" to "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag", + "androidDeepLink" to "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end", + "iosDeepLink" to "new-custom:Item?value=${"a".repeat(512)}%2f" + ) + val created = success(send(HttpMethod.POST, fields = baseFields() + links.mapValues { " \t${it.value}\n " })) + val stored = reload(created["id"].asLong()) + links.forEach { (field, value) -> assertEquals(value, created[field].asText()) } + assertEquals(links["webUrl"], stored.webUrl) + assertEquals(links["androidDeepLink"], stored.androidDeepLink) + assertEquals(links["iosDeepLink"], stored.iosDeepLink) + } + + @ParameterizedTest + @CsvSource( + "OMITTED, OMITTED, , ", + "NULL, NULL, , ", + "2026-10-27 00:00, OMITTED, 2026-10-26T15:00:00, ", + "OMITTED, 2026-10-28 23:59, , 2026-10-28T14:59:59", + "2026-10-27 00:00, 2026-10-28 23:59, 2026-10-26T15:00:00, 2026-10-28T14:59:59", + "2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27T14:59:00, 2026-10-27T14:59:59", + "1970-01-01 09:01, 2038-01-19 12:13, 1970-01-01T00:01:00, 2038-01-19T03:13:59", + "OMITTED, 1970-01-01 09:00, , 1970-01-01T00:00:59", + "2038-01-19 12:14, OMITTED, 2038-01-19T03:14:00, ", + "1988-05-08 01:30, 1988-05-08 03:30, 1988-05-07T16:30:00, 1988-05-07T17:30:59", + "1988-10-09 02:30, 1988-10-09 02:30, 1988-10-08T16:30:00, 1988-10-08T16:30:59" + ) + @DisplayName("H2 TIMESTAMP(0) 재조회 후 UTC 초 경계와 HTTP KST 분 문자열이 정확히 왕복한다") + fun shouldRoundTripAdminDateStringsExactlyAfterDatabaseReload( + start: String, + end: String, + utcStart: String?, + utcEnd: String? + ) { + val response = send(HttpMethod.POST, fields = baseFields() + dates(start, end)) + if (start == "2026-10-27 23:59") evidence("POST dates", response) + val created = success(response) + val stored = reload(created["id"].asLong()) + assertEquals(utcStart?.let(LocalDateTime::parse), stored.startDate) + assertEquals(utcEnd?.let(LocalDateTime::parse), stored.endDate) + assertDate(created, "startDate", start) + assertDate(created, "endDate", end) + assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + } + + @ParameterizedTest + @ValueSource(strings = ["startDate", "endDate"]) + @DisplayName("존재하지 않는 KST DST gap 분은 POST·PUT 모두 업로드와 DB 변경 전에 거부한다") + fun shouldRejectKstDstGapBeforeUploadOrPersistence(field: String) { + assertRejectedWithoutMutation( + baseFields() + mapOf("startDate" to null, "endDate" to null) + (field to "1988-05-08 02:30") + ) + } + + @Test + @DisplayName("날짜의 실제 JSON 타입·엄격한 형식·달력·DB TIMESTAMP 범위·역전을 업로드 전에 거부한다") + fun shouldRejectInvalidDateJsonTypesFormatsAndRangesBeforeUpload() { + val invalid = listOf( + 2026, true, emptyList(), emptyMap(), "", " ", "not-a-date", + "2026-02-29 10:00", "2026-04-31 10:00", "2026-13-01 10:00", "2026-10-27 24:00", + "2026-10-27T10:00", "2026-10-27 10:00:00", "2026-10-27 10:00Z", "2026-10-27 10:00+09:00", + "2026-1-01 10:00", "+02026-10-27 10:00", " 2026-10-27 10:00", "2026-10-27 10:00 ", + "0000-01-01 00:00", "9999-12-31 23:59", "1969-12-31 23:59", "2039-01-01 00:00" + ) + listOf("startDate", "endDate").forEach { field -> + invalid.forEach { value -> assertRejectedWithoutMutation(baseFields() + (field to value)) } + } + assertRejectedWithoutMutation(baseFields() + dates("1970-01-01 09:00", "OMITTED")) + assertRejectedWithoutMutation(baseFields() + dates("OMITTED", "2038-01-19 12:14")) + assertRejectedWithoutMutation(baseFields() + dates("2026-10-29 00:00", "2026-10-28 23:59")) + } + + @ParameterizedTest + @CsvSource( + "OMITTED, OMITTED, 2026-10-28 00:00, 2026-10-29 23:59", + "NULL, OMITTED, NULL, 2026-10-29 23:59", + "OMITTED, NULL, 2026-10-28 00:00, NULL", + "NULL, NULL, NULL, NULL", + "2026-10-27 00:00, OMITTED, 2026-10-27 00:00, 2026-10-29 23:59", + "OMITTED, 2026-10-30 23:59, 2026-10-28 00:00, 2026-10-30 23:59", + "2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59", + "NULL, 2026-10-26 00:00, NULL, 2026-10-26 00:00", + "2026-10-30 00:00, NULL, 2026-10-30 00:00, NULL" + ) + @DisplayName("수정 생략은 경계를 보존하고 null은 개별 해제하며 이미지·주소는 유지한다") + fun shouldUpdateDatesWithOmittedPreservedAndNullCleared(start: String, end: String, wantStart: String, wantEnd: String) { + val id = createScheduled() + val before = reload(id) + Mockito.clearInvocations(s3Uploader) + val updated = success(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end), image = null)) + assertDate(updated, "startDate", wantStart) + assertDate(updated, "endDate", wantEnd) + val stored = reload(id) + assertEquals(expectedUtc(wantStart, 0), stored.startDate) + assertEquals(expectedUtc(wantEnd, 59), stored.endDate) + assertEquals(before.imagePath, stored.imagePath) + assertEquals(before.webUrl, stored.webUrl) + Mockito.verifyNoInteractions(s3Uploader) + } + + @ParameterizedTest + @CsvSource("2026-10-30 00:00, OMITTED", "OMITTED, 2026-10-27 23:59", "2026-10-30 00:00, 2026-10-29 23:59") + @DisplayName("수정은 유지한 날짜와 새 날짜를 병합한 역전 범위를 업로드 전에 거부한다") + fun shouldRejectReversedEffectiveUpdateRange(start: String, end: String) { + val id = createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + assertStatus(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end)), HttpStatus.BAD_REQUEST) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("관리자 목록은 빈 배열·placement 격리·미래·만료·무기한과 20개 초과 id 순서를 보장한다") + fun shouldListAllPeriodsInIdOrderWithoutTruncation() { + assertEquals(emptyList(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + val ids = (0 until 23).map { index -> + val period = when (index % 3) { + 0 -> dates("2030-01-01 00:00", "2030-01-02 23:59") + 1 -> dates("2020-01-01 00:00", "2020-01-02 23:59") + else -> emptyMap() + } + success(send(HttpMethod.POST, fields = baseFields() + period))["id"].asLong() + } + val other = success(send(HttpMethod.POST, fields = baseFields() + ("placement" to "CONTENT_RECOMMENDATION"))) + assertEquals(ids, success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).map { it["id"].asLong() }) + assertEquals(listOf(other), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList()) + listOf("", "?placement=", "?placement=OTHER", "?placement=home_recommendation").forEach { + assertStatus(send(HttpMethod.GET, it), HttpStatus.BAD_REQUEST) + } + } + + @Test + @DisplayName("빈 파일·위장 이미지 교체를 거부하고 업로드 실패 시 기존 행과 이미지 경로를 보존한다") + fun shouldKeepExistingImageWhenReplacementUploadFails() { + val id = createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + listOf(byteArrayOf(), "not an image".toByteArray()).forEach { bytes -> + assertStatus(send(HttpMethod.POST, image = bytes), HttpStatus.BAD_REQUEST) + assertStatus(send(HttpMethod.PUT, "/$id", image = bytes), HttpStatus.BAD_REQUEST) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + Mockito.doThrow(IllegalStateException("Upload failed")).`when`(s3Uploader) + .upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull()) + val response = send(HttpMethod.PUT, "/$id", baseFields() + ("webUrl" to "https://changed.test")) + assertEquals(HttpStatus.OK, response.statusCode) + assertFalse(objectMapper.readTree(response.body)["success"].asBoolean()) + assertEquals(before, storedRows()) + assertFalse(requireNotNull(response.body).contains("Upload failed")) + } + + @Test + @DisplayName("없는 id의 수정·삭제는 404이며 이미지 업로드나 행 변경이 없다") + fun shouldRejectMissingIdWithoutMutation() { + createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + listOf(HttpMethod.PUT, HttpMethod.DELETE).forEach { method -> + val response = send(method, "/${Long.MAX_VALUE}") + evidence("missing $method", response) + assertStatus(response, HttpStatus.NOT_FOUND) + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("익명 401과 모든 비관리자 역할의 CRUD 거부는 업로드·DB 변경 없이 기존 정책을 따른다") + fun shouldRejectAnonymousAndNonAdminBeforeUpload() { + val id = createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + (listOf(null) + MemberRole.values().filter { it != MemberRole.ADMIN }.map { tokenFor(it) }).forEach { token -> + listOf( + HttpMethod.GET to "?placement=HOME_RECOMMENDATION", + HttpMethod.POST to "", + HttpMethod.PUT to "/$id", + HttpMethod.DELETE to "/$id" + ).forEach { (method, suffix) -> + val response = send(method, suffix, token = token) + if (token == null) { + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } else { + assertEquals(HttpStatus.OK, response.statusCode) + val body = objectMapper.readTree(response.body) + assertFalse(body["success"].asBoolean()) + assertEquals("You do not have permission.", body["message"].asText()) + } + if (method == HttpMethod.DELETE) evidence(if (token == null) "anonymous" else "non-admin", response) + } + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("필수 multipart part 누락·잘못된 multipart는 기존 framework 오류 envelope를 반환하며 변경하지 않는다") + fun shouldCharacterizeMissingImageAndMalformedMultipart() { + val responses = listOf( + send(HttpMethod.POST, image = null), + send(HttpMethod.POST, includeRequest = false), + restTemplate.exchange( + url(), + HttpMethod.POST, + HttpEntity("not a multipart body", headers(adminToken).apply { contentType = MediaType.MULTIPART_FORM_DATA }), + String::class.java + ) + ) + responses.forEach { response -> + evidence("framework multipart", response) + assertEquals(HttpStatus.OK, response.statusCode) + val body = objectMapper.readTree(response.body) + assertFalse(body["success"].asBoolean()) + assertTrue(body["data"].isNull) + } + assertEquals(0L, rowCount()) + Mockito.verifyNoInteractions(s3Uploader) + } + + private fun assertRejectedWithoutMutation(fields: Map = baseFields(), rawRequest: String? = null) { + if (rowCount() == 0L) createScheduled() + val id = jdbcTemplate.queryForObject("select min(id) from sub_banner", Long::class.java) + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + listOf(HttpMethod.POST to "", HttpMethod.PUT to "/$id").forEach { (method, suffix) -> + val response = send(method, suffix, fields, rawRequest = rawRequest) + assertStatus(response, HttpStatus.BAD_REQUEST) + assertFalse(requireNotNull(response.body).contains("javascript", ignoreCase = true)) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + } + + private fun createScheduled(): Long = success( + send(HttpMethod.POST, fields = baseFields() + dates("2026-10-28 00:00", "2026-10-29 23:59")) + )["id"].asLong() + + private fun send( + method: HttpMethod, + suffix: String = "", + fields: Map = baseFields(), + image: ByteArray? = PNG, + token: String? = adminToken, + rawRequest: String? = null, + includeRequest: Boolean = true + ): ResponseEntity { + val headers = headers(token) + val body = if (method == HttpMethod.POST || method == HttpMethod.PUT) { + headers.contentType = MediaType.MULTIPART_FORM_DATA + LinkedMultiValueMap().apply { + if (includeRequest) { + add( + "request", + HttpEntity( + rawRequest ?: objectMapper.writeValueAsString(fields), + HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON } + ) + ) + } + if (image != null) { + add( + "image", + HttpEntity( + object : ByteArrayResource(image) { + override fun getFilename(): String = "client-name.png" + }, + HttpHeaders().apply { contentType = MediaType.IMAGE_PNG } + ) + ) + } + } + } else { + null + } + return restTemplate.exchange(url() + suffix, method, HttpEntity(body, headers), String::class.java) + } + + private fun headers(token: String?): HttpHeaders = HttpHeaders().apply { + token?.let { setBearerAuth(it) } + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + accept = listOf(MediaType.APPLICATION_JSON) + } + + private fun success(response: ResponseEntity, allowNullData: Boolean = false): JsonNode { + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertTrue(body["success"].asBoolean(), response.body) + assertTrue(body["message"].isNull) + assertTrue(body["errorProperty"].isNull) + if (!allowNullData) assertFalse(body["data"].isNull, "Successful create/update/list must return data") + return body["data"] + } + + private fun assertAdminFields(data: JsonNode) { + assertEquals( + setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"), + data.fieldNames().asSequence().toSet() + ) + } + + private fun assertStatus(response: ResponseEntity, status: HttpStatus) { + assertEquals(status, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(status.value(), body["status"].asInt()) + assertFalse(body.has("trace")) + assertFalse(body.has("exception")) + } + + private fun reload(id: Long): SubBanner = requireNotNull( + transactionTemplate.execute { + entityManager.flush() + entityManager.clear() + repository.findById(id) + } + ) + + private fun storedRows(): List> = jdbcTemplate.queryForList("select * from sub_banner order by id") + + private fun rowCount(): Long = requireNotNull( + jdbcTemplate.queryForObject("select count(*) from sub_banner", Long::class.java) + ) + + private fun tokenFor(role: MemberRole): String { + val member = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role) + ) + val id = requireNotNull(member.id) + memberIds.add(id) + return tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))), + id + ) + } + + private fun assertDate(data: JsonNode, field: String, value: String) { + if (value == "NULL" || value == "OMITTED") assertTrue(data[field].isNull) else assertEquals(value, data[field].asText()) + } + + private fun dates(start: String, end: String): Map = mapOf("startDate" to start, "endDate" to end) + .filterValues { it != "OMITTED" } + .mapValues { if (it.value == "NULL") null else it.value } + + private fun expectedUtc(value: String, second: Int): LocalDateTime? = if (value == "NULL") { + null + } else { + LocalDateTime.parse(value, DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm")).minusHours(9).withSecond(second) + } + + private fun evidence(label: String, response: ResponseEntity) { + println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}") + } + + private fun url(): String = "http://127.0.0.1:$port/admin/sub-banners" + + private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream() + + private fun baseFields(): Map = mapOf( + "placement" to "HOME_RECOMMENDATION", + "webUrl" to WEB, + "androidDeepLink" to null, + "iosDeepLink" to null + ) + + companion object { + private const val WEB = "https://example.test/promo?x=a%2Fb" + private val PNG = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a0foAAAAASUVORK5CYII=" + ) + } +} From 73aa6da7f06909ef0bb0e6a5220f56946bc67946 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:23:20 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(subbanner):=20=ED=99=88=EA=B3=BC=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=B6=94=EC=B2=9C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20=EC=84=9C=EB=B8=8C=20=EB=B0=B0=EB=84=88?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AudioRecommendationFacade.kt | 12 +- .../dto/AudioRecommendationsResponse.kt | 5 +- .../application/HomeRecommendationFacade.kt | 6 + .../HomeRecommendationResponse.kt | 2 + .../v2/subbanner/dto/SubBannerResponse.kt | 24 + .../web/AudioRecommendationControllerTest.kt | 2 + .../in/web/AudioRecommendationEndToEndTest.kt | 1 + .../AudioRecommendationFacadeTest.kt | 4 +- .../home/HomeRecommendationControllerTest.kt | 7 +- .../HomeRecommendationFacadeTest.kt | 2 + .../HomeRecommendationResponseTest.kt | 2 + .../RecommendationSubBannerIntegrationTest.kt | 525 ++++++++++++++++++ 12 files changed, 587 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt index b4e19432..04948252 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt @@ -4,14 +4,22 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @Component class AudioRecommendationFacade( - private val queryService: AudioRecommendationQueryService + private val queryService: AudioRecommendationQueryService, + private val subBannerService: SubBannerService, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse { val result = queryService.getRecommendations(member, lang) - return AudioRecommendationsResponse.from(result) + val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION) + .map { SubBannerResponse.from(it, cloudFrontHost) } + return AudioRecommendationsResponse.from(result, subBanners) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt index a3d18dce..a936abfa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt @@ -6,9 +6,11 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse data class AudioRecommendationsResponse( val banners: List, + val subBanners: List, val originalSeries: List, val latestAudios: List, val newAndHotAudios: List, @@ -18,9 +20,10 @@ data class AudioRecommendationsResponse( val recommendedAudios: List ) { companion object { - fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse { + fun from(recommendations: AudioRecommendations, subBanners: List): AudioRecommendationsResponse { return AudioRecommendationsResponse( banners = recommendations.banners.map(RecommendationBannerResponse::from), + subBanners = subBanners, originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from), latestAudios = recommendations.latestAudios.map(AudioCardResponse::from), newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index a1f86435..2e359e79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -30,6 +30,9 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendatio import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @@ -39,6 +42,7 @@ import java.time.LocalDateTime class HomeRecommendationFacade( private val queryService: HomeRecommendationQueryService, private val memberContentPreferenceService: MemberContentPreferenceService, + private val subBannerService: SubBannerService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -68,6 +72,8 @@ class HomeRecommendationFacade( lang = bannerLang ) .map { RecommendationBannerResponse.from(it.toBanner()) }, + subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION) + .map { SubBannerResponse.from(it, cloudFrontHost) }, recentlyActiveCreators = queryService.findRecentlyActiveCreators( HOME_ACTIVE_CREATOR_LIMIT, member?.id, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt index cc19db1a..77516e0d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse import java.time.LocalDateTime import java.time.ZoneOffset @@ -20,6 +21,7 @@ internal fun profileImageUrl(cloudFrontHost: String, path: String?): String { data class HomeRecommendationResponse( val lives: List, val banners: List, + val subBanners: List, val recentlyActiveCreators: List, val recentDebutCreators: List, val firstAudioContents: List, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt new file mode 100644 index 00000000..1a47e413 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.v2.subbanner.dto + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement + +data class SubBannerResponse( + val id: Long, + val placement: SubBannerPlacement, + val imageUrl: String, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String? +) { + companion object { + fun from(banner: SubBanner, cloudFrontHost: String): SubBannerResponse = SubBannerResponse( + id = requireNotNull(banner.id), + placement = banner.placement, + imageUrl = "$cloudFrontHost/${banner.imagePath}", + webUrl = banner.webUrl, + androidDeepLink = banner.androidDeepLink, + iosDeepLink = banner.iosDeepLink + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt index 50992a80..b0c59109 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt @@ -67,6 +67,7 @@ class AudioRecommendationControllerTest @Autowired constructor( .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.banners").isArray) + .andExpect(jsonPath("$.data.subBanners").isEmpty) .andExpect(jsonPath("$.data.originalSeries").isArray) .andExpect(jsonPath("$.data.latestAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios").isArray) @@ -93,6 +94,7 @@ class AudioRecommendationControllerTest @Autowired constructor( private fun emptyResponse(): AudioRecommendationsResponse { return AudioRecommendationsResponse( banners = emptyList(), + subBanners = emptyList(), originalSeries = emptyList(), latestAudios = emptyList(), newAndHotAudios = emptyList(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index 7154a39f..39874698 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -49,6 +49,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor( mockMvc.perform(get("/api/v2/audio/recommendations")) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.subBanners").isEmpty) .andExpect(jsonPath("$.data.originalSeries").isArray) .andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId)) .andExpect(jsonPath("$.data.latestAudios").isArray) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt index 55799fd0..3c24bdda 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -16,7 +17,7 @@ import org.mockito.Mockito class AudioRecommendationFacadeTest { private val objectMapper = jacksonObjectMapper() private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java) - private val facade = AudioRecommendationFacade(queryService) + private val facade = AudioRecommendationFacade(queryService, Mockito.mock(SubBannerService::class.java), "https://cdn.test") @Test @DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다") @@ -39,6 +40,7 @@ class AudioRecommendationFacadeTest { assertEquals(false, response.recommendedAudios[0].isPointAvailable) val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + assertEquals(objectMapper.readTree("[]"), json["subBanners"]) assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean()) assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 85fec011..069deefd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.Recomm import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertThrows @@ -59,7 +60,8 @@ class HomeRecommendationControllerTest @Autowired constructor( private val memberRepository: MemberRepository, private val creatorFollowingRepository: CreatorFollowingRepository, private val entityManager: EntityManager, - private val jdbcTemplate: JdbcTemplate + private val jdbcTemplate: JdbcTemplate, + private val subBannerService: SubBannerService ) { @MockBean private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService @@ -372,6 +374,7 @@ class HomeRecommendationControllerTest @Autowired constructor( val facade = HomeRecommendationFacade( failingQueryService, preferenceService, + subBannerService, "https://cdn.test" ) Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, lang = Lang.KO)) @@ -414,6 +417,7 @@ class HomeRecommendationControllerTest @Autowired constructor( val facade = HomeRecommendationFacade( failingQueryService, preferenceService, + subBannerService, "https://cdn.test" ) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) @@ -448,6 +452,7 @@ class HomeRecommendationControllerTest @Autowired constructor( val facade = HomeRecommendationFacade( failingQueryService, preferenceService, + subBannerService, "https://cdn.test" ) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index f8cedadb..f927b102 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRec import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName @@ -40,6 +41,7 @@ class HomeRecommendationFacadeTest { private val facade = HomeRecommendationFacade( queryService, preferenceService, + Mockito.mock(SubBannerService::class.java), "https://cdn.test" ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt index ec6fda32..a805ee3c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt @@ -14,6 +14,7 @@ class HomeRecommendationResponseTest { fun shouldSerializeNewHomeRecommendationFields() { val response = HomeRecommendationResponse( lives = emptyList(), + subBanners = emptyList(), banners = listOf( RecommendationBannerResponse( imageUrl = "https://cdn.test/banner.png", @@ -101,6 +102,7 @@ class HomeRecommendationResponseTest { val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + assertEquals(objectMapper.readTree("[]"), json["subBanners"]) assertEquals("https://cdn.test/banner.png", json["banners"][0]["imageUrl"].asText()) assertEquals(true, json["banners"][0]["eventItem"].isNull) assertEquals(11L, json["banners"][0]["creatorId"].asLong()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt new file mode 100644 index 00000000..7fb3b9dc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt @@ -0,0 +1,525 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.EnumSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import javax.persistence.EntityManager + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "cloud.aws.s3.bucket=test-bucket", + "spring.datasource.url=jdbc:h2:mem:public-sub-banner-http;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class RecommendationSubBannerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val repository: SubBannerRepository, + private val jdbcTemplate: JdbcTemplate, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val time: TestTime, + private val requestMappingHandlerMapping: RequestMappingHandlerMapping +) { + @LocalServerPort + private var port: Int = 0 + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private val memberIds = mutableListOf() + + @BeforeEach + fun setUp() { + jdbcTemplate.update("delete from sub_banner") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)") + jdbcTemplate.update("insert into audio_content_main_tab (id, title, is_active) values (2, 'recommendation', true)") + transactionTemplate.execute { + entityManager.persist(legacyBanner()) + entityManager.persist(legacyBanner().apply { tab = entityManager.getReference(AudioContentMainTab::class.java, 2L) }) + entityManager.flush() + entityManager.clear() + } + time.set(LocalDateTime.parse("2026-10-29T23:59:59.999999999")) + } + + @AfterEach + fun cleanUp() { + jdbcTemplate.update("delete from sub_banner") + jdbcTemplate.update("delete from content_banner") + jdbcTemplate.update("delete from audio_content_main_tab") + tokenRepository.deleteAll() + memberRepository.deleteAllById(memberIds) + memberIds.clear() + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("익명 추천 GET은 기존 응답과 배너를 보존하며 두 placement의 빈 subBanners 배열을 반환한다") + fun shouldReturnEmptyArrayForEitherPlacement(placement: SubBannerPlacement) { + val response = send(recommendationPath(placement)) + evidence("empty $placement", response) + assertEquals(emptyList(), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("공개 목록만 현재 기간을 필터링하고 관리자는 미래·만료·무기한을 모두 유지한다") + fun shouldReturnOnlyVisiblePlacementRowsInIdOrderWithoutDates(placement: SubBannerPlacement) { + val now = LocalDateTime.parse("2026-10-29T23:59:59") + val unbounded = save(banner(placement)) + val future = save(banner(placement, start = now.plusSeconds(1))) + val untilNow = save(banner(placement, end = now)) + val expired = save(banner(placement, end = now.minusSeconds(1))) + val fromNow = save(banner(placement, start = now)) + val instant = save(banner(placement, start = now, end = now)) + val other = SubBannerPlacement.values().first { it != placement } + save(banner(other)) + val response = send(recommendationPath(placement)) + evidence("visible $placement", response) + assertEquals( + listOf(unbounded, untilNow, fromNow, instant).map(::publicTree), + subBanners(response, placement).toList() + ) + assertEquals(1, time.calls.get()) + val admin = success(send("$ADMIN_PATH?placement=$placement", token = tokenFor(MemberRole.ADMIN))) + assertEquals(listOf(unbounded, future, untilNow, expired, fromNow, instant).map { it.id }, ids(admin)) + assertTrue(admin.all { it.has("startDate") && it.has("endDate") }) + Mockito.verifyNoInteractions(s3Uploader) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("실제 관리자 DELETE는 모든 기간의 행을 보존하며 익명·관리자 목록에서 비활성 행을 제외한다") + fun shouldHideSoftDeletedRowsFromPublicAndAdminWhileRetainingDatabaseRows(placement: SubBannerPlacement) { + val now = LocalDateTime.parse("2026-10-29T23:59:59") + val periods = listOf(null to null, now.minusMinutes(1) to now, now.plusSeconds(1) to null, null to now.minusSeconds(1)) + val deleted = periods.map { (start, end) -> save(banner(placement, start, end)) } + val active = periods.map { (start, end) -> save(banner(placement, start, end)) } + val other = save(banner(SubBannerPlacement.values().first { it != placement })) + val before = storedRows() + val token = tokenFor(MemberRole.ADMIN) + + deleted.forEach { banner -> + val response = send("$ADMIN_PATH/${banner.id}", HttpMethod.DELETE, token) + evidence("soft-delete $placement", response) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + assertEquals( + objectMapper.readTree("""{"success":true,"message":null,"data":null,"errorProperty":null}"""), + objectMapper.readTree(response.body) + ) + } + + val retained = storedRows() + assertEquals(before.size, retained.size, "Soft delete must retain every original row") + before.zip(retained).forEach { (original, stored) -> + if (deleted.any { it.id == (stored["id"] as Number).toLong() }) { + assertEquals(false, stored["is_active"]) + assertEquals(original - setOf("is_active", "updated_at"), stored - setOf("is_active", "updated_at")) + } else { + assertEquals(true, stored["is_active"]) + assertEquals(original, stored) + } + } + println("DB EVIDENCE $placement retained=${retained.size} inactive=${retained.count { it["is_active"] == false }}") + val publicResponse = send(recommendationPath(placement)) + evidence("after soft-delete $placement", publicResponse) + assertEquals(active.take(2).map(::publicTree), subBanners(publicResponse, placement).toList()) + assertEquals(1, time.calls.get()) + val admin = success(send("$ADMIN_PATH?placement=$placement", token = token)) + assertEquals(active.map { it.id }, ids(admin)) + admin.forEach { data -> + assertEquals( + setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"), + data.fieldNames().asSequence().toSet() + ) + } + assertEquals( + listOf(publicTree(other)), + subBanners(send(recommendationPath(other.placement)), other.placement).toList() + ) + assertEquals(listOf(other.id), ids(success(send("$ADMIN_PATH?placement=${other.placement}", token = token)))) + assertEquals(retained, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @ParameterizedTest + @CsvSource( + "2026-10-29T23:58:59.999999999, false, false", + "2026-10-29T23:59:00, true, false", + "2026-10-29T23:59:00.123456789, true, false", + "2026-10-29T23:59:00.999999999, true, false", + "2026-10-29T23:59:59, true, true", + "2026-10-29T23:59:59.123456789, true, true", + "2026-10-29T23:59:59.999999999, true, true", + "2026-10-30T00:00:00, false, false", + "2026-10-30T00:00:00.123456789, false, false" + ) + @DisplayName("고정 UTC now는 한 번만 읽고 초 절삭하여 시작·종료·동일 초·날짜 경계를 포함 판정한다") + fun shouldUseOneSecondPrecisionNowAcrossInclusiveBoundaries(now: String, inMinute: Boolean, atInstant: Boolean) { + val start = LocalDateTime.parse("2026-10-29T23:59:00") + val end = LocalDateTime.parse("2026-10-29T23:59:59") + SubBannerPlacement.values().forEach { placement -> + val minute = save(banner(placement, start = start, end = end)) + val instant = save(banner(placement, start = end, end = end)) + time.set(LocalDateTime.parse(now)) + val response = send(recommendationPath(placement)) + evidence("UTC $now $placement", response) + val expected = listOfNotNull(minute.takeIf { inMinute }, instant.takeIf { atInstant }) + assertEquals(expected.map(::publicTree), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + assertEquals(start, reload(requireNotNull(minute.id)).startDate) + assertEquals(end, reload(requireNotNull(minute.id)).endDate) + } + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("공개 목록은 pagination이나 20개 제한 없이 모든 항목을 id 오름차순으로 반환한다") + fun shouldReturnAllVisibleRowsWithoutLimit(placement: SubBannerPlacement) { + val saved = (0 until 25).map { index -> save(banner(placement).copy(imagePath = "sub_banner/$index.png")) } + save(banner(SubBannerPlacement.values().first { it != placement })) + val response = send(recommendationPath(placement)) + evidence("25 untruncated $placement", response) + val data = subBanners(response, placement) + assertEquals(saved.map(::publicTree), data.toList()) + assertEquals(saved.map { requireNotNull(it.id) }.sorted(), ids(data)) + assertEquals(25, data.size()) + assertEquals(1, time.calls.get()) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("iOS 요청도 Android 전용 행과 모든 저장 링크 원문을 공개 DTO 6필드 그대로 받는다") + fun shouldPreserveAllStoredLinksWithoutServerSideOsFiltering(placement: SubBannerPlacement) { + val androidOnly = save(banner(placement).copy(webUrl = null, androidDeepLink = "sampleapp://Item?id=a%2Fb")) + val allLinks = save( + banner(placement).copy( + webUrl = "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag", + androidDeepLink = "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end", + iosDeepLink = "new-custom:Item?value=${"a".repeat(512)}%2f" + ) + ) + val response = send(recommendationPath(placement), userAgent = "iPhone iOS") + evidence("all OS links $placement", response) + assertEquals(listOf(androidOnly, allLinks).map(::publicTree), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + } + + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("추천 경로가 placement를 고정하며 다른 placement query로 바꿀 수 없다") + fun shouldKeepPlacementFixedByRecommendationRoute(placement: SubBannerPlacement) { + val saved = save(banner(placement)) + val other = SubBannerPlacement.values().first { it != placement } + save(banner(other)) + val response = send("${recommendationPath(placement)}?placement=$other") + assertEquals(listOf(publicTree(saved)), subBanners(response, placement).toList()) + assertEquals(1, time.calls.get()) + } + + @Test + @DisplayName("폐기한 앱 GET은 handler가 없고 인증 요청도 404이며 익명 요청은 기존 인증 정책으로 거부한다") + fun shouldRemoveStandaloneHandlerAndReturnNotFoundForAuthenticatedRequest() { + save(banner()) + val before = storedRows() + val response = send("$RETIRED_PATH?placement=HOME_RECOMMENDATION", token = tokenFor(MemberRole.USER)) + evidence("retired authenticated GET", response) + assertEquals(HttpStatus.NOT_FOUND, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(404, body["status"].asInt()) + assertFalse(body.has("trace")) + assertFalse(body.has("exception")) + assertFalse(requestMappingHandlerMapping.handlerMethods.keys.any { RETIRED_PATH in it.patternValues }) + val anonymous = send("$RETIRED_PATH?placement=HOME_RECOMMENDATION") + evidence("retired anonymous GET", anonymous) + assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode, anonymous.body) + assertEquals(0, time.calls.get()) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("익명은 정확한 GET만 허용하고 HEAD·변경 method·하위·접두사 인접 경로는 401이다") + fun shouldPermitOnlyExactPublicGetAndKeepAdminProtected() { + val saved = save(banner()) + val before = storedRows() + SubBannerPlacement.values().forEach { placement -> + val path = recommendationPath(placement) + val expected = if (placement == saved.placement) listOf(publicTree(saved)) else emptyList() + assertEquals(expected, subBanners(send(path), placement).toList()) + listOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.HEAD, HttpMethod.PATCH).forEach { method -> + val response = send(path, method) + evidence("anonymous $method $path", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + listOf("$path/", "$path/${saved.id}", "$path/extra", "$path-extra").forEach { neighbor -> + val response = send(neighbor) + evidence("anonymous GET $neighbor", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + } + listOf("lives", "debut-creators", "ai-characters").forEach { section -> + val response = send("/api/v2/home/recommendations/$section") + evidence("anonymous home page $section", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + listOf(HttpMethod.GET to "$ADMIN_PATH?placement=HOME_RECOMMENDATION", HttpMethod.DELETE to "$ADMIN_PATH/${saved.id}") + .forEach { (method, path) -> + val response = send(path, method) + evidence("anonymous admin $method", response) + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body) + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("실제 비관리자 JWT의 관리자 조회·삭제 거부는 기존 HTTP 200 success false를 유지한다") + fun shouldKeepNonAdminReadAndMutationDeniedWithoutSideEffects() { + val saved = save(banner()) + val before = storedRows() + val token = tokenFor(MemberRole.USER) + listOf(HttpMethod.GET to "$ADMIN_PATH?placement=HOME_RECOMMENDATION", HttpMethod.DELETE to "$ADMIN_PATH/${saved.id}") + .forEach { (method, path) -> + val response = send(path, method, token) + evidence("non-admin $method", response) + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertFalse(body["success"].asBoolean()) + assertEquals("You do not have permission.", body["message"].asText()) + assertTrue(body["data"].isNull) + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + private fun send( + path: String, + method: HttpMethod = HttpMethod.GET, + token: String? = null, + userAgent: String = "sub-banner-http-test" + ): ResponseEntity = restTemplate.exchange( + "http://127.0.0.1:$port$path", + method, + HttpEntity( + HttpHeaders().apply { + token?.let { setBearerAuth(it) } + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + set(HttpHeaders.USER_AGENT, userAgent) + accept = listOf(MediaType.APPLICATION_JSON) + } + ), + String::class.java + ) + + private fun success(response: ResponseEntity): JsonNode { + val data = successData(response) + assertTrue(data.isArray) + return data + } + + private fun successData(response: ResponseEntity): JsonNode { + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertTrue(body["success"].asBoolean(), response.body) + assertTrue(body["message"].isNull) + assertTrue(body["errorProperty"].isNull) + return body["data"] + } + + private fun subBanners(response: ResponseEntity, placement: SubBannerPlacement): JsonNode { + val data = successData(response) + assertTrue(data.isObject, response.body) + assertTrue(data.has("subBanners"), "Recommendation response must include subBanners: ${response.body}") + val legacyBanner = """"banners":[{"imageUrl":"https://cdn.test/legacy.png","eventItem":null, + "creatorId":null,"seriesId":null,"link":"https://example.test/legacy"}]""" + val expected = objectMapper.readTree( + when (placement) { + SubBannerPlacement.HOME_RECOMMENDATION -> + """{ + $legacyBanner,"lives":[],"recentlyActiveCreators":[],"recentDebutCreators":[], + "firstAudioContents":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[] + }""" + SubBannerPlacement.CONTENT_RECOMMENDATION -> + """{ + $legacyBanner,"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[], + "pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[] + }""" + } + ) + assertEquals(expected.fieldNames().asSequence().toSet() + "subBanners", data.fieldNames().asSequence().toSet()) + expected.fields().forEach { (field, value) -> assertEquals(value, data[field], "Existing $field must be preserved") } + assertTrue(data["subBanners"].isArray, response.body) + return data["subBanners"] + } + + private fun recommendationPath(placement: SubBannerPlacement): String = when (placement) { + SubBannerPlacement.HOME_RECOMMENDATION -> "/api/v2/home/recommendations" + SubBannerPlacement.CONTENT_RECOMMENDATION -> "/api/v2/audio/recommendations" + } + + private fun legacyBanner(): AudioContentBanner = AudioContentBanner( + thumbnailImage = "legacy.png", + type = AudioContentBannerType.LINK + ).apply { link = "https://example.test/legacy" } + + private fun publicTree(banner: SubBanner): JsonNode = objectMapper.readTree( + objectMapper.writeValueAsString( + mapOf( + "id" to banner.id, + "placement" to banner.placement.name, + "imageUrl" to "https://cdn.test/${banner.imagePath}", + "webUrl" to banner.webUrl, + "androidDeepLink" to banner.androidDeepLink, + "iosDeepLink" to banner.iosDeepLink + ) + ) + ) + + private fun ids(data: JsonNode): List = data.map { it["id"].asLong() } + + private fun banner( + placement: SubBannerPlacement = SubBannerPlacement.HOME_RECOMMENDATION, + start: LocalDateTime? = null, + end: LocalDateTime? = null + ): SubBanner = SubBanner( + placement = placement, + imagePath = "sub_banner/test.png", + webUrl = "https://example.test/promo?x=a%2Fb", + androidDeepLink = null, + iosDeepLink = null, + startDate = start, + endDate = end + ) + + private fun save(banner: SubBanner): SubBanner = requireNotNull( + transactionTemplate.execute { + val saved = repository.save(banner) + entityManager.flush() + entityManager.clear() + repository.findById(requireNotNull(saved.id)) + } + ) + + private fun reload(id: Long): SubBanner = requireNotNull( + transactionTemplate.execute { + entityManager.clear() + repository.findById(id) + } + ) + + private fun storedRows(): List> = jdbcTemplate.queryForList("select * from sub_banner order by id") + + private fun tokenFor(role: MemberRole): String { + val member = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role) + ) + val id = requireNotNull(member.id) + memberIds.add(id) + return tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))), + id + ) + } + + private fun evidence(label: String, response: ResponseEntity) { + println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}") + } + + class TestTime { + private val value = AtomicReference() + val calls = AtomicInteger() + + fun set(now: LocalDateTime) { + value.set(now) + calls.set(0) + } + + fun now(): LocalDateTime { + calls.incrementAndGet() + return value.get() + } + } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun subBannerTestTime(): TestTime = TestTime() + + @Bean + @Primary + fun fixedTimeSubBannerService( + repository: SubBannerRepository, + s3Uploader: S3Uploader, + objectMapper: ObjectMapper, + time: TestTime + ): SubBannerService = SubBannerService(repository, s3Uploader, objectMapper, "test-bucket", time::now) + } + + companion object { + private const val RETIRED_PATH = "/api/v2/sub-banners" + private const val ADMIN_PATH = "/admin/sub-banners" + } +} From 103f8c4396c030d4d44b03edcdb5855e3322ed76 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:24:20 +0900 Subject: [PATCH 11/13] =?UTF-8?q?docs(subbanner):=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EA=B3=BC=20=EC=95=B1=C2=B7=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=97=B0=EB=8F=99=20=EA=B3=84=EC=95=BD=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261029_가로형서브배너/admin-prompt.md | 90 +++++++++++ docs/20261029_가로형서브배너/api-contract.md | 141 +++++++++++++++++ docs/20261029_가로형서브배너/app-prompt.md | 102 +++++++++++++ docs/20261029_가로형서브배너/prd.md | 152 +++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 docs/20261029_가로형서브배너/admin-prompt.md create mode 100644 docs/20261029_가로형서브배너/api-contract.md create mode 100644 docs/20261029_가로형서브배너/app-prompt.md create mode 100644 docs/20261029_가로형서브배너/prd.md diff --git a/docs/20261029_가로형서브배너/admin-prompt.md b/docs/20261029_가로형서브배너/admin-prompt.md new file mode 100644 index 00000000..a7882b2d --- /dev/null +++ b/docs/20261029_가로형서브배너/admin-prompt.md @@ -0,0 +1,90 @@ +# 관리자 구현 프롬프트: 가로형 서브 배너 + +이 프롬프트는 관리자 작업에 단독으로 사용할 수 있다. 작업 대상 repository의 실제 stack, 관리자 인증, API client, multipart 전송 및 form/list 패턴을 먼저 확인한다. 특정 framework를 전제하지 않는다. 이 작업은 frontend-only이며 서버, 저장소, schema, migration, 배포 작업으로 확장하지 않는다. + +**상태:** 관리자 CRUD API 구현·검증 완료, 미배포. 백엔드 전체 테스트·lint·빌드와 독립 코드 리뷰를 통과했다. 화면 배치, 폼 표현, 삭제 확인 등 UI 선택은 작성자 추천이며 계약으로 가장하지 않는다. + +## 목표 + +가로형 서브 배너의 목록·생성·수정·삭제 UI를 제공한다. 레코드 하나는 `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` placement 하나에 속한다. 별도 상세 조회는 없으므로 목록 데이터로 수정 form을 채운다. + +## API 계약 + +아래 계약은 구현되어 있지만 아직 배포되지 않았다. 배포된 target을 확인한 뒤 그 target에만 실제 연결한다. target 오류 후 환경 변경이나 mock fallback을 자동 적용하지 않는다. fixture는 명시적인 UI 검증에서만 사용하며 mock 상태를 서버 성공으로 표시하지 않는다. + +- `GET /admin/sub-banners?placement={placement}`: 필수 placement query, 삭제되지 않은 활성 배너를 기간과 무관하게 반환한다. +- `POST /admin/sub-banners`: 생성. +- `PUT /admin/sub-banners/{id}`: 기본 JSON 네 필드 교체. 두 날짜 경계만 선택적으로 생략 보존 또는 명시적 null 해제한다. +- `DELETE /admin/sub-banners/{id}`: 소프트 삭제. 성공 시 목록에서 제거한다. 삭제된 id의 수정·재삭제는 404이며, 응답은 기존 HTTP 200과 `data:null`이다. `isActive`를 직접 보내거나 복원·활성 토글 UI를 추가하지 않는다. +- 기존 관리자 API client로 `Authorization: Bearer `을 전송하고 `ADMIN` 역할이 필요하다. 익명 요청은 HTTP 401이다. 유효한 비관리자 요청은 현재 정책상 HTTP 200 `success:false`이며 403을 보장하지 않는다. +- 성공은 HTTP 200 및 네 필드 `ApiResponse`: `success`, `message`, `data`, `errorProperty`. GET `data`는 전체 객체 배열, POST/PUT은 저장된 전체 객체, DELETE는 `null`. 빈 목록 `[]`. +- 관리자 응답 형식 `AdminSubBanner`: `id` integer JSON number(정밀도를 보존하는 불투명 식별자), `placement` enum(`HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION`), `imageUrl` 절대 http/https URL 문자열, `webUrl`·`androidDeepLink`·`iosDeepLink` 각각 string|null, `startDate`·`endDate` 각각 string|null. 모든 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. 목록, 생성, 수정 모두 이 관리자 shape를 쓴다. +- 앱은 두 추천 API의 `data.subBanners`를 사용한다. 배열 항목은 날짜·활성 필드가 없는 기존 6필드이며 활성·기간 내 항목에 앱의 웹/현재 OS 링크 조건을 적용한다. 관리자 CRUD 경로·요청·응답은 이 조회 통합으로 바뀌지 않는다. +- POST/PUT은 multipart: 파일 파트 `image`, `Content-Type: application/json`인 JSON 파트 `request`. 기본 JSON의 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink` 네 필드는 항상 모두 포함한다. 기존 네 필드는 PUT replacement이며 주소 `null`은 지운다. POST `image` 필수. PUT에서 `image` 생략은 기존 이미지 유지, 포함은 교체다. +- `startDate`, `endDate`는 각각 선택적이다. create에서 생략 또는 null이면 무제한이다. update에서 생략하면 해당 저장 값을 보존하고 null이면 해당 경계만 해제한다. update는 두 날짜를 모두 제출해도 된다. 현재 저장된 placement와 주소가 예시와 같을 때 `{ "placement":"HOME_RECOMMENDATION", "webUrl":"https://example.test/promo", "androidDeepLink":"sampleapp://promo", "iosDeepLink":null, "startDate":"2026-11-01 09:30" }`는 주소를 유지하고 생략한 `endDate`를 유지한다. `"endDate":null`을 추가하면 종료만 해제한다. 날짜를 둘 다 null로 보내면 기간 제한을 모두 해제한다. +- 기간 의미: null/null은 무제한, 시작만 있으면 시작 분의 `:00`부터, 종료만 있으면 종료 분의 `:59`까지, 두 값이 있으면 양 끝을 포함한다. 같은 분도 유효하며 과거 시작일 제한은 없다. +- 입력은 엄격한 `Asia/Seoul` `yyyy-MM-dd HH:mm` 분 단위다. 초, `Z`, offset, blank string 및 존재하지 않는 KST 현지 시각 `1988-05-08 02:30`은 HTTP 400이다. 겹치는 현지 시각은 기존 앞선 offset으로 처리된다. browser local timezone이나 UTC ISO 문자열로 변환하지 않는다. 날짜 선택기에는 `KST`를 명시한다. +- 변환된 경계 instant는 UTC `1970-01-01 00:00:01`부터 `2038-01-19 03:14:07` 사이여야 한다. 이에 따른 KST 입력 범위는 시작 `1970-01-01 09:01`부터 `2038-01-19 12:14`, 종료 `1970-01-01 09:00`부터 `2038-01-19 12:13`이다. 이 범위 안의 과거 날짜는 유효하다. 잘못된 날짜/type 또는 effective start가 end보다 늦으면 HTTP 400이며 값 보정은 없다. update에서는 저장된 값과 새 값의 조합으로 검증한다. +- **작성자 제안:** form submit 경계에서 주소를 trim하고 blank를 `null`로 정규화한 뒤, 그 결과 세 주소 중 적어도 하나가 nonblank인지 검증한다. 이는 XOR 조건이 아니며 두 개 이상도 유효하다. nonblank URI 원문은 trim 외 재인코딩·변형하지 않는다. +- 웹 URL은 host를 가진 절대 http(s) URL이어야 한다. Android/iOS 딥링크는 문법적으로 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다. +- 서비스가 검증하는 JSON/URI/date/type/placement 오류, 빈 파일 또는 비이미지 업로드는 HTTP 400이다. 유효한 정수 ID의 수정·삭제 대상이 없으면 HTTP 404이며 상태 오류 및 익명 401은 표준 Spring Boot `{timestamp,status,error,path}` body를 쓴다. 이는 `ApiResponse`가 아니다. multipart 파트 누락 또는 malformed multipart는 HTTP 200 `success:false`다. 프레임워크 바인딩 오류까지 모두 400이라고 가정하지 않는다. 유효한 비관리자는 HTTP 200 `success:false`다. 모든 응답에서 HTTP 상태와 `success`를 함께 확인하며 403을 전제하지 않는다. +- 파일 내용은 MIME 감지로 이미지인지 검사하며 GIF를 허용한다. 빈 파일과 비이미지는 HTTP 400이다. 현재 설정의 최대 파일/요청 크기는 1024 MB이며 제품 권장 용량이 아니다. reverse proxy 또는 배포 설정이 더 낮은 제한을 둘 수 있다. 범용 업로드 endpoint는 없다. + +```json +{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30","endDate":null} +``` + +POST/PUT 성공 응답 예시: + +```json +{ + "success": true, + "message": null, + "data": {"id": 123, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/promo.jpg", "webUrl": "https://example.test/promo", "androidDeepLink": "sampleapp://promo", "iosDeepLink": null, "startDate": "2026-11-01 09:30", "endDate": null}, + "errorProperty": null +} +``` + +## 폼과 목록 동작 + +- 관리자 API는 placement별 삭제되지 않은 모든 배너를 id 오름차순으로 반환한다. 클라이언트에서 재정렬하지 않고 전부 표시한다. 별도 정렬 편집은 추가하지 않는다. +- 폼은 이미지, 세 주소, KST 시작/종료 날짜 선택기를 제공한다. 날짜 입력을 비우면 `null`을 보내며 빈 문자열은 보내지 않는다. 각 경계를 별도로 지울 수 있다. 화면에 KST를 표시하고 브라우저 지역 시각 또는 UTC ISO로 바꾸지 않는다. +- 편집은 목록의 전체 객체를 기반으로 모든 값을 채운다. 기존 이미지 URL/선택 상태, 주소 및 기간 값을 오류 후 보존한다. 기간만 바꾸는 요청은 기존 placement와 세 주소를 그대로 전송하고, image를 생략해 기존 이미지를 유지한다. 주소를 임의로 null로 초기화하지 않는다. +- 관리자 목록은 삭제되지 않은 배너의 미래/현재/만료/무기간 상태를 모두 표시하며 노출 기간으로 필터링하지 않는다. 기간 밖 레코드도 열어 수정할 수 있지만 소프트 삭제된 배너는 표시·수정하지 않는다. 같은 placement의 서버 id 오름차순을 유지한다. +- 업데이트 기본 네 JSON 필드는 항상 보내되, 날짜 필드는 둘 다 보내거나 필요한 경계만 보내거나 생략할 수 있다. 생략은 보존, 명시적 null은 해당 경계 해제다. API의 생략 보존 의미는 UI가 두 날짜를 모두 보낼 때도 유지된다. +- 최소 한 주소 검증, 주소 trim/null 직렬화, URI 원형 보존을 적용한다. +- 날짜 선택값은 KST 분 문자열로 직렬화한다. 시간대 변환으로 날짜나 분을 바꾸지 않는다. create의 날짜 미입력은 null/no-bound로 보내거나 생략할 수 있다. update에서 미입력 경계를 보존하려면 해당 필드를 생략하고, 사용자가 지웠으면 명시적 null을 보낸다. +- 저장 후 해당 placement 목록을 갱신한다. 실패 시 입력값과 기존 이미지 상태를 보존하고 실패를 알린다. autosave는 두지 않는다. +- 삭제 확인, 중복 제출 방지, 상태 표시 및 성공 후 갱신은 기존 관리자 UI 패턴을 따른다. mutation 자동 재시도는 하지 않으며 timeout 결과가 불명확하면 상태를 조회한다. +- 조회 실패를 빈 성공으로 취급하지 않는다. +- 필요하면 두 딥링크 주소를 입력해 특정 OS만 지원하는 레코드임을 설명할 수 있으나 별도 기능으로 확장하지 않는다. + +## 주소 조합 acceptance matrix (작성자 제안) + +`web`, `android`, `ios` 열은 해당 주소가 nonblank인 경우다. 빈 값은 null로 전송한다. Valid 여부는 최소 한 값 요구에 따른다. +Android/iOS 탭 동작은 작성자 추천이며 제품 승인된 UI 동작이 아니다. 권장 흐름은 현재 OS 딥링크 우선, 없으면 웹 URL이다. + +| web | android | ios | 유효 | Android 동작 | iOS 동작 | +|---|---|---|---|---|---| +| 없음 | 없음 | 없음 | 아니오 | 표시 안 함 | 표시 안 함 | +| 있음 | 없음 | 없음 | 예 | 웹 | 웹 | +| 없음 | 있음 | 없음 | 예 | Android 딥링크 | 표시 안 함 | +| 없음 | 없음 | 있음 | 예 | 표시 안 함 | iOS 딥링크 | +| 있음 | 있음 | 없음 | 예 | Android 딥링크 우선, 실패 시 웹 | 웹 | +| 있음 | 없음 | 있음 | 예 | 웹 | iOS 딥링크 우선, 실패 시 웹 | +| 없음 | 있음 | 있음 | 예 | Android 딥링크 | iOS 딥링크 | +| 있음 | 있음 | 있음 | 예 | Android 딥링크 우선, 실패 시 웹 | iOS 딥링크 우선, 실패 시 웹 | + +## 필수 테스트와 완료 확인 + +- 여덟 주소 조합의 form 유효성 및 Android/iOS 대상 판단. +- 전부 null로 수정할 때 오류, `null`로 한 필드 지우기, 공백 trim, URI 원형 보존. +- 두 날짜 null, 시작만, 종료만, 같은 분, 경계의 `:00` 및 `:59`, 과거 날짜가 유효한 경우를 확인한다. +- create 날짜 생략/null, update 날짜 생략 보존, 개별 null 해제, 두 경계 해제, loaded date 재전송, 유효 범위가 retained/new 값 조합으로 검증됨을 확인한다. +- 잘못된 날짜/type, blank, 초, `Z`/offset 및 시작이 종료보다 늦은 값이 거부되고 자동 교환/rollover되지 않는지 확인한다. +- 미래/현재/만료/무기간 레코드가 관리자 목록에 남고 기간 외 레코드도 편집 가능한지 확인한다. 일정만 수정하고 image를 생략하면 기존 이미지가 유지되어야 한다. +- 여러 항목 표시, placement별 목록 분리, create/update/delete 성공과 실패. +- 실패 시 입력·이미지 상태 보존, 삭제 확인, 중복 제출 방지 및 timeout 후 중복 POST 방지 흐름. +- 인증/권한 실패와 목록 조회 실패를 empty로 오인하지 않는 상태. + +프롬프트는 frontend-only로 실행한다. 백엔드 검증은 완료됐지만 운영 DB 적용·배포는 하지 않았다. 배포 target이 확인되기 전 운영 요청이나 배포 성공을 주장하지 않는다. UI를 수정하는 작업은 기존 프로젝트 방식으로 검증하고, 서버 코드 및 API는 변경하지 않는다. diff --git a/docs/20261029_가로형서브배너/api-contract.md b/docs/20261029_가로형서브배너/api-contract.md new file mode 100644 index 00000000..64b3aba8 --- /dev/null +++ b/docs/20261029_가로형서브배너/api-contract.md @@ -0,0 +1,141 @@ +# 가로형 서브 배너 API 계약 + +> **상태: 소프트 삭제·두 추천 API 통합 구현 및 검증 완료, 미배포.** 최종 전체 테스트 2,682개(실패·오류·무시 0), lint·bootJar 및 독립 리뷰를 통과했다. 운영 DB 적용과 배포는 수행하지 않았다. + +## 1. 공통 형식 + +애플리케이션 성공 응답은 네 필드를 가진 `ApiResponse` 형식이다. 아래는 관리자 빈 목록 예시이며, 두 추천 GET의 data는 §2의 응답 객체다. + +```json +{ + "success": true, + "message": null, + "data": [], + "errorProperty": null +} +``` + +`data`는 조회의 응답 객체 또는 목록, 생성·수정의 전체 배너 객체, 삭제의 `null`이다. 성공 요청은 HTTP 200이다. 일부 요청 오류도 기존 handler에 따라 HTTP 200의 `success:false`로 응답하므로 HTTP 상태와 `success`를 모두 확인한다. + +```json +{ + "id": 123, + "placement": "HOME_RECOMMENDATION", + "imageUrl": "https://cdn.example.test/sub-banner.jpg", + "webUrl": "https://example.test/promo?x=a%2Fb", + "androidDeepLink": "sampleapp://promo/item?id=a%2Fb", + "iosDeepLink": null +} +``` + +| 필드 | 타입 | 규칙 | +|---|---|---| +| `id` | integer JSON number | 불투명 식별자. 소비 클라이언트의 정밀도 안전 ID 전략을 사용한다. | +| `placement` | enum | `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` | +| `imageUrl` | string | 서버가 생성한 저장 키를 기반으로 만든 절대 `http` 또는 `https` URL. 클라이언트 파일명은 저장 키가 아니다. | +| `webUrl` | string 또는 null | 절대 `http` 또는 `https` URL | +| `androidDeepLink` | string 또는 null | Android 대상 링크 원문 | +| `iosDeepLink` | string 또는 null | iOS 대상 링크 원문 | + +공개 `SubBanner`에는 날짜 필드를 추가하지 않는다. 앱 응답은 아래와 같이 기간 필터 적용 후의 기본 필드만 반환한다. 세 주소 중 하나 이상은 공백이 아닌 값이어야 한다. 입력 주소는 trim 후 공백이면 `null`로 보내고, nonblank URI는 trim 외 재인코딩·정규화·손상을 가하지 않는다. `webUrl`은 host를 가진 절대 http(s) URL이어야 한다. OS 딥링크는 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다. + +관리자 전용 `AdminSubBanner`는 `SubBanner`의 기본 필드에 nullable `startDate`, `endDate`를 더한 형태다. 관리자 GET 및 POST/PUT 응답은 모두 `AdminSubBanner`를 반환한다. 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. + +관리자 기간 의미는 기존 콘텐츠 배너 예약과 동일하다. null/null은 무제한, 시작만 있으면 시작부터, 종료만 있으면 해당 종료 경계까지 포함하며 시작과 종료가 같은 분인 것도 유효하다. 입력·응답은 분 단위지만 실제 노출 판정은 초 단위로 양 끝을 포함한다. 시작 경계는 입력 분의 `:00`, 종료 경계는 입력 분의 `:59`다. 예를 들어 종료 입력이 `2026-11-01 12:00`이면 `12:00:59`까지 포함하고 `12:01:00`부터 제외한다. API 문자열에는 초, `Z`, offset을 표현하거나 받지 않는다. + +요청 날짜는 선택적이다. 생성 시 생략 또는 null은 해당 경계를 제한 없음으로 둔다. 수정 시 생략은 저장된 경계를 유지하고 명시적 null은 그 경계만 해제한다. 두 날짜의 유효 범위는 수정 후 유지되는 값과 새 값을 합쳐 검증한다. 입력은 엄격한 Asia/Seoul `yyyy-MM-dd HH:mm` 분 단위다. 잘못된 형식, 문자열이 아닌 값, 빈 문자열, 초/시간대가 포함된 값, 존재하지 않는 KST 현지 시각(1988-05-08 02:30), 시작이 종료보다 늦은 값은 HTTP 400이다. 존재하는 겹침 시각은 기존의 앞선 offset을 사용한다. 자동 교환이나 날짜 넘김 보정, 과거 날짜 제한은 없다. + +UTC로 변환된 경계 instant는 `1970-01-01 00:00:01`부터 `2038-01-19 03:14:07` 사이여야 한다. 이에 따라 시작 입력 허용 범위는 KST `1970-01-01 09:01`부터 `2038-01-19 12:14`, 종료 입력은 `1970-01-01 09:00`부터 `2038-01-19 12:13`이다. 범위 바깥을 포함해 변환 instant가 저장 가능 범위를 벗어나면 HTTP 400이다. 이 범위 안의 유효한 과거 날짜는 허용된다. + +## 2. 앱 추천 조회 + +| 항목 | 계약 | +|---|---| +| Method / Path | `GET /api/v2/home/recommendations` | +| Query | 서브 배너용 query 없음. endpoint가 `HOME_RECOMMENDATION` placement를 결정한다. | +| 접근 | 익명 조회 가능. 로그인 사용자도 기존 클라이언트 헤더를 유지해 조회한다. | +| 성공 | HTTP 200, `ApiResponse` | +| `data` | 기존 홈 추천 응답 객체의 모든 필드와 `subBanners: SubBanner[]`를 포함한다. 기존 `banners` 및 추천 섹션은 유지한다. | +| `data.subBanners` | 해당 placement의 활성 배너 중 현재 노출 기간 안에 있는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 `[]`. 날짜·활성 필드는 응답하지 않는다. | + +```json +{ + "success": true, + "message": null, + "data": { + "lives": [], + "banners": [], + "recentlyActiveCreators": [], + "recentDebutCreators": [], + "firstAudioContents": [], + "aiCharacters": [], + "genreCreators": [], + "cheerCreators": [], + "popularCommunityPosts": [], + "subBanners": [{"id":123,"placement":"HOME_RECOMMENDATION","imageUrl":"https://cdn.example.test/sub-banner.jpg","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null}] + }, + "errorProperty": null +} +``` + +`GET /api/v2/audio/recommendations`도 익명 조회 가능하며 로그인 사용자는 기존 클라이언트 헤더를 유지한다. 이 endpoint는 `CONTENT_RECOMMENDATION`을 사용하고 기존 `banners`, `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios` 필드에 `subBanners: SubBanner[]`를 추가한다. `data.subBanners`에는 동일한 전체·활성·기간 필터 및 id 오름차순 규칙이 적용되며, 빈 결과는 `[]`다. 두 API 모두 기존 추천 `data`를 배너 배열로 대체하지 않으며 별도 placement query를 보내지 않는다. + +`GET /api/v2/sub-banners`는 더 이상 공개 앱 조회 경로가 아니며 별도 요청으로 사용하지 않는다. 이를 새 alias로 취급하지 않는다. 서버가 반환한 항목에 기존 OS별 링크 필터를 적용하며 날짜와 활성 상태는 클라이언트에서 판단하지 않는다. + +두 추천 GET은 Authorization 헤더 없이 200으로 조회할 수 있다. 아래 인증 오류 설명은 관리자 등 인증이 필요한 경로에 적용되며, 앱 추천 조회에 새 로그인 조건을 추가하는 것이 아니다. 서브 배너 전용 HTTP 요청·서버 fallback은 없다. + +## 3. 관리자 목록과 변경 + +관리자 경로는 `Authorization: Bearer ` 인증과 `ADMIN` 역할이 필요하다. 유효한 비관리자 요청이 반드시 403을 반환하는 것은 아니며 현재 정책 응답은 HTTP 200 `success:false`다. + +| Method / Path | 목적 | 성공 data | +|---|---|---| +| `GET /admin/sub-banners?placement={placement}` | placement별 삭제되지 않은 활성 레코드, 기간 무관. 필수 query, id 오름차순 | `AdminSubBanner[]` | +| `POST /admin/sub-banners` | 신규 생성 | 생성된 `AdminSubBanner` | +| `PUT /admin/sub-banners/{id}` | 원래 네 JSON 필드는 교체, 날짜 필드는 선택적 생략 보존 | 수정된 `AdminSubBanner` | +| `DELETE /admin/sub-banners/{id}` | 소프트 삭제 | `null` | + +삭제는 내부 `is_active` 값을 false로 변경하며 행·이미지 key·주소·기간·생성 시각과 S3 이미지는 보존한다. 생성 기본값은 true다. 삭제된 id는 관리자/공개 목록에서 제외하고, 수정·재삭제는 없는 대상과 동일하게 404다. 성공 응답은 기존 HTTP 200 및 `data:null`을 유지한다. `isActive` 요청/응답 필드나 복구·활성 토글 API는 추가하지 않는다. + +POST와 PUT은 `multipart/form-data`다. `image` 파트는 파일이며, `request` 파트는 `Content-Type: application/json`인 JSON이다. 별도 이미지 업로드 endpoint는 없다. + +```json +{ + "placement": "HOME_RECOMMENDATION", + "webUrl": "https://example.test/promo", + "androidDeepLink": "sampleapp://promo", + "iosDeepLink": null, + "startDate": "2026-11-01 09:30", + "endDate": null +} +``` + +- POST: `image` 필수. +- PUT은 기존 네 필드 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink`를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소 `null`은 해당 주소를 지운다. 이는 전체 JSON replacement라고 무조건 부르는 대신, 아래 두 날짜 필드만 예외적으로 생략 보존한다고 명시한다. +- `startDate`, `endDate`는 요청에서 각각 선택적이다. POST에서 생략 또는 null이면 무제한 경계다. PUT에서 생략하면 기존 경계를 보존하고, null이면 그 경계만 해제한다. 둘 다 보낼 필요는 없다. PUT은 날짜 두 필드를 모두 보내도 된다. +- 기간만 수정하더라도 `placement`와 세 주소는 현재 저장된 값을 그대로 보낸다. 예를 들어 현재 주소가 아래 예시와 같을 때 `{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30"}`는 생략한 `endDate`를 유지한다. 같은 주소를 유지하며 `"startDate":null,"endDate":null`을 보내면 양쪽 경계만 해제한다. 기간 변경을 위해 기존 주소를 임의로 null로 바꾸지 않는다. +- PUT에서 `image` 생략 시 기존 이미지를 유지하고, 포함 시 교체한다. +- 수정 화면은 목록 응답의 전체 객체로 채우며 별도 상세 GET은 두지 않는다. +- 파일 내용은 이미지여야 하며 MIME 감지로 검사한다. GIF를 허용한다. 빈 파일과 비이미지는 HTTP 400이다. 현재 서버 설정의 최대 파일/요청 크기는 각각 1024 MB이며, 이는 제품 권장 용량이 아니다. reverse proxy나 배포 설정은 더 낮은 제한을 둘 수 있다. + +관리자 목록에는 삭제되지 않은 활성 레코드의 미래 시작·현재 노출·종료된 기간·양쪽 null 상태가 모두 포함되며 기간으로 숨기지 않는다. 소프트 삭제된 행은 기간과 무관하게 제외한다. 같은 placement/id 오름차순을 사용한다. + +## 4. 기존 배너 예약 근거 + +예약 의미는 기존 콘텐츠 배너 예약 동작을 따른다. 날짜의 외부 API 표현은 위 형식과 경계 규칙을 따른다. + +## 5. 오류와 재시도 + +서비스가 검증하는 JSON/URI/날짜/타입/placement 오류와 업로드된 빈 파일 또는 비이미지는 HTTP 400이다. 유효한 정수 ID로 조회한 수정·삭제 대상이 없으면 HTTP 404이며, 이들 상태 오류는 표준 Spring Boot 형식 `{"timestamp":...,"status":...,"error":...,"path":...}`을 사용하고 `ApiResponse`가 아니다. 인증되지 않은 요청은 HTTP 401과 같은 표준 형식이다. 유효한 비관리자 요청은 현재 권한 정책에 따라 HTTP 200 `ApiResponse`의 `success:false`를 반환하며 403을 보장하지 않는다. `image`/`request` multipart 파트 누락 및 malformed multipart는 기존 handler에 따라 HTTP 200 `success:false`다. 프레임워크 바인딩 오류까지 모두 400이라고 가정하지 않는다. 소비자는 모든 응답에서 HTTP 상태와 `success`를 함께 확인하며 구체적인 오류 코드나 `errorProperty` 값을 가정하지 않는다. + +```json +{"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":null} +``` + +이 예시는 HTTP 200의 `success:false` 응답 예시다. 표준 Spring Boot 상태 오류의 body는 위 ApiResponse 형식이 아니다. `Idempotency-Key`는 제공되지 않는다. POST/PUT/DELETE를 자동 재시도하지 않고 중복 제출을 막는다. 결과를 알 수 없는 timeout 뒤, 특히 POST는 재시도 전 GET으로 상태를 대조한다. + +## 6. 제공 상태와 호환성 + +관리자 CRUD·소프트 삭제·두 추천 API 통합을 구현하고 최종 전체 2,682개 테스트와 lint·bootJar·독립 리뷰를 통과했다. 실제 HTTP/DB 및 단계별 실행 증거는 [plan-task.md](plan-task.md)에 있다. DDL은 독립 H2에 적용해 검증했지만 native MySQL 및 운영 DB에는 적용하지 않았다. 구현·검증 완료를 운영 배포 완료로 간주하지 않는다. + +배포 시 신규 `sub_banner` 테이블과 `is_active` 컬럼 등 현재 DDL을 먼저 준비해야 한다. 통합된 추천 조회도 해당 테이블을 사용하므로 DDL 준비 전 서버 코드를 배포하지 않는다. 실제 운영 DDL 적용과 배포는 별도 승인 대상으로 이번 작업에서는 수행하지 않는다. diff --git a/docs/20261029_가로형서브배너/app-prompt.md b/docs/20261029_가로형서브배너/app-prompt.md new file mode 100644 index 00000000..c006d2ff --- /dev/null +++ b/docs/20261029_가로형서브배너/app-prompt.md @@ -0,0 +1,102 @@ +# 앱 구현 프롬프트: 가로형 서브 배너 + +이 프롬프트는 앱 작업에 단독으로 사용할 수 있다. 현재 repository에 접근해 실제 앱 stack과 기존 화면·URL opener·API client 패턴을 먼저 확인하고 그 패턴에 맞춰 작업하라. 특정 framework를 가정하지 말고 기존 API를 변경하지 않는다. + +**상태:** 소프트 삭제와 두 추천 API 통합의 백엔드 구현·검증 완료, 미배포. 전체 테스트·lint·빌드와 독립 리뷰를 통과했다. 제공 환경을 확인하기 전 운영 연동 또는 서버 배포가 완료됐다고 가정하지 않는다. + +## 목표 + +메인 홈 추천과 메인 콘텐츠 추천 화면에 가로형 서브 배너 영역을 추가한다. 각 placement 목록에 여러 배너가 있을 수 있다. 아래 API와 데이터 계약은 구현된 계약이다. 화면 배치, 표현, 링크 우선순위, fetch 실패 시 UI 처리는 작성자 추천이며 기존 앱 패턴을 따른다. + +## API 계약 + +추천 API 통합 계약: + +- 홈: `GET /api/v2/home/recommendations`, 익명 조회 가능, `data.subBanners`의 placement는 `HOME_RECOMMENDATION`으로 고정. +- 콘텐츠 추천: `GET /api/v2/audio/recommendations`, 익명 조회 가능, `data.subBanners`의 placement는 `CONTENT_RECOMMENDATION`으로 고정. +- 비로그인 사용자는 Authorization 헤더 없이 두 GET을 조회할 수 있다. 로그인 화면으로 차단하지 않는다. 로그인 사용자는 기존 클라이언트 헤더를 그대로 유지하며, 별도 서브 배너 요청을 추가하지 않는다. +- 응답은 기존 추천 `data` 객체에 `subBanners: SubBanner[]`를 추가한 `ApiResponse`다. 기존 추천 필드와 `banners`를 보존하며 `data`를 배너 배열로 바꾸지 않는다. 서브 배너용 placement query는 보내지 않는다. +- 성공은 HTTP 200이며 envelope는 `success`, `message`, `data`, `errorProperty` 네 필드다. 클라이언트는 HTTP status와 `success`를 모두 확인한다. HTTP 200의 `success:false`도 실패다. 다른 HTTP 오류도 성공으로 처리하지 않는다. +- 배너 객체: `id` 정수(JSON number, 불투명 ID로 앱의 정밀도 안전 전략 사용), `placement` enum, `imageUrl` 절대 http/https URL, `webUrl` string|null, `androidDeepLink` string|null, `iosDeepLink` string|null. 다른 배너 필드는 가정하지 않는다. +- 공개 `SubBanner` DTO는 위 기본 필드 그대로이며 `startDate`/`endDate`/`isActive`를 추가하거나 가정하지 않는다. 서버는 활성 배너 중 현재 노출 기간 안인 항목 전체를 id 오름차순으로 `data.subBanners`에 반환하고, 결과가 없어도 배열을 `[]`로 반환한다. 앱은 그 목록에 기존 웹 URL/현재 OS 링크 필터를 적용한다. 응답 순서와 placement는 보존한다. +- 더 이상 사용하지 않을 별도 앱 요청은 `GET /api/v2/sub-banners`다. 앱에서 해당 endpoint를 호출하거나 대체용 두 번째 요청을 추가하지 않는다. 삭제된 경로의 구체적인 오류 status를 가정하지 않는다. +- 기간 판단은 서버가 맡는다. 기기 시계로 기간을 다시 계산하거나 로컬 예약 timer를 추가하지 않는다. +- 서브 배너만을 위한 별도 fetch는 없다. 추천 API의 통신 실패는 기존 추천 화면의 오류 정책으로 처리하며 정상 `subBanners:[]`로 바꾸지 않는다. 개별 배너의 이미지 로딩·링크 실행 실패는 기존 추천 콘텐츠의 표시와 분리한다. + +홈 추천 성공 응답 예시 (기존 추천 필드는 예시 데이터로 유지, 서브 배너 두 항목): + +```json +{ + "success": true, + "message": null, + "data": { + "lives": [], + "banners": [], + "recentlyActiveCreators": [], + "recentDebutCreators": [], + "firstAudioContents": [], + "aiCharacters": [], + "genreCreators": [], + "cheerCreators": [], + "popularCommunityPosts": [], + "subBanners": [ + {"id": 101, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/one.jpg", "webUrl": "https://example.test/one", "androidDeepLink": "sampleapp://one", "iosDeepLink": null}, + {"id": 102, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/two.jpg", "webUrl": null, "androidDeepLink": null, "iosDeepLink": "sampleapp://two"} + ] + }, + "errorProperty": null +} +``` + +빈 목록 응답: + +```json +{"success":true,"message":null,"data":{"lives":[],"banners":[],"recentlyActiveCreators":[],"recentDebutCreators":[],"firstAudioContents":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[],"subBanners":[]},"errorProperty":null} +``` + +콘텐츠 추천도 기존 `data` 구조를 유지한다. 빈 데이터 예시는 다음과 같으며, 배너가 있으면 `subBanners`에 `CONTENT_RECOMMENDATION` 항목이 들어간다. + +```json +{"success":true,"message":null,"data":{"banners":[],"subBanners":[],"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[],"pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[]},"errorProperty":null} +``` + +## 표시 규칙 + +- 서버 응답의 id 오름차순은 유지한다. 아래 UI 선택은 작성자 추천이다: 모든 표시 대상 항목을 수직으로 나열하고 새 slider/autoplay는 추가하지 않는다. +- placement별 화면 내 정확한 위치는 기존 화면을 살펴 정한다. main banner 뒤에 배치하는 것은 작성자 추천이며 확정된 API 동작이 아니다. +- 작성자 추천: 이미지 원본 비율에 맞춰 표시하고, 필터 결과가 없을 때 영역과 여백을 숨긴다. 기존 화면 레이아웃에 맞게 적용하며 이를 API 계약으로 간주하지 않는다. +- 표시 조건은 대상 OS 딥링크 또는 웹 URL 보유다. 대상 OS 딥링크가 있어도 앱 설치/launch 가능 여부로 사전 필터링하지 않는 것은 작성자 추천이다. +- 기간 필터가 적용된 서버 결과에 기존 OS별 링크 필터를 그대로 적용한다. 공개 날짜 필드를 읽거나 클라이언트에서 기간을 판단하지 않는다. + +## 링크 처리와 보안 + +작성자 추천: 탭 시 대상 OS 딥링크가 있으면 우선 기존 플랫폼 URL opener로 열고, 없으면 웹 URL을 연다. 딥링크 시도 실패 시 웹 URL이 있으면 한 번 대체 시도할 수 있다. 다른 OS 딥링크를 대체 수단으로 쓰지 않는다. 구체적인 우선순위와 실패 피드백은 기존 앱 링크 처리 패턴을 따른다. + +웹 URL은 절대 `http`/`https` URL이며 host가 필요하다. 딥링크는 절대 URI이고 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 클라이언트에서도 임의 입력을 실행하거나 문자열 연결/eval하지 않는다. URI 원문은 재인코딩·정규화하지 않는다. 네트워크 응답은 외부 입력으로 검증한다. + +## 링크 존재 조합 확인표 (작성자 추천) + +`있음`은 해당 주소가 nonblank임을 뜻한다. 아래 탭 동작은 작성자 추천이며 UI 의무 계약이 아니다. + +| web | Android | iOS | 유효 표시 대상 | Android 탭 | iOS 탭 | +|---|---|---|---|---|---| +| 없음 | 없음 | 없음 | 없음 | 표시 안 함 | 표시 안 함 | +| 있음 | 없음 | 없음 | 양쪽 | 웹 | 웹 | +| 없음 | 있음 | 없음 | Android | 딥링크 | 표시 안 함 | +| 없음 | 없음 | 있음 | iOS | 표시 안 함 | 딥링크 | +| 있음 | 있음 | 없음 | 양쪽 | 딥링크 우선, 실패 시 웹 | 웹 | +| 있음 | 없음 | 있음 | 양쪽 | 웹 | 딥링크 우선, 실패 시 웹 | +| 없음 | 있음 | 있음 | 양쪽 | Android 딥링크 | iOS 딥링크 | +| 있음 | 있음 | 있음 | 양쪽 | 딥링크 우선, 실패 시 웹 | 딥링크 우선, 실패 시 웹 | + +## 수용 및 검증 + +실제 앱 구조에 맞는 테스트를 추가한다. 최소 검증 범위: + +- 모든 항목 표시, 응답 순서 유지, 홈/콘텐츠 placement 격리, 빈 목록에서 gap 없음. +- URI 입력을 보존하면서 대상 OS 링크 우선, OS 링크 부재 시 웹, 딥링크 실패 시 웹 fallback 한 번, 양쪽 실패 시 비치명 상태. +- 다른 OS 링크를 실행하지 않고 잘못된 scheme을 거부한다. +- 비로그인 두 GET의 `data.subBanners`를 소비하며 별도 배너 요청이 발생하지 않는다. 추천 API 오류와 정상 빈 배열을 구분하고, 개별 이미지·링크 오류는 다른 추천 콘텐츠를 깨뜨리지 않는다. +- fixture 검증은 명시 모드에 한정되고 정상 운영 요청에 자동 적용되지 않는다. + +최소 변경으로 구현하고 관련 focused test, 프로젝트 type/lint 검사, 실제 화면에서 두 영역·빈 상태·tap 동작을 확인한다. 서버나 API를 구현하지 말고, 서버 준비를 완료했다고 보고하지 않는다. diff --git a/docs/20261029_가로형서브배너/prd.md b/docs/20261029_가로형서브배너/prd.md new file mode 100644 index 00000000..5d90028f --- /dev/null +++ b/docs/20261029_가로형서브배너/prd.md @@ -0,0 +1,152 @@ +# 가로형 서브 배너 백엔드 요구사항 + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 소프트 삭제·두 추천 API 통합 및 전체 검증 완료, 미배포 | +| 작성일 | 2026-10-29 | +| 최종 수정일 | 2026-10-29 | +| 대상 기능 | 가로형 서브 배너 백엔드 및 영속성 | +| 관련 API 계약 | [api-contract.md](api-contract.md), 구현·실제 HTTP 검증 기준 | +| 구현 계획 | [plan-task.md](plan-task.md) | + +## 요구사항 상태와 현재 승인 범위 + +- 기존 `USER REQUIREMENTS`는 사용자가 확정한 제품 요구이며, 아래 `USER REQUIREMENTS`와 `DEC-001`, `DEC-005`에 보존한다. +- 초기 API·저장 모델 제안은 `DEC-009`의 사용자 구현 승인에 따라 구현했으며 실제 검증한 계약은 `api-contract.md`에 기록한다. 앱 레이아웃·링크 우선순위 등 UI 추천은 백엔드 구현 완료와 구분한다. +- **현재 승인 범위는 문서대로 백엔드 코드와 DDL 파일을 생성하고 검증하는 것이다.** 운영 DB 적용·배포·앱/관리자 UI 구현은 제외한다. 앞선 문서-only 승인 이력은 Decision Log에 보존한다. +- API 및 저장 모델은 이 문서와 API 계약을 기준으로 구현하되 완료·검증·배포 상태는 구분한다. 소비자 프롬프트는 실제 구현 계약과 다른 설명이 남지 않도록 완료 시 상태를 맞춘다. + +## USER REQUIREMENTS + +- 가로형 서브 배너 기능이 필요하다. +- 관리자는 배너를 생성, 조회, 수정, 삭제할 수 있어야 한다. +- 웹 URL, Android 딥링크, iOS 딥링크는 각각 선택적으로 등록하며 적어도 하나가 있어야 한다. 정확히 하나만 허용하는 XOR 조건은 아니다. 세 값이 모두 없는 조합은 거부하고 나머지 7개 존재 조합은 허용한다. +- 노출 위치는 메인 홈 추천과 메인 콘텐츠 추천이며, 각 위치에 여러 배너를 조회할 수 있어야 한다. +- 앱은 서버가 반환한 배너 중 웹 URL 또는 현재 대상 OS 링크가 있는 항목을 표시한다. +- 관리자는 기존 콘텐츠 배너 예약 기간과 같은 선택적 시작·종료 기간을 설정할 수 있다. +- 삭제는 `is_active`를 이용한 소프트 삭제로 수행한다. 생성 기본값은 true이며 삭제 시 false로 변경해 행과 기존 데이터를 보존한다. +- 앱 조회는 기존 홈 추천·콘텐츠 추천 API의 `data.subBanners` 배열로 제공하며 두 GET 모두 비로그인 접근을 유지한다. 별도 앱 조회 `/api/v2/sub-banners`는 제거한다. 앱 프롬프트도 이 계약으로 갱신한다. + +## 초기 제안과 구현 결정 + +- 한 레코드에 한 placement를 저장하고 같은 소재를 두 곳에 노출하려면 각각 등록한다. `DEC-003`의 초기 제안을 구현에 적용했으며 최초 사용자 발화로 소급하지 않는다. +- 주소는 trim 후 빈 문자열을 null로 정규화하고 URI 원문을 보존한다. `DEC-002`의 입력 처리 제안을 구현했다. +- 독립 테이블, 서버 생성 이미지 key 및 이미지 검증을 유지한다. 초기 DB row 물리 삭제는 `DEC-010`에 따라 `is_active=false` 소프트 삭제로 변경한다. 이미지의 기존 S3 object 자동 삭제는 제공하지 않는다. + +## 목표와 비목표 + +### 목표 + +- **G1:** 독립 테이블과 백엔드 도메인으로 두 placement의 배너 레코드를 격리하고, 생성·조회·수정·삭제 후 DB 재조회에서도 데이터가 보존된다. (`SB-001`~`SB-003`, `SB-010`) +- **G2:** 관리자 API는 ADMIN만 사용하고 이미지 multipart 포함 CRUD를 제공하며, payload 검증이 업로드와 영속화보다 먼저 이뤄진다. (`SB-004`~`SB-007`, `SB-011`) +- **G3:** 공개 GET은 placement와 유효 기간으로 필터링하고 전체 목록을 id 오름차순으로 반환하며, 날짜 없는 공개 DTO와 공통 응답 envelope를 유지한다. (`SB-008`~`SB-009`) +- **G4:** 배포 DDL은 적용·롤백 영향을 명시하며 기존 콘텐츠 배너 테이블과 데이터를 변경하지 않는다. (`SB-010`) + +### 포함 범위 + +- Kotlin/Spring 도메인, application service, persistence adapter와 DTO/controller. +- 공개 단일 GET 및 관리자 CRUD endpoint, 보안 matcher, 이미지 업로드 연동. +- 신규 테이블 생성용 SQL artifact와 테스트, 시간 정밀도 및 접근 제어 검증. + +### 제외 범위 + +- 앱 및 관리자 실제 UI 구현, 기존 대형 콘텐츠 배너 변경, 예약 스케줄러/job, 순서 편집, 별도 활성 토글·삭제 복구 UI/API, 분석. +- 새 라이브러리, 공통 예외 처리기 전면 변경, 배포 실행, 운영 DB 직접 변경. +- 기존 콘텐츠 배너 테이블 migration. 새 서브 배너 전용 테이블만 제안한다. +- idempotency key, pagination, 자동 재시도 또는 요청 간 exactly-once 보장. + +## 대상 사용자와 권한 + +| 주체 | 허용 동작 | 거부 조건 | +|---|---|---| +| 익명 앱 클라이언트 | 지정 placement의 공개 GET | 다른 HTTP method 및 인접 경로는 기존 인증 정책 적용 | +| `ADMIN` | 모든 관리자 CRUD | 인증되지 않았거나 ADMIN이 아닌 요청은 변경 전 거부 | + +## 기능 요구사항 + +아래 요구사항은 `DEC-009`의 구현 승인에 따라 완료했으며 각 행을 [plan-task.md](plan-task.md)의 실행 Goal·검증 증거에 연결한다. 상태 열의 초기 제안 표기는 요구사항 출처이며 현재 미구현을 뜻하지 않는다. 최종 wire 계약은 `api-contract.md`를 따른다. + +| ID | 상태 | 요구사항 | 수용 기준 | 계약 / Goal | +|---|---|---|---|---| +| `SB-001` | 작성자 영속성 제안 | 서브 배너는 독립적인 테이블에 저장한다. placement는 `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` 하나다. | 신규 레코드가 제안 SQL의 독립 테이블에서 저장/조회되고, 기존 콘텐츠 배너 테이블에는 schema/data 변경이 없다. | §1, `P2-T1` | +| `SB-002` | API 형태 제안, 최소 주소 조건 확정 | 응답 객체는 id, placement, imageUrl, 세 nullable 링크를 가진다. 내부 저장 모델은 CDN URL 대신 object key인 imagePath를 보관한다. | 8개 존재 조합 중 모두 null인 경우는 거부하고 나머지 7개 조합은 허용한다. 관리자와 공개 DTO에만 imagePath를 절대 imageUrl로 조립한다. | §1, `P2-T1`, `P3-T1` | +| `SB-003` | CRUD 확정, 응답·정렬 제안 | 관리자는 placement별 활성 목록, 생성, 수정, 삭제를 수행한다. | GET은 삭제되지 않은 행을 기간과 무관하게 id 오름차순으로 반환한다. 생성/수정은 저장된 객체, 삭제는 `data:null`로 응답한다. | §3, `P3-T1`, `P5-T1` | +| `SB-004` | 최소 주소 조건 확정, 정규화·URI 안전 규칙 제안 | 세 링크는 각각 nullable이며 trim 후 최소 하나가 nonblank여야 한다. | 모든 8개 존재 조합을 검사하며 모두 null/blank인 경우만 거부한다. 공백뿐인 값은 null 처리한다. nonblank 값은 trim 외 원문 byte/문자열을 재인코딩하거나 정규화하지 않는다. web URL은 absolute http/https, 딥링크는 유효한 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부한다. 별도의 임의 scheme allowlist는 추가하지 않는다. | §1, `P3-T1` | +| `SB-005` | 작성자 안전 제안 | 이미지가 필수인 생성과 선택적 이미지 교체 수정을 지원한다. | MIME/type 검증 및 모든 request 검증이 S3 업로드와 DB 변경 전에 끝난다. 업로드 실패 시 기존 이미지 값은 바뀌지 않는다. DB rollback이 이미 완료된 S3 업로드를 되돌린다고 가정하지 않는다. | §3, `P3-T1` | +| `SB-006` | 사용자 확정 | 선택적 `startDate`, `endDate`를 독립적으로 설정·조회·수정·해제한다. | KST `yyyy-MM-dd HH:mm`만 받고 응답한다. 생성 누락/null은 unbounded, 수정 누락은 유지, 수정 null은 해당 경계 해제다. 병합 후 날짜 역전은 저장하지 않는다. | §1, §3, `P2-T1`, `P3-T1` | +| `SB-007` | 확정 요구, 상태 매핑은 기존 정책 확인 필요 | 관리자 GET/POST/PUT/DELETE는 ADMIN 권한이어야 한다. | 익명 및 non-ADMIN이 CRUD를 시도해도 데이터가 변하지 않는다. 테스트는 실제 공통 접근 거부 handler의 HTTP/envelope 동작을 확인하고 임의로 403을 가정하지 않는다. | §3, `P3-T1`, `P4-T1` | +| `SB-008` | 사용자 후속 확정 | 두 추천 API가 각 화면 placement의 활성·기간 내 전체 서브 배너를 함께 반환한다. | 비로그인 `GET /api/v2/home/recommendations`는 HOME_RECOMMENDATION, `GET /api/v2/audio/recommendations`는 CONTENT_RECOMMENDATION의 `data.subBanners`를 반환한다. id 오름차순·빈 배열·기간 경계·무절단 규칙은 유지하고 별도 앱 조회 endpoint를 제거한다. | §2, `P6-T1` | +| `SB-009` | 사용자 후속 확정 | 기존 추천 응답에 `subBanners`만 추가하고 다른 필드는 보존한다. | 각 배열 항목은 기존 6필드 `{id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink}`다. 기존 `banners`와 추천 콘텐츠를 교체하지 않는다. 날짜·isActive는 노출하지 않으며 OS 필터는 앱 책임이다. | §1, §2, `P6-T1` | +| `SB-010` | 작성자 데이터 안전 제안 | 신규 테이블 DDL과 적용 전후 안전성을 제공한다. | `docs/20261029_가로형서브배너/create-sub-banner.sql`에 모든 column/table COMMENT, nullable UTC `TIMESTAMP` 기간 컬럼, 필요 시 기존 audit timestamp 관례를 포함한다. 계획에 적용 조건과 rollback 절차를 적고 자동 live DDL은 하지 않는다. | §1, `P2-T1` | +| `SB-011` | 작성자 API/오류 제안 | 성공 envelope와 계약된 응답 shape를 일관되게 유지한다. | 성공은 `ApiResponse` 4필드, HTTP 200, 관리자 빈 목록 `[]`, 관리자 POST/PUT 객체, DELETE `null`, 공개 날짜 미포함. 잘못된 입력/없음 상태의 status는 API 계약 검토 후 기존 handler를 활용하거나 endpoint 경계에서 처리한다. | `api-contract.md` §1, §5, `P3-T1`, `P4-T1` | +| `SB-012` | 사용자 후속 확정 | 관리자 삭제는 `is_active=false` 소프트 삭제다. | DB 행·이미지 key·주소·기간·생성 시각을 보존한다. 관리자/공개 목록과 id 조회에서 비활성 행을 제외하며 삭제된 id 수정·재삭제는 404다. 응답에 isActive를 추가하지 않고 S3는 보존한다. 복구/활성 토글/cleanup은 범위 밖이다. | §3, `P5-T1` | + +## API 및 데이터 계약 + +- 앱 조회: `GET /api/v2/home/recommendations`, `GET /api/v2/audio/recommendations`의 `data.subBanners` 배열. 비로그인 허용, 화면별 placement 고정, 페이지네이션 없음, id 오름차순. 별도 `/api/v2/sub-banners`는 제거한다. +- 관리자: `GET/POST /admin/sub-banners`, `PUT/DELETE /admin/sub-banners/{id}`, ADMIN 전용. GET은 필수 placement query. +- 성공은 HTTP 200과 공통 `ApiResponse` `{success,message,data,errorProperty}`. POST/PUT multipart에는 `image`와 JSON `request`; POST image 필수, PUT image 선택. PUT의 기존 네 필드는 필수 replacement이고 날짜만 선택적 보존/해제다. +- 두 추천 응답의 `subBanners` 항목은 기본 6필드, 관리자 DTO는 날짜 문자열 두 필드를 더한다. 추천 응답의 기존 `banners` 등 모든 기존 필드는 유지한다. +- 관리자 목록은 삭제되지 않은 활성 행의 모든 기간 상태를 반환한다. 공개 조회는 활성 행에 현재 기간 필터를 추가하며 앱에서 로컬 시간으로 기간 판정하지 않는다. +- 입력 검증과 없는 id는 `ResponseStatusException`을 통해 400/404 Boot 오류 body를 반환한다. 익명은 401, 유효한 비관리자 및 multipart 바인딩 오류는 기존 handler의 200 `success:false`다. 공통 예외 정책을 변경하지 않았다. + +## 시간·검증·저장 규칙 + +- 입력 시각은 엄격한 KST `yyyy-MM-dd HH:mm` 분 정밀도만 허용한다. 초, offset, `Z`, 공백/빈 문자열, raw scalar의 문자열 외 타입, 잘못된 달력 날짜 및 병합 후 start > end는 저장/업로드 전에 거부한다. +- 시작 분은 `:00`, 종료 분은 `:59`까지 포함한다. 동일 분 경계는 유효하다. null 경계는 독립적으로 unbounded다. +- 저장 경계는 KST 입력에서 한 번 정규화한 UTC 초 단위 값이며, 그 값을 영속화와 관리자 응답 변환에 공통 사용한다. DB flush/clear/reload 후 시작 `:00`·종료 `:59`와 관리자 날짜 문자열을 정확히 왕복 비교한다. 공개 조회의 현재 시각은 한 번만 얻어 초 단위로 절삭한다. 이 시각 seam에 나노초 `0`, `123456789`, `999999999` 및 날짜 경계를 넣어 종료 분 마지막 초와 다음 초의 판정을 확인한다. 분 형식 API에 소수초 입력을 허용하는 것은 아니다. +- 링크 입력은 JSON 원문 유형 확인 후 trim 처리한다. nonblank URI는 trim 이후 원문을 보존한다. 기존 Tika 기반 `validateImage(file, gifAllowed = true)`를 호출해 이미지 내용을 검사하고 GIF를 허용한다. 빈 파일은 거부한다. 신규 dependency나 임의 추가 크기 제한은 없다. +- 현재 `application.yml` multipart 제한은 파일 및 요청 각각 1024MB다. 이를 새 제품 상한으로 해석하거나 몰래 변경하지 않는다. +- `S3Uploader.upload(InputStream, bucket, filePath, metadata?)` 반환은 object key다. `imageUrl` 생성 시 기존 CDN 변환 관례를 사용하며 object key를 URL로 오인해 응답하지 않는다. 저장 실패 뒤 이미 업로드된 S3 객체는 자동 삭제/rollback을 보장하지 않으며 cleanup job은 범위 밖이다. +- 삭제는 `is_active=false`만 반영하고 DB 행과 S3 object를 보존한다. 관리자 목록은 활성 행의 모든 기간 상태를, 공개 목록은 활성 행 중 기간 내 항목만 반환한다. + +## 근거와 제안 경계 + +현행 동작 근거: + +- 추천 배너 구조: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`, `.../v2/api/home/adapter/in/web/HomeRecommendationController.kt`, `.../v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`. +- 기존 관리자 배너: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerController.kt`, `AdminContentBannerService.kt`, `GetAdminContentBannerResponse.kt`. +- 예약 기간 규칙: `docs/20261027_콘텐츠배너예약기간/prd.md` §6-8, `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt`, `docs/20261027_콘텐츠배너예약기간/alter-content-banner-reservation.sql`. +- 경량 헥사고날 패키지와 권한: `docs/agent-guides/코드스타일.md`, `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`. +- 응답/예외 사실: `src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt`, `SodaExceptionHandler.kt`. +- 이미지 업로드/검증: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt`. +- 테스트 관례: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt`, `AdminContentBannerServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`. + +신규 `v2.subbanner` 경로와 SQL은 구현되어 있다. 기존 `v2.creatorname` 패턴을 따른 단일 독립 기능 모듈이며 불필요한 API composition 모듈이나 utility wrapper를 추가하지 않았다. + +## 수용 기준 + +- [x] `SB-001`~`SB-012` 각각을 구현 Goal·테스트·Gate 증거로 연결했다. +- [x] 신규 테이블, 기간 및 기본 CRUD는 기존 콘텐츠 배너 schema/data를 변경하지 않는다. +- [x] 관리자 기간 경계를 실제 DB flush/clear/reload와 HTTP 날짜 왕복으로 정확히 검증했다. +- [x] 익명 public GET만 permitAll이며 인접 경로·다른 method·관리자 권한 보호를 실제 HTTP로 검증했다. +- [x] payload 검증 실패의 이미지 upload/DB mutation 0회와 실제 오류 응답을 검증했다. +- [x] 전체 2,676 tests, lint, bootJar 및 독립 최종 리뷰 통과. 운영 DB 적용·배포는 별도이며 완료로 표시하지 않는다. +- [x] 후속 P5·P6는 전체 2,682 tests(실패·무시 0), lint·bootJar와 독립 리뷰를 통과했다. 비로그인 두 추천 응답의 subBanners와 standalone handler 제거, 소프트 삭제 행 보존 및 동시 비복원을 실제 HTTP/DB로 검증했다. 앞선 2,676건은 초기 구현 이력이다. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | +|---|---|---|---|---| +| 2026-10-29 | DEC-001 | 사용자 확정 | 가로형 서브 배너, 관리자 CRUD, 세 nullable 주소 중 최소 하나, 두 추천 placement, 각 목록 다중 항목, 웹 URL 또는 대상 OS 딥링크가 있는 앱 항목 표시 | 사용자 확인 | +| 2026-10-29 | DEC-002 | 작성자 제안 | trim 후 blank를 null로 만들고 nonblank URI를 변형하지 않음 | 입력 안전성 제안, 승인되지 않음 | +| 2026-10-29 | DEC-003 | 작성자 제안 | API 경로·모델·정렬 순서·단일 placement 레코드 정책 | 일관된 최소 계약 제안, 승인 및 서버 제공 대기 | +| 2026-10-29 | DEC-004 | 작성자 제안 | 대상 OS 딥링크 우선 및 실패 시 웹 URL fallback | 앱 이동 정책 제안, 승인되지 않음 | +| 2026-10-29 | DEC-005 | 사용자 확정 | 관리자에서 콘텐츠 배너와 같은 선택적 노출 기간을 설정한다. KST 분 형식 및 경계 의미는 기존 콘텐츠 배너 예약 동작을 따른다. | 사용자 확인 및 기존 콘텐츠 배너 근거 | +| 2026-10-29 | DEC-006 | 사용자 승인, 범위 제한 | 이번 승인으로 기존 디렉터리의 PRD와 plan-task를 백엔드 요구사항 및 구현 계획으로 완성한다. 이번 변경은 문서 전용이며 제안 API 전체 승인이나 실제 구현 승인이 아니다. | 사용자 요청의 현재 작업 승인 범위 | +| 2026-10-29 | DEC-007 | 작성자 구현 권고, 검토 필요 | 독립 `v2.subbanner` 모듈과 제안된 신규 테이블을 사용한다. 이미 업로드된 S3 객체의 DB rollback 후 자동 정리는 보장하지 않으며 삭제 정책은 첫 구현 Task에서 확인한다. | 현행 코드 패턴과 S3/DB transaction 한계. 사용자 제품 결정으로 간주하지 않음 | +| 2026-10-29 | DEC-008 | 사용자 승인 | 문서 전용 작업의 검증은 문서 대조와 공백 검사로 한정하고 Gradle·테스트·빌드는 실행하지 않는다. 향후 구현용 명령을 문서에 적는 것만으로 실행 검증을 요구하지 않는다. | 사용자 승인 및 `docs/agent-guides/문서유지보수.md`의 검증 지침 수정 | +| 2026-10-29 | DEC-009 | 사용자 구현 승인 | 문서대로 백엔드 코드와 DDL 파일을 구현한다. 운영 DB 적용·배포는 승인 범위에 포함하지 않는다. 기존 기간·독립 API 모델을 사용하며 검증 시 H2·테스트 JWT·S3 대체 객체로 외부 부작용을 격리한다. | 후속 사용자 요청 및 ulw 실행 | +| 2026-10-29 | DEC-010 | 사용자 후속 확정·정정 | `is_active BOOLEAN NOT NULL DEFAULT TRUE`를 추가하고 DELETE는 false 갱신으로 변경한다. 삭제된 행은 모든 소비자 조회에서 제외하며 응답·권한·기간·S3 정책은 유지한다. 앞선 하드 삭제 완료 기록은 당시 이력으로 보존한다. | "is_active를 두고 소프트 삭제로 하자" | +| 2026-10-29 | DEC-011 | 사용자 후속 확정·정정 | 별도 앱 GET을 제거하고 두 추천 GET에 `data.subBanners`를 추가한다. 두 GET 모두 비로그인 접근을 유지하고 앱 프롬프트도 함께 변경한다. 관리자 CRUD·소프트 삭제·기간·응답 항목은 유지한다. | 두 추천 API 통합 및 앱 프롬프트 수정 요청 | + +## 후속 사용자 요구사항: 관리자 노출 기간 + +- 관리자는 가로형 서브 배너의 선택적 `startDate`, `endDate`를 설정·조회·수정·해제할 수 있다. +- 두 경계는 독립적 nullable 값이다. 둘 다 null이면 기간 제한이 없고, 시작만 있으면 시작 시각부터, 종료만 있으면 종료 경계까지 노출한다. 같은 분의 시작/종료도 유효하다. +- 관리자 입력과 응답은 `Asia/Seoul`의 `yyyy-MM-dd HH:mm` 문자열이며 분 정밀도다. 시작 분은 `:00`부터 포함하고 종료 분은 `:59`까지 포함한다. 초, `Z`, offset은 받지 않는다. +- 공개 조회는 현재 기간 안에 있는 항목 전체를 반환한다. 앱은 그 결과에 기존 웹 URL/현재 OS 딥링크 필터를 적용하며 앱 로컬 시계로 기간을 계산하지 않는다. +- 공개 DTO에는 날짜·활성 필드를 추가하지 않는다. 관리자 목록은 삭제되지 않은 활성 행의 미래·현재·만료·무제한 상태를 모두 포함한다. +- 기존 근거는 `docs/20261027_콘텐츠배너예약기간/prd.md` §6-8, `AdminContentBannerService.kt` 및 `GetAdminContentBannerResponse.kt`다. 신규 계약의 승인이나 구현을 뜻하지 않는다. From bafa922242e43085c5de75b1e10320a1766286f1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:24:30 +0900 Subject: [PATCH 12/13] =?UTF-8?q?docs(subbanner):=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EA=B3=BC=20=EB=8B=A8=EA=B3=84=EB=B3=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=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 --- docs/20261029_가로형서브배너/plan-task.md | 547 ++++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 docs/20261029_가로형서브배너/plan-task.md diff --git a/docs/20261029_가로형서브배너/plan-task.md b/docs/20261029_가로형서브배너/plan-task.md new file mode 100644 index 00000000..3dd5b2bf --- /dev/null +++ b/docs/20261029_가로형서브배너/plan-task.md @@ -0,0 +1,547 @@ +# 가로형 서브 배너 백엔드 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | P5 소프트 삭제·P6 추천 통합 구현 및 전체 검증 완료, 미배포 | +| 작성일 | 2026-10-29 | +| 요구사항 기준 | [prd.md](prd.md) | +| API 기준 | [api-contract.md](api-contract.md), 실제 구현·HTTP 검증 계약 | +| 현재 Phase | Phase 2~6 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +두 placement의 서브 배너를 독립 테이블과 백엔드 API로 제공한다. 기존 문서 준비 Goal `DOC-T1`~`DOC-T5`는 완료된 역사적 문서 작업이다. 이후 사용자가 문서대로 백엔드 코드 및 DDL 생성을 승인했으며, 운영 DB 적용·배포는 범위 밖이다. + +## 범위 + +### 포함 + +- `prd.md`의 요구사항 요약과 결정 상태 기록 +- `api-contract.md`의 실제 API 계약 +- 독립 `v2.subbanner` 기능 모듈·DDL·관리자 CRUD·소프트 삭제 및 기존 두 추천 GET 응답 합성 +- 현재 구현 작업은 신규 subbanner 코드·테스트·DDL 파일 생성과 실제 계약 문서 동기화를 포함한다. 이전 문서 전용 작업은 별도 완료 이력으로 보존한다. + +### 제외 + +- **현재 제외:** 운영 DB DDL 적용·배포. 코드 변경이 포함된 이번 구현에서는 테스트·lint·빌드를 실행했다. 문서 전용 변경에 대한 Gradle 생략 규칙은 유지한다. +- **향후 구현 범위 밖:** 앱/관리자 실제 UI, 기존 대형 배너 변경, scheduler/job, 순서 편집, 활성화 switch, analytics +- 배포, 운영 DB 직접 변경, 기존 콘텐츠 배너 schema migration +- 운영 배포가 완료됐다는 주장 +- 신규 dependency, global exception rewrite, 공통 utility wrapper +- 앱/client network 통합은 backend 구현 후 별도 계약 검토와 제공이 끝날 때까지 차단한다. backend를 알 수 없는 외부 block으로 취급하지 않는다. + +## 문서 변환 Goal + +### `DOC-T5` 백엔드 계획 문서 변환 + +**Goal 실행 `DOC-T5`:** 사용자 승인 범위에서 PRD와 계획 문서만 backend 요구사항 및 향후 구현 계획으로 변환한다. + +- **시작 조건:** `DOC-T1`~`DOC-T4`의 기존 문서 결과가 있고 사용자가 backend 문서 보완과 문서 검증 지침 수정을 승인했다. +- **완료 증거:** 지정된 기존 문서 세 파일만 수정, 신규 backend Goal은 전부 미체크, 과거 DOC 체크박스/Gates/Progress와 이전 Gradle 증거 보존. +- **범위 밖:** API 구현 승인, 코드·SQL 신규 파일 작성, Gradle/test/build 실행, 앱·관리자 prompt 변경. +- **TDD 예외 사유:** 문서 전용 변경이다. +- **대체 검증 방법:** PRD 요구 ID↔Goal 추적, 정확한 path/signature 일치, 과거 완료 기록과 미실행 검증 상태를 수동 대조한다. +- [x] PRD와 backend Phase/Goal 초안 작성 완료. +- [x] 부모 작업자가 문서 대조·공백 검사 및 범위를 확인했다. 아래 `DOC-T5` 최종 검증 기록을 따른다. + +## 백엔드 구현 공통 제약 + +- 기술 기준은 저장소의 Kotlin·Java 17·Spring Boot 2.7.14·JPA/QueryDSL·JUnit 5와 Gradle Wrapper를 그대로 사용한다. 버전·dependency 변경은 계획에 포함하지 않는다. +- 서브 배너 도메인·관리자는 `v2/subbanner`에 두고 기존 `v2/api/home` 및 `v2/api/content/recommendation`의 facade/DTO에서 응답을 합성한다. 대응 테스트 경로도 같은 책임을 따른다. +- 데이터 계층은 한 개의 독립 기능 모듈로 유지한다. `SubBannerController`는 P6에서 제거했고, `AdminSubBannerController`와 `SubBannerService`·persistence·6필드 public DTO는 재사용한다. 단일 클래스 파일명은 ktlint 규칙을 따른다. +- 현재 앱 조회는 `GET /api/v2/home/recommendations`, `GET /api/v2/audio/recommendations`의 `data.subBanners`다. 관리자 경로는 `/admin/sub-banners`를 유지한다. 이전 Phase의 standalone GET 기록은 당시 구현 이력이며 현재 계약은 [api-contract.md](api-contract.md)를 따른다. +- 파일/링크/날짜 입력을 경계에서 검사한다. 오류는 기존 `ApiResponse`/`SodaExceptionHandler` 사실을 확인하고 global handler는 고치지 않는다. +- DDL은 `docs/20261029_가로형서브배너/create-sub-banner.sql`이며 P5에서 is_active 기본 true를 추가했다. 테스트용 Flyway/자동 live migration을 만들지 않는다. +- 기존 S3Uploader, 이미지 검증기 및 QueryDSL/JPA를 재사용한다. GIF는 허용하며 S3 object 자동 삭제/정리는 제공하지 않는다. 새 dependency는 추가하지 않았다. +- 시간은 KST 분 입력→UTC 초 단위 DB 값→KST 분 응답이다. `TIMESTAMP` DDL precision을 확인하고 service에서 한번 얻은 `now`를 초 단위로 정규화한다. +- 각 단계의 원래 명령은 계획이며 실제 실행 결과는 해당 Progress를 따른다. 문서 전용 변경은 Gradle을 실행하지 않고, 코드 변경의 Gate는 테스트·lint·빌드 증거로 판정한다. + +## Historical Phase 1: 문서 작성 완료 기록 + +아래 `DOC-T1`~`DOC-T4`, Gate와 Progress는 기존 문서 작업 이력이다. 새 backend Phase의 완료 상태나 구현 착수 근거가 아니다. + +**Phase 결과:** 요구사항, 제안 API와 두 독립 구현 프롬프트가 서로 일치하는 문서 묶음. + +### Task 1.1 요구사항과 준비 계획 + +**Goal 실행 `DOC-T1`:** 샘플에 맞춰 PRD와 문서 작성 계획을 작성한다. + +- **시작 조건:** 사용자 확정 요구, 프로젝트 문서 가이드 및 샘플 확인. +- **완료 증거:** 정확한 파일 두 개 작성, `USER REQUIREMENTS`와 `AUTHOR PROPOSALS` 구분. +- **범위 밖:** 코드 구현, 추가 계획 문서. +- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다. +- **대체 검증 방법:** 요구사항 표와 문서 가이드 대조. + +**Files:** `docs/20261029_가로형서브배너/prd.md`, `docs/20261029_가로형서브배너/plan-task.md` + +- [x] 요구사항, 비목표, 외부 의존, 결정 기록 작성. +- [x] 구현 계획을 문서 준비 범위로 한정. + +### Task 1.2 계약 및 독립 프롬프트 + +**Goal 실행 `DOC-T2`:** API 제안과 독립 실행 가능한 앱·관리자 프롬프트를 작성한다. + +- **시작 조건:** `DOC-T1` 문서 완료. +- **완료 증거:** 요청된 세 파일 작성, API 필드/라우트/오류 상태가 PRD와 일치, 각 prompt에 필요한 계약을 자체 포함. +- **범위 밖:** 서버 구현 지시, 실 API 연결, 특정 앱/admin 코드베이스 존재 가정. +- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다. +- **대체 검증 방법:** PRD/API/prompt 사이 필드, 인증, 주소 처리, 차단 조건 교차 점검. + +**Files:** `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/app-prompt.md`, `docs/20261029_가로형서브배너/admin-prompt.md` + +- [x] endpoint와 전송 형식을 하나의 최소 제안으로 고정한다. +- [x] 두 prompt를 별도 사용 가능하게 만들고 백엔드 구현 지시를 제외한다. +- [x] 콘솔에 prompt 본문을 출력하지 않는다. + +### Task 1.3 출처 표기 및 독립 사용성 보정 + +**Goal 실행 `DOC-T3`:** 초안 검토에서 발견한 확정 요구·제안 혼용을 고치고 프롬프트의 독립 사용성을 검증한다. + +- **시작 조건:** `DOC-T2` 작성 완료와 부모 검토. +- **완료 증거:** 제안 상태, 응답 예시, 주소 조합표, URL 검증 및 순서 설명을 부모가 직접 대조. +- **범위 밖:** 새로운 제품 결정, 서버 구현 및 실제 앱·관리자 연결. +- **Files:** 같은 디렉터리의 `prd.md`, `api-contract.md`, `app-prompt.md`, `admin-prompt.md`. +- **TDD 예외 사유:** 실행 코드가 없는 문서 보정. +- **대체 검증 방법:** 아래 Progress의 직접 대조·독립 검토·공백 검사. +- [x] 사용자 확정 범위와 작성자 제안을 분리하고 두 프롬프트의 계약·검증 사례를 보완했다. + +### DOC-T4 후속: 관리자 노출 기간 + +**Goal 실행 `DOC-T4`:** 사용자 확인을 받은 콘텐츠 배너 동등 노출 기간 설정을 기존 다섯 문서에 일관되게 반영한다. + +- **시작 조건:** 사용자가 관리자 페이지에서 콘텐츠 배너와 동일하게 노출 기간 설정이 필요하다고 확인했다. +- **범위:** 기존 문서만 수정한다. 구현 계획의 TDD 예외는 문서 전용 변경으로 실행 동작이 없기 때문이다. +- **Files:** `docs/20261029_가로형서브배너/prd.md`, `docs/20261029_가로형서브배너/plan-task.md`, `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/app-prompt.md`, `docs/20261029_가로형서브배너/admin-prompt.md`. +- **완료 기준:** 시작·종료 경계 의미, KST 입력/응답 형식, create/update 생략·null 규칙, 기간 역전 검증, 공개 응답 DTO 불변, 관리자 기간 무관 목록, 앱 기간 필터와 OS 필터가 모든 문서에서 일치한다. 관리자 프롬프트에 KST 입력, 경계 개별 해제, 오류/편집 보존 및 기간 외 기록 편집 기준을 포함한다. +- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다. +- **대체 검증 방법:** 다섯 문서의 용어·JSON 예시·요청/응답 규칙 상호 대조와 공백 검사. +- **부모 검증:** 아래 후속 `DOC-T4-GATE`와 부모 작업자의 `./gradlew tasks --all` 검증은 별도로 수행한다. 과거 `DOC-GATE` 완료 기록은 유지한다. +- [x] 다섯 기존 문서에 후속 요구와 독립 프롬프트 세부사항을 반영한다. +- [x] 부모가 문서 내용을 검토하고 후속 검증을 수행했다. + +#### 후속 Gate `DOC-T4-GATE` + +- **시작 조건:** `DOC-T4` 문서 보완 완료. +- **완료 증거:** 기존 콘텐츠 배너 규칙과 다섯 문서 대조, 공백 검사, Gradle 문서 검증 명령 결과 기록. +- **범위 밖:** 실제 API·관리자 화면 구현 및 실연동 완료 판정. +- [x] 부모가 노출 기간 문서의 후속 검증을 완료했다. + +### Phase 1 Gate `DOC-GATE` + +- **시작 조건:** `DOC-T1`, `DOC-T2`, `DOC-T3` 작성 완료. +- **완료 증거:** 요청된 파일만 관리했으며 문서 내용과 경로를 대조. +- **범위 밖:** 제품 기능 구현 완료, API 제공·배포 완료 판정. +- [x] 부모 작업자가 변경 파일과 요구사항 추적을 검증했다. + +`DOC-GATE`의 기존 완료 기록은 `DOC-T3` 당시 검증 기록이다. `DOC-T4` 후속 반영은 별도 `DOC-T4-GATE`와 아래 부모 검증 기록으로 판정하며 과거 완료 기록으로 대체하지 않는다. + +검증 명령: 부모 작업자가 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`, 종료 코드 0을 확인했다. 상세 증거는 아래에 누적한다. + +## Historical DOC-T1~DOC-T4 변경 금지 + +아래 지침은 당시 문서-only 작업 범위의 기록이며 새 backend 계획에는 적용하지 않는다. + +- 기존 다섯 파일 외의 파일을 변경하지 않는다. +- backend, DB/schema/migration, architecture, 배포 구현 지시를 작성하지 않는다. +- 제안 경로를 실제 배포 경로라고 표현하지 않는다. +- 요청된 다섯 파일 이외의 파일을 만들지 않는다. + +## Progress + +### `DOC-T1` 1차 실행, 2026-10-29 + +- 상태: 완료 +- 무엇을: PRD와 문서 준비 계획 작성. +- 왜: 앱/관리자 구현 요청 전에 사용자 요구와 문서 범위를 분명히 하기 위해. +- 어떻게: 프로젝트 작업절차·문서유지보수 가이드 및 샘플과 기존 API 근거를 읽고 요구사항을 분리해 기록. +- 남은 항목: `DOC-T2`, 부모 최종 검증. +- 다음 행동: 계약과 프롬프트 작성. + +### `DOC-T2` 1차 실행, 2026-10-29 + +- 상태: 작성 완료, 부모 검증 대기 +- 무엇을: API 제안, 독립 앱 프롬프트, 독립 관리자 프롬프트 작성. +- 왜: 별도 담당자가 현재 코드베이스나 prompt 원문 없이도 요구와 제안 계약을 이해하도록 하기 위해. +- 어떻게: PRD의 요구사항 및 차단 경계를 계약 모델·endpoint·수용 기준에 대조. +- 남은 항목: 부모 작업자의 문서 검토와 `./gradlew tasks --all`. +- 다음 행동: 부모가 Gate 검증을 기록한다. + +### `DOC-T3` 후속 보정 및 `DOC-GATE`, 2026-10-29 + +- 상태: 문서 보정·최종 검증 완료. 제품 기능과 신규 API는 미구현이다. +- 무엇을: 사용자 확정 요구와 작성자 제안의 출처를 정정하고, 두 독립 프롬프트의 JSON 예시·8가지 주소 조합·오류 처리·multipart·null 삭제 규칙을 대조했다. +- 왜: 실제로 제공되지 않은 API나 승인되지 않은 링크 우선순위를 확정 계약처럼 전달하지 않기 위해. +- 어떻게: 부모가 다섯 파일 전체와 기존 추천/관리자 Controller·배너 DTO·공통 응답 근거를 직접 읽었다. 앱의 위험 URI scheme 표기를 `javascript`로 정정하고 대소문자 무관 검사를 명시했다. +- 독립 검토: 첫 검토 에이전트는 모델 호환성 오류로 실행되지 않았다. 대체 검토는 완료됐으며 순서 충돌 후보 1건을 보고했다. 부모 대조 결과 서버의 id 오름차순과 클라이언트의 응답 순서 보존은 양립하므로 충돌은 아니다. 오해를 줄이도록 두 프롬프트에 서버 순서와 클라이언트 재정렬 금지를 함께 적었다. 그 외 중대한 누락은 보고되지 않았다. +- 명령 검증: `./gradlew tasks --all` 재실행은 `BUILD SUCCESSFUL in 999ms`, 종료 코드 0이었다. 기존 Gradle deprecation 안내가 있었다. +- 공백 검증: 다섯 파일 각각 `GIT_MASTER=1 /usr/bin/git diff --no-index --check -- /dev/null 파일경로` 실행, 출력 없음·종료 코드 0. 최초 셸 검사에서 zsh의 예약 변수 `path`로 명령 검색이 실패해 `doc_path`와 절대 git 경로로 고친 뒤 재실행했다. 파일 내용 문제는 아니었다. +- 변경 범위: `GIT_MASTER=1 /usr/bin/git -c core.quotePath=false status --short --untracked-files=all`에서 해당 디렉터리의 새 Markdown 다섯 개만 확인했다. 기존 파일 변경, 커밋, 배포는 없다. +- 생략·한계: 문서만 변경하여 전체 테스트·빌드 및 실제 앱/관리자 화면 QA는 실행하지 않았다. Markdown LSP 진단 도구가 제공되지 않아 직접 내용 대조와 공백 검사로 대체했다. 신규 API 실연동은 계약 합의·제공 후 별도 검증이 필요하다. + +### `DOC-T4` 후속 반영, 2026-10-29 + +- 상태: 문서 수정 완료, 부모 검증 대기. 기존 `DOC-GATE`의 과거 완료 상태는 변경하지 않으며 이번 검증은 `DOC-T4-GATE`로 구분한다. +- 무엇을: 콘텐츠 배너와 같은 기간 설정 요구를 기존 다섯 문서에 반영한다. +- 왜: 관리자가 가로형 서브 배너에도 예약 노출 경계를 설정하고 목록에서 기간 외 레코드까지 관리할 수 있도록 계약 제안을 명확히 하기 위해. +- 어떻게: 기존 콘텐츠 배너 예약 PRD와 구현을 근거로 KST 분 정밀도, 종료 분 inclusive 경계, 생성/수정 null 의미를 문서화한다. 공개 `SubBanner`는 유지하고 기간 필터는 앱 조회 보장으로만 정의한다. +- 남은 항목: 부모 작업자의 문서 검토, `./gradlew tasks --all` 및 최종 문서 검증. +- 변경 범위: 지정된 기존 다섯 Markdown만 수정. 코드·신규 파일·API 구현은 없다. +- 생략·한계: 이 후속 문서 작업에서는 Gradle, 런타임 및 배포 검증을 수행하지 않았다. 신규 API는 제안 상태이며 구현·승인은 별도다. + +### `DOC-T4-GATE` 부모 검증, 2026-10-29 + +- 상태: 문서 후속 검증 완료. 신규 API와 실제 관리자·앱 기능의 구현 완료를 뜻하지 않는다. +- 무엇을·왜: 다섯 문서를 직접 읽고 기존 콘텐츠 배너의 PRD·Service·응답 formatter·노출 조건과 대조하여 날짜 계약의 차이를 방지했다. +- 대조 결과: KST 분 입력/응답과 초 단위 포함 경계를 구분했고, null/null·한쪽 경계·동일 분·역전/잘못된 입력·수정 생략 유지와 null 해제 규칙이 일치한다. 관리자 목록은 기간 무관, 공개 목록은 기간 내 항목만 반환하며 공개 DTO와 기존 OS 링크 필터는 유지한다. +- 보정: 관리자 응답의 기존 필드 타입을 보존하고 요청·응답 JSON 예시의 기간을 맞췄다. 기간만 수정할 때 기존 주소와 이미지를 보존하도록 명시했으며, 과거 Gate와 후속 Gate를 분리했다. +- 명령: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, 종료 코드 0. 다섯 파일 각각 `GIT_MASTER=1 /usr/bin/git diff --no-index --check -- /dev/null 파일경로`도 출력 없음·종료 코드 0이었다. +- 범위: `GIT_MASTER=1 /usr/bin/git -c core.quotePath=false status --short --untracked-files=all`에서 기존 서브 배너 Markdown 다섯 개만 확인했다. 새 문서·코드 변경·커밋·배포는 없다. +- 생략·한계: 문서 변경이므로 전체 테스트·빌드 및 실제 관리자/앱 UI·API 연동은 검증하지 않았다. Markdown LSP 도구 미제공으로 직접 계약 대조와 공백 검사로 대체했다. 신규 API는 계약 제안 상태를 유지한다. + +## Phase 2: 데이터 모델 및 기간 규칙 + +**Phase 결과:** 새로운 독립 테이블과 도메인/persistence 계층에서 배너를 저장하고 placement, 주소, 기간 규칙을 보장한다. + +**선행조건:** 부모가 backend 요구사항과 제안 API를 검토하고 구현 착수를 별도 승인. 과거 Phase 1/DOC Gate 완료는 backend 구현 승인이 아니다. + +### Task 2.1 독립 데이터 모델, 기간 처리와 DDL + +**Goal 실행 `P2-T1`:** `SubBanner` 저장 모델과 독립 SQL artifact를 정의하고 DB 왕복으로 초 정밀도 및 기간 규칙을 증명한다. + +- **요구사항:** `SB-001`, `SB-002`, `SB-006`, `SB-010`. +- **완료 증거:** 도메인 및 repository test, DDL의 테이블/컬럼 comment와 nullable UTC second-precision period, JPA flush/clear/reload 결과 기록. +- **시작 조건:** backend 착수 승인. 첫 구현 checkpoint에서 GIF 허용 여부와 S3 object 삭제/교체 실패 시 정책을 검토하고 사용자 확인이 필요한 선택은 구현 전에 결정 기록에 추가한다. 권고안은 기존 Tika `image/*` 검증을 재사용하고 GIF 허용, 기존 DB 변경 없음, S3 자동 cleanup 미보장이다. +- **범위 밖:** API/controller, multipart upload, 운영 DB 적용, 기존 content_banner migration. +- **TDD 예외 사유:** 없음. + +**Files (모두 PROPOSED):** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt` +- Create: `docs/20261029_가로형서브배너/create-sub-banner.sql` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt` + +**Interfaces:** + +- Produces: `enum class SubBannerPlacement { HOME_RECOMMENDATION, CONTENT_RECOMMENDATION }`; `data class SubBanner(val id: Long? = null, val placement: SubBannerPlacement, val imagePath: String, val webUrl: String?, val androidDeepLink: String?, val iosDeepLink: String?, val startDate: LocalDateTime?, val endDate: LocalDateTime?)`; `interface SubBannerRepository { fun findById(id: Long): SubBanner?; fun findAllByPlacement(placement: SubBannerPlacement): List; fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List; fun save(subBanner: SubBanner): SubBanner; fun deleteById(id: Long) }`. `imagePath`는 S3 object key이며 DTO에서만 기존 `cloud.aws.cloud-front.host`와 조합해 `imageUrl`을 만든다. +- Consumes: 기존 `BaseEntity`, QueryDSL/JPA와 프로젝트 DDL convention. 만든 repository의 `findVisibleByPlacement`에서 null 경계 독립 unbounded, 양끝 포함, id asc 정렬. + +제안 테이블은 `sub_banner`이며 아래 매핑을 `SubBannerEntity` 및 향후 SQL에 함께 적용한다. 이는 검토용 저장 설계이지 기존 테이블이나 확정된 운영 스키마가 아니다. + +| 컬럼 | 제안 MySQL 타입·규칙 | 매핑 | +|---|---|---| +| `id` | `BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` | `BaseEntity.id` | +| `placement` | `VARCHAR(32) NOT NULL` | `SubBannerPlacement`, enum 이름 저장 | +| `image_path` | `LONGTEXT NOT NULL` | 서버가 생성한 S3 object key, 절대 URL 아님 | +| `web_url`, `android_deep_link`, `ios_deep_link` | 각각 `LONGTEXT NULL` | 기존 URI를 255자 등으로 잘라 저장하지 않음 | +| `start_date`, `end_date` | 각각 `TIMESTAMP NULL DEFAULT NULL` | UTC 초 단위, H2 검증 컬럼은 `TIMESTAMP(0)` | +| `is_active` | `BOOLEAN NOT NULL DEFAULT TRUE` | `SubBannerEntity.isActive`; P5 후속 소프트 삭제 시 false | +| `created_at` | `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` | `BaseEntity.createdAt` | +| `updated_at` | `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | `BaseEntity.updatedAt` | + +모든 컬럼·테이블에 MySQL COMMENT를 붙이고 `(placement, id)` 인덱스를 둔다. `BaseEntity`와 전역 시간 설정은 변경하지 않는다. DDL은 신규 table CREATE 한 개이며 rollback 제안은 데이터 보존 확인 및 별도 승인 이후 해당 신규 table만 DROP, 자동 rollback 실행은 금지다. 삭제는 승인된 구현에서 해당 DB row만 제거하는 제안이며 S3 object는 자동 삭제하지 않는다. + +- [x] **RED:** 링크 불변식·기간·목록·수정·삭제의 실패 테스트를 먼저 작성했다. +- [x] **RED 확인:** 위 두 focused 클래스에서 26개 중 19개 assertion failure를 확인했다. 컴파일 실패는 RED 증거로 세지 않았다. +- [x] **GREEN:** domain, Entity, QueryDSL repository와 신규 테이블 DDL을 구현했다. +- [x] **GREEN 확인:** 같은 focused 테스트 26개 통과. 실제 H2 `TIMESTAMP(0)`의 flush/clear/reload, 긴 URI, 기간·placement·25개 초과 목록·수정·삭제를 검증했다. +- [x] **REFACTOR:** 직접 영향 배너·creatorname 회귀를 포함한 50개 테스트와 `ktlintCheck` 통과. 부모의 별도 focused 재실행도 26개 통과했다. + +### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** 독립 schema와 시간/persistence 경계를 backend feature로 확정한다. + +- **시작 조건:** `P2-T1` 완료. +- **완료 증거:** focused 테스트 통과, SQL review에서 신규 table 외 변경 없음, 결정 checkpoint 기록. +- **범위 밖:** 운영 DB 적용, app/admin 구현. +- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*'` 및 `./gradlew ktlintCheck`, 둘 다 **구현 시 실행, 이번 작업 미실행**. 전체 test/build는 공통 보안/설정 영향 또는 final Gate 등 project guide 조건에서만 실행한다. +- **Expected:** DB reload 뒤 날짜 정확도와 모든 허용 링크 조합 확인, ktlint 성공, live DDL 실행 0회. +- [x] `P2-T1` 완료 증거 확인. 독립 P2 리뷰는 명세·품질 승인, 차단 사항 없음. +- [x] DDL과 Entity의 테이블·컬럼·정밀도·index·COMMENT 일치 및 기존 데이터 비변경을 검토했다. 운영 적용·rollback은 별도 승인 대상이다. + +| Goal | 선행조건 | 종속 산출물 | +|---|---|---| +| `P2-T1` | backend 착수 승인 | domain/repository 인터페이스, 신규 SQL artifact | +| `P2-GATE` | `P2-T1` | Phase 3 착수 조건 | + +## Phase 3: 관리자 API 및 multipart 이미지 + +**Phase 결과:** ADMIN만 multipart 기반 CRUD를 수행하고, 유효성 검사 실패가 image upload/DB 변경 없이 끝난다. + +**선행조건:** `P2-GATE` 완료 및 관리자 API contract 검토. + +### Task 3.1 관리자 CRUD + +**Goal 실행 `P3-T1`:** 관리자 CRUD와 이미지 업로드를 구현하고 요청 검증, 병합 기간, 응답 envelope를 통합 테스트로 확인한다. + +- **요구사항:** `SB-003`~`SB-007`, `SB-011`, `SB-012`. +- **완료 증거:** create/list/update/delete 통합 테스트, DB 저장된 URI exact preservation, S3 object key의 기존 CDN URL 변환, 기간 KST exact roundtrip, 검증 실패 전 upload/save 없음. +- **시작 조건:** `P2-GATE`, ADMIN route/API draft review. 이미지 정책은 `P2-T1` decision checkpoint에서 확정. +- **범위 밖:** 공개 조회, ordering, cleanup scheduler, 공통 exception rewrite, 무단 400/404 보장. + +**Files (Create는 신규 제안 경로, Reference는 기존 파일):** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt` +- Reference: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`의 관리자 경로는 기존 `anyRequest().authenticated()` 유지, 별도 permitAll 추가나 이 Task의 설정 수정은 없다. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt` +- 서비스 동작은 위 실제 HTTP→service→JPA 통합 테스트에서 검증한다. 같은 시나리오만 반복하는 별도 service unit test는 추가하지 않는다. + +**Interfaces:** + +- Consumes: `SubBannerRepository`, `SubBannerPlacement`, 기존 `S3Uploader.upload(inputStream: InputStream, bucket: String, filePath: String, metadata: ObjectMetadata? = null): String`의 반환 object key, 해당 key를 public `imageUrl`로 만드는 기존 CDN 변환 방식, `validateImage(file: MultipartFile, gifAllowed: Boolean)`. +- Produces: `SubBannerService.findAllByPlacement(placement: SubBannerPlacement): List`, `create(image: MultipartFile, request: String): SubBanner`, `update(id: Long, image: MultipartFile?, request: String): SubBanner`, `delete(id: Long): Unit`. 서비스는 raw JSON의 필드 존재 여부·타입을 검사한 뒤 DTO로 변환하여 생략된 날짜와 명시적 null을 잃지 않는다. 기본 4필드의 누락도 검증한다. Controller at `/admin/sub-banners`: `GET list(placement): ApiResponse>`; `POST create(image: MultipartFile, request: String): ApiResponse`; `PUT /{id} update(id: Long, image: MultipartFile?, request: String): ApiResponse`; `DELETE /{id} delete(id: Long): ApiResponse`. DELETE는 서비스의 Unit을 data로 전달하지 않고 `data:null`인 성공 응답을 만든다. Controller class ADMIN preauthorization. `AdminSubBannerResponse` has `id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink, startDate, endDate`; create/update request wire fields follow `api-contract.md`. 공개 서비스 메서드는 `P4-T1`에서 추가한다. + +- [x] **RED:** 실제 HTTP/JWT/H2/S3 대체 경계에서 CRUD·8개 주소 조합·JSON/URI/날짜 검증·생략/null·이미지 실패·기간 무관 목록 테스트를 먼저 작성했다. +- [x] **RED:** 익명 및 모든 비관리자 역할 거부, 없는 id의 404·무변경 테스트도 작성했다. +- [x] **RED 확인:** 신규 HTTP 테스트 39개에서 assertion failure 39개를 확인했다. 이후 DST gap 회귀에서도 43개 중 2개가 기대 400/실제 200으로 실패했다. +- [x] **GREEN:** 관리자 controller·service·DTO를 구현했다. 검증 후에만 업로드/저장하며 트랜잭션·이미지 보존·row 삭제·실제 오류 계약을 적용했다. +- [x] **GREEN 확인:** 최초 39/39, DST 회귀 수정 후 실제 HTTP 43/43 통과. POST/PUT 시작·종료 gap 거부·upload 0·DB 불변과 정상 과거/overlap 왕복을 확인했다. +- [x] **REFACTOR:** 수정 전 직접 영향 회귀 161/161 및 수정 후 focused 43/43·`ktlintCheck` 통과. 파일명은 클래스명에 맞춰 단수형으로 정리했다. + +### Phase 3 Gate + +**Goal 실행 `P3-GATE`:** CRUD 및 업로드의 검증 순서와 계약 응답을 판정한다. + +- **시작 조건:** `P3-T1` 완료. +- **완료 증거:** multipart integration test, 저장 후 DB 확인, validation-failure 무변경 증거, 공통 응답 envelope 대조. +- **범위 밖:** public GET, browser UI, S3 object cleanup 보장. +- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.AdminSubBannerControllerIntegrationTest'`와 `./gradlew ktlintCheck`. 실제 security 공통 영향을 수정했을 때는 `./gradlew test`로 검증을 확대한다. 실행 증거는 아래 Progress에 기록한다. +- **Expected:** 검증 실패는 upload/DB mutation 0회, 성공 POST/PUT 객체 및 DELETE null 응답, ADMIN만 변경. +- [x] `P3-T1` 완료 증거 확인. 독립 P3 리뷰의 DST blocker를 RED→GREEN으로 수정하고 scoped 재검토 승인. +- [x] 실제 400/404 Boot 오류 body, 익명 401, 비관리자·multipart 바인딩 오류의 기존 200 `success:false`를 HTTP로 확인했다. 소비자 문서의 최종 계약 동기화는 P4 최종 Gate에서 수행한다. + +| Goal | 선행조건 | 종속 산출물 | +|---|---|---| +| `P3-T1` | `P2-GATE`와 API contract review | 관리자 CRUD endpoint 및 request/response DTO | +| `P3-GATE` | `P3-T1` | Phase 4 착수 조건 | + +## Phase 4: 공개 기간 조회 및 보안 Gate + +**Phase 결과:** 정확한 공개 GET만 익명 허용하고 현재 기간의 전체 목록을 id asc로 반환한다. 관리자/인접 method 보호를 end-to-end 확인한다. + +**선행조건:** `P3-GATE` 완료. + +### Task 4.1 공개 GET, 기간 filtering과 권한 통합 검증 + +**Goal 실행 `P4-T1`:** 익명 공개 조회와 접근 정책을 구현하고 운영 경로에 실제 공개 범위 외 노출이 없음을 검증한다. + +- **요구사항:** `SB-007`~`SB-009`, `SB-011`. +- **완료 증거:** public/admin DTO 차이, placement/date/ordering, 정확한 GET 공개와 보호된 변형 경로 및 role test. +- **시작 조건:** `P3-GATE` 완료, proposed endpoint contract 확인. +- **범위 밖:** app OS link filtering, UI, pagination, active toggle, scheduler. + +**Files (신규 경로 PROPOSED):** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/SubBannerController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt` (공개 조회 및 고정 시각 seam 추가) +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, 정확한 `GET /api/v2/sub-banners`만 permitAll. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/SubBannerControllerIntegrationTest.kt` +- 보안 시나리오는 같은 실제 HTTP/JWT/H2 context의 `SubBannerControllerIntegrationTest`에 통합했다. 중복 HTTP fixture와 별도 security test 클래스는 만들지 않았다. + +**Interfaces:** + +- Consumes: `P2-T1`의 `SubBannerRepository.findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List`와 `P3-T1`의 CDN URL 조립 규칙. +- Produces: `SubBannerService.findVisibleByPlacement(placement: SubBannerPlacement): List`와 `SubBannerController.getSubBanners(placement: SubBannerPlacement): ApiResponse>`. 서비스는 기존 `nowProvider: () -> LocalDateTime` 주입 패턴으로 UTC 시각을 한 번 얻어 초 단위 절삭 후 같은 값을 repository에 전달한다. public DTO는 정확히 `id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink`, 날짜 미포함. `SecurityConfig`에서 해당 GET만 익명 허용. + +- [x] **RED:** 고정 시각·실제 DB·HTTP로 23개 공개 조회/권한 테스트를 작성했다. 두 placement, 전체 25개 목록, 빈 결과, nullable·초·소수초·자정 경계, 6필드 DTO 및 OS 비필터를 포함한다. +- [x] **RED 확인:** 공개 경로 미구현 상태에서 23개 중 22개 assertion failure(기대 200/400, 실제 401)를 확인했다. +- [x] **GREEN:** public controller/DTO·서비스 조회 및 정확한 GET matcher 한 줄을 추가했다. 기존 권한·추천 응답은 유지했다. +- [x] **GREEN 확인:** 실제 HTTP 23/23 통과. GET 외 method·HEAD·인접 경로·관리자 보호와 요청 시각 1회 조회를 검증했다. +- [x] **REFACTOR:** 기존 홈/오디오 추천 회귀 포함 96/96 및 ktlint 통과. 부모의 최종 전체 suite에서도 신규 92개를 포함한 2,676개가 통과했다. + +### Phase 4 Gate + +**Goal 실행 `P4-GATE`:** 신규 public/admin endpoint의 데이터·security·API 계약을 최종 검증한다. + +- **시작 조건:** `P4-T1` 완료. +- **완료 증거:** Phase 2~4 focused tests 통과, 신규 SQL review, security behavior와 실제 공통 오류 응답 기록. +- **범위 밖:** API 배포/운영 DB 적용, 앱·관리자 client 연동 완료 주장. +- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*'` 및 관련 추천 회귀로 확인한 뒤 보안 설정 변경과 사용자 ulw 요구에 따라 부모가 `./gradlew test ktlintCheck bootJar --continue --console=plain`을 실행했다. 결과는 아래 최종 검증 기록을 따른다. +- **직접 영향 회귀:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest'`는 **구현 시 실행, 이번 작업 미실행**이다. 기존 추천 응답이 바뀌지 않았는지 확인한다. +- **Expected:** focused test 성공, backend artifact bootJar 성공, exact public GET만 anonymous, 관리자 CRUD ADMIN-only, 전체 8 조합 및 기간/DB 정밀도 규칙 통과. 운영 DDL 실행과 app integration 완료 판정 0건. +- [x] `P4-T1` 및 모든 선행 Gate 증거 확인. 전체 테스트·lint·bootJar와 독립 최종 코드 리뷰 통과. +- [x] API 계약·앱/관리자 프롬프트를 실제 구현에 맞추고 미배포·운영 DB 미적용 상태를 구분했다. + +| Goal | 선행조건 | 종속 산출물 | +|---|---|---| +| `P4-T1` | `P3-GATE` | public GET, 기간 query, exact security matcher | +| `P4-GATE` | `P2-GATE`, `P3-GATE`, `P4-T1` | backend 구현 검증, 별도 client integration 재개 조건 | + +### 백엔드 실행 의존성 + +```text +P2-T1 → P2-GATE → P3-T1 → P3-GATE → P4-T1 → P4-GATE +``` + +backend checkbox는 실제 검증 후 갱신한다. 문서 작성/부모 검토 완료만으로 구현 완료를 표시하지 않는다. 사용자 구현 승인에 따라 순차적으로 한 개의 Goal만 active한다. + +## Current Progress + +### `DOC-T5` 문서 변환 초안, 2026-10-29 + +- 상태: 작성 초안 완료, 작성자 대조 및 부모 검증 대기. +- 무엇을: 기존 `prd.md`와 `plan-task.md`에 backend requirements와 `P2`~`P4` 향후 implementation plan을 추가했다. DOC-T1~4 역사적 checkbox/Gate/Progress는 유지했다. +- 왜: 앱·관리자 실제 구현 전에 서버 기능과 persistence를 codebase 근거에 맞게 검토할 수 있도록 하기 위해. +- 어떻게: API draft, project guides 및 기존 security, S3, image validation, reservation, exception, JPA test patterns를 대조했다. API 전면 승인·배포·구현 상태로 표현하지 않았다. +- 검증: PRD ID와 Goal, exact paths/signatures, proposed API shapes, 향후 DDL, historical checkboxes 및 미착수 backend checkboxes를 직접 대조했다. 이번 작업에서 Gradle/test/build를 실행하지 않았다. +- 남은 항목: 작성자 문서 대조 및 부모 검토. backend 구현은 별도 승인 대기. +- 다음 행동: 부모가 이 문서 Gate를 검증하고 반영한다. + +### `DOC-T5` 부모 최종 문서 검증, 2026-10-29 + +- 상태: 백엔드 PRD·계획 문서 보완 및 문서 검증 지침 수정 완료. 백엔드 구현은 미착수이며 `P2-T1`과 모든 후속 구현 Task/Gate는 미완료다. +- 무엇을·왜: 프롬프트 작성 계획을 실제 백엔드 개발에 사용할 수 있는 요구사항·파일·인터페이스·TDD·Gate 계획으로 보완하고, 문서만 바꿀 때 불필요한 Gradle 실행을 제거했다. +- 문서 대조: `SB-001`~`SB-012`를 `P2-T1`·`P3-T1`·`P4-T1`과 연결했다. 사용자 확정과 작성자 제안을 구분하고, 8개 주소 존재 조합 중 모두 없는 경우만 거부·나머지 7개 허용으로 PRD/테스트 이름/계약을 맞췄다. 기간 형식·생략/null·노출 경계·관리자/공개 DTO는 기존 API 초안과 일치한다. +- 파일·책임 대조: 신규 파일은 모두 제안 경로로 표시했다. 저장 object key인 `imagePath`와 응답 `imageUrl`을 구분하고 각 Phase의 서비스 제공 메서드, 변경 파일, 신규 테이블 매핑을 일치시켰다. 기존 구현과 회귀 테스트 경로를 직접 확인했다. +- 이력 대조: `DOC-T1`~`DOC-T4`의 완료 체크박스와 과거 Gradle 실행 결과는 삭제하거나 새 작업 증거로 재사용하지 않았다. 새 백엔드 Task/Gate의 체크박스는 모두 미체크다. +- 공백 검사: `GIT_MASTER=1 /usr/bin/git diff --check -- docs/agent-guides/문서유지보수.md`와 PRD/계획 각각의 `git diff --no-index --check -- /dev/null 파일경로`는 출력 없음·종료 코드 0이었다. +- 범위 대조: 현재 승인 범위에서 수정한 파일은 이 PRD·계획 및 `docs/agent-guides/문서유지보수.md` 세 개다. 이전 작업에서 존재한 앱/관리자 prompt와 API 초안은 변경 대상에 넣지 않았다. 새 코드·SQL·빌드 설정·배포 변경은 없다. +- 미실행·사유: 사용자 승인과 수정한 문서 전용 검증 지침에 따라 이번 작업에서는 `./gradlew tasks --all`·테스트·빌드를 실행하지 않았다. 위 구현 단계의 명령들은 향후 검증 계획이며 이번 실행 결과가 아니다. 실제 API 동작과 배포는 검증하지 않았다. +- 다음 행동: PRD/API 제안을 검토하고 별도로 구현 착수를 승인한 뒤 `P2-T1`부터 재개한다. 이미지/GIF 및 S3 객체 보존 정책의 검토 checkpoint를 건너뛰지 않는다. + +### 백엔드 구현 착수, 2026-10-29 + +- 승인: 사용자가 "문서대로 백엔드 코드, DDL 생성 진행"을 요청했다. 기존 설계의 독립 리소스·API·권한·기간 계약을 구현 기준으로 사용한다. 운영 DB 및 AWS에 검증용 요청을 보내지 않는다. +- 실행 방식: 단일 구현 담당자에게 단계별 작업을 위임하고 부모가 파일·실행 증거를 검증한다. 기존 `test` 브랜치에서 진행하며 새 worktree를 자동 생성하거나 이전 문서 변경을 되돌리지 않는다. +- 결정: GIF는 기존 이미지 검증기의 허용 옵션으로 지원하며, 삭제는 DB row에 한정하고 S3 object는 자동 삭제하지 않는다. 입력 오류/없는 리소스의 400/404는 신규 경계에서 처리하고 기존 공통 예외·권한 거부 정책은 변경하지 않는다. +- 검증 시나리오: 관리자 실제 HTTP CRUD와 7개 유효 주소 조합, 모두 빈 주소·잘못된 형식의 무변경 거부, KST 생략/null 및 DB 왕복, 기간/placement 공개 조회와 20개 초과 무절단 목록, 익명/비관리자 권한 거부 및 기존 추천 회귀를 확인한다. +- 도구 한계: 현재 host는 `lsp_diagnostics`를 제공하지 않는다. Kotlin 컴파일과 ktlint로 정적 오류를 검사하되 LSP 검증 완료로 표현하지 않는다. +- 자원: 실행 임시 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subbanner-ulw.kY4Cw5`다. 생성한 test/QA 자원만 종료·정리하며 기존 공유 Gradle 캐시는 삭제하지 않는다. + +### `P2-T1` 및 `P2-GATE`, 2026-10-29 + +- 완료: `SubBanner`·placement·저장소 port·JPA Entity·QueryDSL adapter와 `create-sub-banner.sql`, domain/persistence 테스트를 생성했다. +- RED/GREEN: 두 신규 클래스 26 tests 중 의도한 assertion failure 19개 확인 후 26/26 통과. 기존 배너 baseline 19/19, 직접 영향 배너·creatorname 포함 Gate 50/50 및 전체 `ktlintCheck` 통과. +- 부모 검증: 신규 두 클래스 focused 명령을 다시 실행해 `BUILD SUCCESSFUL in 16s`, XML domain 2 + persistence 24, failures/errors/skipped 모두 0을 확인했다. 전체 변경 소스·테스트·DDL을 읽었고 독립 리뷰도 P2 승인이다. +- 결정: 기간 저장 경계는 repository에서 한 번 초 절삭한 값으로 저장/반환해 DB 반올림을 방지한다. 현재 UTC 시각도 repository query 경계에서 초 절삭한다. 서비스의 UTC 분 입력 정규화와 함께 사용한다. +- 한계: 로컬 MySQL 실행 파일과 실행 중 Docker daemon이 없어 운영 MySQL 적용을 검증하지 않았다. H2 MySQL mode와 DDL/Entity 대조로 확인했으며 운영 DB에는 적용하지 않았다. 전체 suite/HTTP는 후속 Gate에서 수행한다. +- 별도 DDL 직접 검증: task-owned Java/Hibernate driver가 작성한 SQL을 독립 H2에 적용한 뒤 실제 repository CRUD·초 정밀도·기간·placement·잘못된 주소 조합을 검증했다. `MANUAL QA PASS`, `BUILD SUCCESSFUL in 6s`. 초기 driver naming 설정 및 Java fluent setter 오류를 driver에서만 수정했으며 제품 코드는 변경하지 않았다. + +### `P3-T1` 및 `P3-GATE`, 2026-10-29 + +- 완료: 관리자 CRUD·service·request/admin response DTO·실제 random-port JWT HTTP 테스트를 구현했다. 이미지 스트림은 use로 닫고 검증 전에 업로드/저장하지 않는다. 기본 4필드 필수 교체와 날짜 필드의 생략 유지/null 해제를 구분했다. +- 증거: baseline 기존 HTTP/service 5/5; 신규 scaffold RED 39 assertion failures → GREEN 39/39. 기존 배너/creatorname 포함 직접 영향 회귀 161/161, full ktlint 통과. 별도 service unit test 대신 실제 HTTP→service→JPA 경로의 비중복 검증을 유지했다. +- 리뷰 수정: `1988-05-08 02:30`의 Asia/Seoul DST gap 자동 보정을 JDK17과 실제 HTTP로 재현했다. gap 회귀 2 failures → 유효 offset 부재를 거부하도록 수정 → 43/43 및 ktlint 통과. 독립 scoped 재검토는 ADDRESSED/APPROVE다. +- 실제 응답: DELETE HTTP200 `{"success":true,"message":null,"data":null,"errorProperty":null}`. JSON/URI/기간 검증 400과 없는 id 404는 Spring Boot 오류 body. 익명 401, 모든 비관리자와 필수 multipart part 누락은 기존 정책의 HTTP200 `success:false`다. +- 저장 한계: MySQL TIMESTAMP가 표현 가능한 UTC `1970-01-01 00:00:01`~`2038-01-19 03:14:07` 밖 입력은 업로드 전에 400으로 거부한다. 이는 임의의 과거 날짜 제한이 아니다. S3 업로드 후 DB 실패 시 새 object 잔존 가능성은 보존하며 자동 정리를 추가하지 않았다. +- 부모 확인: 실제 소스·테스트를 읽고 최종 XML 43 tests/failures0/errors0/skipped0을 확인했다. P4 후 부모가 전체 HTTP·회귀·빌드를 다시 실행한다. + +### `P4-T1` 및 `P4-GATE` 최종 실행, 2026-10-29 + +- 완료: 공개 controller/6필드 DTO, UTC nowProvider를 한 번 읽는 read-only 서비스 조회, 정확한 `GET /api/v2/sub-banners` 허용 matcher 한 줄과 실제 HTTP 테스트를 구현했다. 기존 추천 API 응답·테이블·공통 예외 정책·dependency는 변경하지 않았다. +- RED/GREEN: 공개 HTTP 23개 중 22개 assertion failures(기대 200/400, 실제 401) → 23/23 통과. JSON fixture의 LongNode/IntNode 차이는 기대 map의 serialize/readTree로 해소하고 전체 JSON tree 비교는 유지했다. 공개·관리자 보안 테스트는 동일 실제 HTTP context로 통합했다. +- 관련 회귀: 신규 92개(domain 2, persistence 24, 관리자 HTTP 43, 공개 HTTP 23) + 기존 홈/오디오 추천 4개 = 96/96, ktlint 통과. +- 부모 최종 명령: `./gradlew test ktlintCheck bootJar --continue --console=plain` → exit 0, `BUILD SUCCESSFUL in 12m 27s`. `build/reports/tests/test/index.html`의 총 2,676 tests, failures 0, ignored 0, 성공률 100%를 확인했다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 생성 확인. 현재 코드로 전체 실제 HTTP 테스트도 재실행됐다. +- 실제 HTTP 증거: 관리자 POST/PUT 200의 관리자 8필드, DELETE 200 `{"success":true,"message":null,"data":null,"errorProperty":null}`. 익명 공개 GET은 200의 6필드 배열/빈 배열, 누락·잘못된 placement는 400. 익명 POST/PUT/DELETE/HEAD/PATCH와 인접 GET은 401이며 비관리자 admin은 기존 200 `success:false`다. 원본 응답은 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.*.xml`의 `HTTP EVIDENCE`에 기록돼 있다. Authorization/JWT는 출력하지 않았다. +- 기간 증거: 고정 now의 시작 `:00`, 종료 `:59.999999999` 포함, 다음 초/다음 날 제외, 한쪽·양쪽 null, 두 placement 격리, 25개 무절단 id 순서, iOS 요청에도 Android-only 행을 반환하는 서버 OS 비필터를 실제 DB·HTTP로 확인했다. +- 독립 최종 리뷰: production 11개·대응 테스트 4개·DDL·SecurityConfig diff·호출 경계를 검토한 결과 `APPROVE`, Must fix/Should fix 없음. 기존 P3 DST gap blocker의 RED→GREEN 및 재검토 승인도 확인했다. 부모가 리뷰와 별도로 전체 실행 결과를 확인했다. +- 문서: API 계약과 앱/관리자 프롬프트를 실제 경로·shape·오류·날짜 허용 범위·이미지 정책으로 갱신했다. 문서 동기화 중 잘못 적힌 경로 ID 형식의 400 보장은 제거했으며 프레임워크 바인딩 오류까지 같은 상태라고 가정하지 않는다. +- 남은 한계: 운영 DB DDL 적용·배포·실제 AWS 업로드·native MySQL 실행·앱/관리자 UI는 수행하지 않았다. DDL은 독립 H2에 실제 적용해 검증했다. host에 LSP 도구가 없어 Kotlin 컴파일/ktlint로 대체했으며 LSP clean으로 주장하지 않는다. S3 업로드 후 DB 실패 시 orphan 및 교체/삭제 후 기존 object 보존 가능성은 승인된 범위대로 남긴다. +- 종료 확인: 변경 파일의 `git diff --check` 및 신규 파일 공백 검사 통과, PRD/plan 미완료 체크박스 없음. `pgrep`에서 Gradle Test Executor·embedded Redis·일회성 QA driver 잔존 없음. 검증 요약을 본 문서에 보존한 뒤 이번 소유 run root·child roots·QA driver·note·cache를 제거하고 경로 부재를 확인했다. 기존 공유 캐시·worktree·소스·빌드 결과/테스트 보고서는 보존했으며 커밋·푸시는 하지 않았다. + +## Phase 5: is_active 소프트 삭제 + +앞선 하드 삭제 구현·검증은 당시 완료 이력이다. 이번 사용자 결정은 이를 대체하는 후속 변경이며 기존 완료 체크박스를 되돌리지 않는다. + +### Task 5.1 저장·조회·삭제 경계 변경 + +**Goal `P5-T1`:** DELETE가 행을 보존하며 is_active만 false로 바꾸고, 삭제된 배너가 모든 소비자 조회와 수정 경로에서 제외된다. + +- 시작 조건: `DEC-010` 사용자 승인 및 기존 P2~P4 완료. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`, `DefaultSubBannerRepository.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`, `adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`, `adapter/in/web/SubBannerControllerIntegrationTest.kt`; `docs/20261029_가로형서브배너/create-sub-banner.sql` 및 기존 PRD/API/프롬프트/계획. +- 완료 증거: DB 행과 기존 필드 보존, false 갱신, findById/관리자 목록/공개 목록 제외, 수정·재삭제 404, 기본 true와 DTO 불변을 실제 persistence/HTTP 테스트로 확인한다. +- 동시 경계: 수정 요청이 활성 entity를 로드한 뒤 다른 트랜잭션의 DELETE가 커밋하더라도, 먼저 시작한 수정이 삭제 상태를 true로 되돌리지 않아야 한다. 두 독립 EntityManager의 겹치는 트랜잭션과 새 조회 context로 재현·검증한다. +- 범위 밖: 활성 토글·복원 API·isActive 요청/응답 필드·S3 삭제·기존 완료 이력 삭제·운영 DDL 적용. 아직 미배포인 신규 CREATE DDL을 보완하며 추측한 레거시 migration은 추가하지 않는다. +- [x] **RED:** 행 보존/비활성/목록 제외·기간·placement·수정/재삭제 경계를 먼저 테스트했다. 추가 리뷰에서 발견한 stale managed entity도 두 트랜잭션으로 재현했다. +- [x] **RED 확인:** 기존 하드 삭제/컬럼 부재 assertion 8개 실패, 이후 동시 상태 덮어쓰기 회귀 1개 실패를 각각 확인했다. +- [x] **GREEN:** entity/DDL 기본 true, DELETE false 갱신, 모든 소비자 조회의 활성 필터와 entity-local DynamicUpdate를 구현했다. +- [x] **GREEN 확인:** focused 99/99 및 실제 HTTP에서 행·필드·S3 보존/미노출/404·기존 DTO/권한/기간 규칙과 동시 수정의 비복원을 확인했다. +- [x] **REFACTOR:** 직접 영향만 정리하고 ktlint·DDL 실제 H2 실행·독립 scoped 재검토를 통과했다. 전체 Gate는 별도 진행한다. + +### Phase 5 Gate `P5-GATE` + +- 시작 조건: `P5-T1` 완료. +- 완료 증거: 실제 HTTP 시나리오·전체 테스트·ktlint·bootJar·독립 리뷰·자원 정리 기록. +- [x] P6를 포함한 최종 전체 테스트 2,682개·lint·bootJar 및 독립 리뷰 통과. DDL의 is_active 기본 true와 entity 일치, 행 보존·조회 제외·동시 비복원을 검증했다. 자원 정리 영수증은 아래 최종 기록을 따른다. +- 도구 한계: LSP 및 native MySQL 실행 환경은 미제공이다. 컴파일/ktlint와 H2 검증으로 대체하되 운영 적용 검증으로 표현하지 않는다. + +### `P5-T1` 1차 검증 및 리뷰, 2026-10-29 + +- baseline: 기존 서브 배너 focused 92/92 통과. 회귀 테스트를 먼저 변경해 기존 하드 삭제와 컬럼 부재로 인한 assertion 8개 실패를 확인했다. +- 1차 GREEN: entity 기본 true/nonnull, DELETE false 갱신, id/관리자/공개 활성 필터와 authored CREATE의 default true를 구현했다. focused 98/98 및 ktlint 통과. 실제 HTTP에서 DELETE 200/null, DB 행·payload·이미지·생성 시각 보존, 비활성 PUT/재DELETE404와 upload0을 확인했다. +- 독립 리뷰: 순차 삭제 경계는 통과했으나, 삭제 전 true entity를 보관한 수정 트랜잭션의 Hibernate 전체 컬럼 UPDATE가 뒤늦게 is_active=true를 덮을 수 있다는 P1 blocker를 확인했다. 기존 flush/clear 후 stale-domain 테스트와 다른 경계이므로 두 트랜잭션 회귀 RED→GREEN을 추가한다. 전체 완료 판정은 수정·재검토 후 수행한다. + +### `P5-T1` 동시 경계 수정 및 Gate 진행, 2026-10-29 + +- 수정: `@DynamicUpdate`를 SubBannerEntity에만 추가해 payload 수정이 변경하지 않은 is_active를 SQL에 포함하지 않도록 했다. 새 잠금·버전 컬럼·외부 API는 추가하지 않았다. +- 재현: T1이 활성 managed entity를 유지한 상태에서 T2 DELETE 커밋 후 T1 payload UPDATE를 커밋했다. 제3 context에서 false를 기대했으나 true가 된 assertion RED 1건을 확인했다. 수정 뒤 persistence29/29와 focused99/99·ktlint가 통과했고 원래 stale-domain 회귀도 유지했다. 독립 재검토는 ADDRESSED/APPROVE다. +- 실제 SQL: 삭제는 `set updated_at=?, is_active=? where id=?`, 일반 payload 변경은 `set updated_at=?, web_url=? where id=?`로 관찰돼 삭제 상태가 덮이지 않았다. 테스트의 독립 EntityManager 3개와 커밋된 fixture는 finally로 정리했다. +- 전체 검증 1차: 부모의 `./gradlew test ktlintCheck bootJar --continue --console=plain`은 `bootJar` 뒤 `:test` 수행 중 실행 도구의 1,200,000ms 제한으로 종료됐다. 테스트 실패나 전체 성공을 판정할 출력은 없었다. 기존 99개 focused HTML을 전체 성공 증거로 재사용하지 않았다. +- 조사·재시도: 종료 후 Gradle Test Executor/embedded Redis 잔존 없음을 확인했다. 코드·테스트·빌드 설정은 바꾸지 않고, 테스트별 진행만 기록하는 임시 init script를 사용해 동일 전체 명령을 2,400,000ms 제한으로 한 번 재실행 중이다. 이 기록은 최종 통과 주장이 아니다. + +## Phase 6: 두 추천 API에 서브 배너 통합 + +### Task 6.1 비로그인 추천 응답 합성 및 기존 앱 GET 제거 + +**Goal `P6-T1`:** 홈·콘텐츠 추천 GET에서 한 요청으로 기존 추천 데이터와 `subBanners`를 받고, 별도 서브 배너 조회 endpoint는 제공하지 않는다. + +- 시작 조건: 사용자 `DEC-011` 승인, P5 focused 99개 및 독립 재리뷰 승인. P5 전체 Gate는 이번 통합 이후 최종 코드로 함께 판정한다. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `dto/recommendation/HomeRecommendationResponse.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`, `dto/AudioRecommendationsResponse.kt`; 기존 `v2/subbanner/adapter/in/web/SubBannerController.kt` 삭제 및 `configs/SecurityConfig.kt`의 해당 단일 permitAll 제거. 두 facade/DTO 직접 생성 테스트 호출자와 기존 실제 HTTP subbanner 테스트를 추천 통합 시나리오로 갱신한다. +- 인터페이스: 두 facade는 기존 `SubBannerService.findVisibleByPlacement` 및 `SubBannerResponse.from`을 재사용한다. HOME/CONTENT placement는 해당 facade가 고정하며 클라이언트 placement query나 OS 조건을 추가하지 않는다. `data.subBanners: List`는 항상 존재하며 없으면 `[]`다. +- 범위 밖: 관리자 API/DDL·소프트 삭제 변경, 기존 banners 교체, 새 fallback/cache/endpoint, 앱 UI 코드, 운영 배포. 기존 추천의 익명 GET 허용은 유지한다. +- [x] **RED:** 두 추천 GET의 실제 익명 HTTP에서 subBanners·활성/기간·배열·legacy 보존·폐기 handler 부재를 검사했다. +- [x] **RED 확인:** 29개 중 26 assertion failures를 확인했다. 통합 필드 부재와 폐기 대상 경로가 여전히 200인 것을 재현했다. +- [x] **GREEN:** 두 facade/DTO에 필수 배열을 합성하고 모든 직접 생성 호출자를 수정했다. standalone controller와 해당 permitAll 한 줄을 제거했다. +- [x] **GREEN 확인:** 같은 RED 명령 29/29 및 subbanner/home/audio 전체 관련 회귀 161/161 통과. 두 실제 추천 GET에 기존 소프트 삭제/기간/권한/무절단 시나리오를 이식했다. +- [x] **REFACTOR:** API 계약·앱 프롬프트를 `data.subBanners`로 갱신했다. 기존 기능·보안 및 JSON assertion을 완화하지 않았고 ktlint와 독립 P6 리뷰를 통과했다. + +### 통합 최종 Gate `P6-GATE` + +- 시작 조건: P6-T1 및 P5 focused/리뷰 완료. +- [x] 소프트 삭제·두 추천 통합 focused/전체 테스트·ktlint·bootJar·독립 리뷰를 통과하고 실제 HTTP 증거를 기록했다. +- [x] 앱 프롬프트/API 문서·소프트 삭제 DDL·행 보존·비로그인 조회·폐기 endpoint 부재를 확인했다. 소유 QA 자원 정리는 최종 종료 기록에 남긴다. + +### 전체 검증 범위 변경, 2026-10-29 + +- 사용자 추가 요청으로 변경 전 코드를 대상으로 실행 중이던 전체 검증 runner를 개별 취소했다. 소프트 삭제 구현은 유지하며 P6 통합 코드로 최종 검증을 새로 수행한다. 이는 전체 테스트 통과/실패 판정이 아니다. +- 취소 당시 Gradle wrapper/test-worker PID를 확인한 뒤 후속 ps/lsof에서 해당 프로세스 부재를 확인했다. 공유 daemon·다른 작업 프로세스는 종료하지 않았다. + +### `P6-T1` 구현·focused·독립 리뷰, 2026-10-29 + +- 구현: HomeRecommendationFacade는 HOME_RECOMMENDATION, AudioRecommendationFacade는 CONTENT_RECOMMENDATION을 고정해 기존 SubBannerService/6필드 DTO를 재사용한다. 기존 추천 DTO의 필수 `subBanners` 배열을 추가했고 모든 기존 필드/기존 banners 매핑을 그대로 유지했다. standalone 앱 controller와 정확한 GET 허용 matcher를 제거했다. 관리자 CRUD·DDL·소프트 삭제 저장소·DynamicUpdate는 변경하지 않았다. +- TDD: 컴파일 후 RED 29개 중 26개 assertion failures → GREEN 29/29. 기존 소켓 테스트를 `RecommendationSubBannerIntegrationTest`로 이식했으며, 폐기된 placement query 검증은 두 화면의 고정 placement 및 handler 부재 검증으로 대체했다. 삭제된 테스트를 통과를 위한 우회로 사용하지 않았다. +- focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*' ktlintCheck --console=plain` → 161/161, failures/errors/skipped 0, `BUILD SUCCESSFUL in 2m 25s`. +- 실제 HTTP: 두 GET은 Authorization 없이 200이며 `data.subBanners:[]` 또는 정확한 6필드 목록을 반환했다. HOME의 기존 9개+신규 배열=10필드, AUDIO의 기존 8개+신규 배열=9필드 전체와 비어 있지 않은 legacy banners를 정확히 비교했다. 각 placement 25개 전체·기간 소수초/자정·삭제 행 9개 중 비활성 4개 보존·OS 비필터를 확인했다. 폐기된 경로는 인증 GET 404/익명 401이며 handler mapping에도 없다. +- 독립 P6 리뷰: APPROVE, 차단 사항 없음. 직접 생성 테스트 호출자 6파일과 실제 HTTP 이식 전체를 확인했고 이전 P5 동시 삭제 비복원 회귀도 유지됨을 확인했다. +- 부모 확인: facade/DTO 변경, 기존 테스트 diff 및 새 실제 HTTP 테스트 전체와 앱/API 문서를 읽었다. 통합 이후에는 별도 배너 fetch가 없으므로 앱 프롬프트의 오류 처리를 추천 API 오류와 개별 이미지/링크 오류로 구분했다. +- 배포 조건: 추천 API도 신규 테이블/컬럼을 조회하므로 현재 DDL 준비가 서버 배포보다 선행해야 한다. 운영 DB 적용·배포는 수행하지 않는다. +- 최종 실행: 안정된 P5+P6 코드로 테스트별 진행 로그만 추가한 임시 init script와 함께 전체 `test ktlintCheck bootJar --continue`를 실행 중이다. 이전 취소/timeout 결과나 focused 통과를 전체 통과로 대체하지 않는다. + +### `P5-GATE`·`P6-GATE` 최종 전체 검증, 2026-10-29 + +- 결과: 안정된 최종 코드에서 `./gradlew test ktlintCheck bootJar --continue --console=plain`과 동작을 바꾸지 않는 임시 테스트 진행 로그 init script를 사용해 1회 실행했다. exit 0, `BUILD SUCCESSFUL in 15m 46s`. 전체 2,682 tests, failures/errors/skipped/ignored 모두 0이다. 부모가 fresh HTML 및 `QA_SUITE_RESULT tests=2682 failed=0 skipped=0` 원본 로그를 직접 확인했다. +- 빌드/정적 검사: `bootJar`가 이번 실행에서 생성됐고 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`를 확인했다. `ktlintCheck`는 변경 없는 소스의 선행 성공 결과를 사용한 UP-TO-DATE 상태이며 전체 명령은 성공했다. Kotlin 컴파일과 선행 focused lint도 통과했다. LSP 도구 미제공을 LSP 성공으로 표현하지 않는다. +- 실제 HTTP: 최종 suite에도 `RecommendationSubBannerIntegrationTest` 24개가 실행됐다. 두 추천 GET의 Authorization 없는 200과 `data.subBanners`, 정확한 기존 root 필드/legacy banners 보존, 활성·기간·placement·25개 무절단, 폐기 경로의 handler 부재/인증 404·익명 401, admin 보호 및 삭제 행 보존을 확인했다. 이전 standalone 테스트 클래스는 source/build/report에서 사라졌고 새 클래스의 XML/HTML은 이번 실행에서 갱신됐다. +- 소프트 삭제: 실제 DELETE는 200/data:null을 유지하며 row/is_active=false/기존 payload 보존, 삭제 후 수정·재삭제404, 관리자·추천 조회 제외를 확인했다. 두 EntityManager의 겹치는 트랜잭션에서도 삭제 상태가 true로 돌아가지 않는 회귀를 유지했다. 앱·관리자에 isActive 필드나 복구 API를 추가하지 않았다. +- 리뷰: P5의 유일한 동시성 blocker는 재현 RED→DynamicUpdate GREEN 후 ADDRESSED/APPROVE. P6 독립 리뷰는 추가 차단 없이 APPROVE다. 각각의 검토와 별개로 부모가 전체 실행 결과·변경 파일·문서를 확인했다. +- 증거 위치: `build/reports/tests/test/index.html`, `build/test-results/test/TEST-*.xml`. 실제 응답은 RecommendationSubBannerIntegrationTest/AdminSubBannerControllerIntegrationTest XML의 HTTP EVIDENCE/DB EVIDENCE, 동시 경계는 SubBannerPersistenceTest XML의 CONCURRENCY EVIDENCE에 남는다. 테스트 토큰은 출력하지 않았다. +- 이전 중단 구분: P5 첫 전체 실행은 20분 도구 제한, 두 번째 진행 로그 실행은 사용자 P6 범위 변경 때문에 취소했다. 둘 다 통과 증거로 사용하지 않았으며 최종 P5+P6 전체 실행만 이번 완료 판정에 사용했다. +- 운영 한계: DDL 파일만 생성·보완했으며 native MySQL·운영 DB·실제 AWS·앱/관리자 UI·배포는 수행하지 않았다. 서버 배포 전에 현재 sub_banner/is_active DDL 준비가 필요하다. 기존 테이블을 이미 별도로 생성한 환경은 배포 담당자가 스키마 차이를 확인해야 하며 자동 migration/운영 변경은 하지 않는다. +- 종료 영수증: tracked/new 파일 공백 검사 통과, PRD/plan 미완료 체크박스 없음, production source에 폐기 `/api/v2/sub-banners` 경로 없음. 테스트 wrapper/worker·embedded Redis 잔존 없음을 확인한 뒤 이번 소유 `subbanner-soft-delete.wmREV2` root와 child root·note·진행 로그 스크립트·cache를 제거하고 경로 부재를 확인했다. 공유 Gradle daemon/cache·기존 worktree·소스·JAR·build 테스트 보고서는 보존했다. 커밋·푸시는 하지 않았다. + +### 커밋 전 영향 범위 재검증, 2026-10-29 + +- 무엇을·왜: 커밋할 최종 변경의 서브 배너·홈·콘텐츠 추천 경계를 재검증했다. 제품 코드와 테스트는 수정하지 않았다. +- 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*' ktlintCheck bootJar --console=plain`. +- 결과: 종료 코드 0, `BUILD SUCCESSFUL in 5m 5s`. 최신 HTML/XML에서 161 tests, failures/errors/skipped 0을 확인했다. 테스트는 재실행됐으며 lint·컴파일·bootJar는 입력 변경이 없어 UP-TO-DATE였다. +- 전체 회귀 생략: 이전 최종 Gate의 전체 2,682개 통과 이후 제품 코드 변경이 없어 전체 suite를 반복하지 않았다. 이번 영향 범위 실행으로 `build/reports/tests/test/index.html`과 XML은 161개 결과로 갱신됐으며, 과거 전체 결과와 구분한다. +- 운영 한계: 운영 DB·native MySQL·실제 AWS·앱/관리자 UI·배포는 이번에도 검증하지 않았다. 서버 배포 전에 현재 DDL 준비가 필요하다. From e66512c05539a20ad7b167c99949597b8fe95b5c Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:24:40 +0900 Subject: [PATCH 13/13] =?UTF-8?q?docs(agents):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EB=B3=80=EA=B2=BD=EC=9D=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=AA=85=ED=99=95?= =?UTF-8?q?=ED=9E=88=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-guides/문서유지보수.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/agent-guides/문서유지보수.md b/docs/agent-guides/문서유지보수.md index 04156143..9262a4ef 100644 --- a/docs/agent-guides/문서유지보수.md +++ b/docs/agent-guides/문서유지보수.md @@ -37,7 +37,9 @@ - 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. - `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. - 연속된 하나의 작업에 대해 PRD 또는 구현 계획/TASK 문서가 여러 개 생기지 않도록 기존 문서 재사용 여부를 먼저 확인한다. -- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다. +- 문서 전용 변경은 문서 간 정합성 대조와 공백 검사까지만 수행한다. `./gradlew tasks --all`, 테스트, 빌드는 실행하지 않는다. +- 코드·빌드 설정·실행 스크립트가 함께 변경되거나 사용자가 실행 검증을 명시적으로 요청한 경우에만 변경 범위에 필요한 명령을 선택해 검증한다. + 문서의 명령 예시나 향후 구현 단계의 검증 명령을 추가한 것만으로는 실행 검증을 요구하지 않는다. 생략한 검증과 사유는 작업 기록에 남긴다. - 운영 DB 반영용 DDL 문서는 MySQL 기준으로 작성한다. - DDL 작성 시 날짜/시간 표시 컬럼은 `TIMESTAMP` 타입을 사용하고, `created_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각'`, `updated_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각'` 형식을 기본으로 한다. - DDL의 모든 컬럼에는 MySQL `COMMENT`를 추가하고, 테이블에도 가능한 경우 `COMMENT`를 남긴다.