Skip to content

[Feat/#186] 아카이빙 등록·수정·삭제·노출 변경 관리자 API 추가 - #196

Open
sangrae2325 wants to merge 6 commits into
feat/#187-archive-related-targetfrom
feat/#186-admin-archive-write
Open

sangrae2325 wants to merge 6 commits into
feat/#187-archive-related-targetfrom
feat/#186-admin-archive-write

Conversation

@sangrae2325

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

🎯 해결하려는 문제가 무엇인가요?

운영진 콘솔에서 아카이빙을 등록·수정·삭제하고, 슬랑제 페이지 노출 여부를 한 번에 바꾸는 관리자 API 4개를 추가합니다.

POST   /v1/admin/archives
PUT    /v1/admin/archives/{archiveId}
DELETE /v1/admin/archives/{archiveId}
PATCH  /v1/admin/archives/exposure

아카이빙 관리자 API의 마지막 작업입니다.

❓ 왜 해결해야 하나요?

지금은 아카이빙을 넣을 방법이 DB 직접 수정뿐입니다. #40이 학생 앱 조회만 만들었고 archive 도메인에 쓰기가 하나도 없습니다. #188이 조회와 노출·삭제 컬럼을, #191이 행사 연결을 끝냈으니 이 PR이 그 위에 쓰기를 올립니다.

노출 변경은 목록의 체크박스 일괄 변경과 행별 드롭다운 변경에 같은 엔드포인트를 씁니다 — 행별 변경은 식별자가 하나인 요청입니다.

⭐ 어떻게 해결했나요?

커밋 6개로 나눴고 각각 단독으로 컴파일됩니다. 마이그레이션은 없습니다 — #188(V27)과 #191(V28)이 컬럼을 모두 넣습니다.

1. 도메인 쓰기 — Archive.create·update·changeExposure와 ArchiveWriteCommand.

등록과 수정이 담는 값이 같아 Command를 하나로 둡니다. 수정이 전체 필드를 통째로 바꾸는 방식이라 등록과 모양이 같고, 둘로 나누면 같은 선언이 두 벌 생깁니다. 요청 record도 같은 이유로 하나입니다.

2. 저장소 쓰기 경로 — save·saveAll·delete·findAllByIds와 @DynamicUpdate.

삭제는 NoticeRepositoryImpl.delete와 같이 관리 상태 엔티티를 읽어 표시만 바꿉니다. 더티 체킹이 is_deleted 한 컬럼만 UPDATE합니다.

3~4. 서비스·API — AdminArchiveService에 네 동작, 사진·행사 확인은 AdminArchiveWriteUseCase가 조합합니다(architecture.md 6-1절).

검증을 네 갈래로 나눴습니다

무엇 어디 응답
입력 형식 (필수·길이·개수) 요청 record 어노테이션 INVALID_INPUT
값 사이의 규칙 (종료일 ≥ 시작일) Archive.validatePeriod INVALID_ARCHIVE_PERIOD
다른 도메인 조회가 필요한 것 (사진·행사 존재) AdminArchiveWriteUseCase FILE_NOT_FOUND / EVENT_NOT_FOUND
대상 존재 (아카이빙) AdminArchiveService ARCHIVE_NOT_FOUND

기간 검증만 요청 어노테이션으로 표현할 수 없습니다 — 값 하나의 형식이 아니라 두 값 사이의 관계라서요.

🧩 이 PR의 한계 & 트레이드오프

  • 수정과 삭제가 겹치는 경로는 @DynamicUpdate에 기대고 있습니다. 수정이 같은 트랜잭션에서 findById로 엔티티를 영속성 컨텍스트에 올린 뒤 merge하므로, 도메인이 들고 있지 않은 is_deleted는 읽어 둔 값 그대로여서 변경으로 잡히지 않고 UPDATE에서 빠집니다. 수정 경로가 "먼저 읽고 나중에 저장"하는 한 유효하고, 읽지 않고 바로 merge하는 경로가 생기면 깨집니다
  • 등록 응답에 만들어진 식별자를 담지 않습니다. 등록하면 목록으로 돌아가는 흐름이라 필요가 없습니다. 바로 상세로 이동하게 되면 archiveId를 돌려줘야 합니다
  • 사진을 최소 한 장으로 강제합니다. 명세는 선택이지만 Archive.thumbnailFileId()가 "사진 배열의 첫 장이 대표"라서, 비우면 학생 앱 목록에서 썸네일이 빈 칸이 됩니다. 비우고 싶다는 요구가 나오면 다시 봅니다
  • 일괄 변경 상한은 100건입니다. 목록에서 체크박스로 고르는 동작이라 한 번에 고를 수 있는 최대를 한 페이지 최대(PageParams의 size 상한)와 같게 뒀습니다
  • DELETE에 숫자가 아닌 archiveId를 넣으면 500입니다. @PathVariable Long이 MethodArgumentTypeMismatchException을 내는데 GlobalExceptionHandler에 핸들러가 없습니다. 저장소 전체 공통 문제라 common-api를 건드려야 해서 범위에서 뺐습니다. 후속 이슈로 보면 좋겠습니다
  • 쓰기 UseCase에 @Transactional을 걸지 않았습니다. 확인은 읽기이고 그 전에 쓰는 것이 없어 실패해도 되돌릴 것이 없습니다. 쓰기는 서비스가 자기 트랜잭션에서 합니다(AdminNoticeWriteUseCase·AdminLockerAssignUseCase와 같은 판단)
  • 테스트 코드는 없습니다. AGENTS.md 원칙에 따랐습니다

⛓️ 기존 기능에 미치는 영향

  • 학생 앱은 바뀌지 않습니다. 쓰기만 더하고 조회 경로는 그대로입니다
  • 삭제가 실제로 동작하기 시작합니다. #188이 넣은 is_deleted 필터가 이 PR 전에는 켤 방법이 없어 확인할 수 없었습니다
  • ArchiveErrorCode에 INVALID_ARCHIVE_PERIOD 하나가 늘고, 기존 코드는 그대로입니다
  • ArchiveJpaEntity에 @DynamicUpdate가 붙습니다. 쓰기 경로가 처음 생기는 자리라 NoticeJpaEntity·LockerJpaEntity와 같은 이유로 답니다

🔀 Edge Case & 실패 시나리오

상황 동작
imageIds가 빈 배열이거나 누락 INVALID_INPUT (400)
archiveIds 누락 INVALID_INPUT (400) — @Size만으로는 null이 통과해 500이 나던 것을 @NotEmpty로 막았다
archiveIds가 101건 INVALID_INPUT (400)
archiveIds에 같은 값이 두 번 통과 — 서비스가 걷어낸다
endDate가 startDate보다 앞섬 INVALID_ARCHIVE_PERIOD (400)
endDate 생략 통과 — 당일 활동이다
없는 사진 파일을 연결 FILE_NOT_FOUND (404)
없는 행사를 연결 EVENT_NOT_FOUND (404)
미게시 행사를 연결 통과 — 운영진 드롭다운이 미게시도 보여준다
없는/삭제된 아카이빙 수정·삭제 ARCHIVE_NOT_FOUND (404)
일괄 변경에 없는 아카이빙이 하나라도 섞임 ARCHIVE_NOT_FOUND (404), 아무것도 바뀌지 않는다
확인 직후 사진·행사가 삭제됨 상세에서 조용히 빠진다 (연결만 null)

📋 검토한 대안과 선택 이유

  • 등록·수정 Command를 둘로 두기: 처음엔 그렇게 만들었다가 합쳤습니다. 필드가 완전히 같아 선언이 두 벌이 됩니다. 선례인 LockerPeriodCreateCommand/UpdateCommand는 실제로 필드가 달랐습니다(수정에 연도·학기가 없음)
  • 중복 식별자를 요청에서 걷어내기: LockerStatusChangeRequest.distinctLockerIds() 선례가 있지만, 같은 PR의 파일 존재 확인이 Set.copyOf로 스스로 보장하고 있어 기준을 맞춰 서비스로 옮겼습니다
  • 삭제를 하드 삭제로: 실수로 지운 아카이빙을 되살릴 수 있어야 하고, 저장소의 다른 테이블도 소프트 삭제입니다
  • 노출 변경을 PUT으로: 한 필드만 바꾸는 부분 변경이라 PATCH가 맞습니다
  • 식별자에 "슬랑제"를 넣기: 아카이빙이 노출될 곳은 슬랑제 페이지뿐이라 isExposed로 충분하고, 어디에 노출되는지는 주석과 javadoc이 설명합니다. 컬럼·경로·필드를 스택 전체에서 같은 기준으로 맞췄습니다

💬 리뷰 포인트

  • [c] @DynamicUpdate가 삭제 되살아남을 막는 근거가 "수정 경로가 먼저 읽는다"에 걸려 있습니다. 지금 update는 findById → save 순서라 안전한데, 이 전제가 코드로 강제되지는 않습니다. 더 단단히 가려면 Archive가 삭제 여부를 들고 있거나 BaseSoftDeleteEntity를 손봐야 하는데 둘 다 이 PR 범위를 넘습니다
  • [c] 사진 최소 한 장이 맞는지 봐주세요. 명세는 선택인데 대표 이미지가 사진 배열의 첫 장이라 강제하는 쪽으로 갔습니다
  • [a] 경로·필드 이름이 명세와 다릅니다. seulrangje-exposure → /exposure, isSeulrangjeExposed → isExposed. 스택 전체를 같이 바꿨고 이슈 본문도 맞췄는데, 관리자 프론트가 명세를 보고 붙였다면 함께 맞춰야 합니다

@SiZe는 null을 통과시켜 archiveIds를 빠뜨린 요청이 NPE로 500이 났다. @notempty를 함께 걸어
400 INVALID_INPUT으로 나가게 한다. 같은 PR의 imageIds는 이미 @notempty를 쓰고 있어 기준이 갈려 있었다.

함께 정리한 것
- 중복 식별자 제거를 요청에서 서비스로 옮겨, 스스로 보장하는 파일 존재 확인과 기준을 맞춘다
- 등록·수정 Command가 필드까지 같아 ArchiveWriteCommand 하나로 합친다
- ArchiveJpaEntity의 @DynamicUpdate 주석을 바로잡는다. 수정은 같은 트랜잭션에서 엔티티를 올린 뒤
  merge하므로 is_deleted가 변경으로 잡히지 않아 UPDATE에서 빠진다 — 삭제가 되돌아가지 않는다
- import 순서 정리
@sangrae2325 sangrae2325 self-assigned this Oct 29, 2026
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

아카이빙 등록·수정·삭제·노출 변경 관리자 API 추가

1 participant