Skip to content

[Feat/#103] 게시판(공지) 관리자 API 추가 - #104

Open
jjunh33 wants to merge 7 commits into
mainfrom
feat/#103-admin-notice-api
Open

jjunh33 wants to merge 7 commits into
mainfrom
feat/#103-admin-notice-api

Conversation

@jjunh33

@jjunh33 jjunh33 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

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

운영진 콘솔에서 공지를 등록·수정·삭제하고 상단 고정을 바꾸는 관리자 API를 추가합니다. 지금은 학생 앱의 공지 조회 API만 있어서 공지를 넣을 방법이 DB 직접 입력뿐입니다.

API 경로
공지 작성 POST /v1/admin/notices
공지 수정 PUT /v1/admin/notices/{noticeId}
공지 삭제 DELETE /v1/admin/notices/{noticeId}
공지 고정 설정/해제 PATCH /v1/admin/notices/{noticeId}/pin

❓ 왜 해결해야 하나요?

학생 앱이 읽는 데이터를 운영진이 직접 관리할 수 없는 상태입니다. 관리자 콘솔(준비 중)이 붙으려면 이 API가 먼저 있어야 합니다.

⭐ 어떻게 해결했나요?

  • 권한: SecurityConfig가 /v1/admin/**를 ADMIN으로 1차 인가하고, 컨트롤러의 AdminApiUser 리졸버가 ADMIN role을 한 번 더 확인합니다. 부서 제한(@RequireDepartment)은 두지 않았습니다.
  • 작성: 이미지·첨부 파일 ID가 모두 존재하는지 확인한 뒤 저장합니다. 공지(welfare)와 파일(file)은 서로 다른 도메인이라 AdminNoticeWriteUseCase가 두 Service를 조합합니다. 새 공지는 고정되지 않은 상태로 만들어지고, 작성자는 요청 본문이 아니라 토큰의 관리자 ID입니다.
  • 수정: 요청의 updatedAt(KST 기준 epoch 밀리초)이 저장된 updated_at과 다르면 409 NOTICE_UPDATE_CONFLICT로 거부합니다. updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교합니다.
  • 삭제: is_deleted만 바꾸는 소프트 삭제입니다. 연결된 파일은 지우지 않습니다.
  • 고정: {pinned} 값으로 설정/해제합니다. 이미 그 상태여도 200입니다.
  • Notice 도메인이 updatedAt을 갖게 했고, NoticeCreateCommand·NoticeUpdateCommand를 추가했습니다.
  • admin-api가 core:domain:file에 의존합니다(파일 존재 확인).

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

  • 수정 충돌 검사가 완전히 원자적이지 않습니다. 한 트랜잭션 안에서 시각을 비교하고 저장하므로, 화면을 열어 둔 사이의 수정은 막지만 두 관리자가 밀리초 단위로 동시에 저장하면 뒤의 요청이 덮어쓸 수 있습니다. 완전히 막으려면 비관적 락이 필요한데 현재 코드베이스에 없는 패턴이라 넣지 않았습니다.
  • updatedAt을 받아올 조회 API가 없습니다. 관리자 상세 조회가 없고 학생 앱 상세 응답에도 updatedAt이 없어서, 이 PR만으로는 프론트가 수정 시각을 알 수 없습니다. 조회 API가 같은 방식(KST 기준 epoch 밀리초)으로 내려줘야 합니다. 어긋나면 수정이 계속 409가 됩니다.
  • 고정 변경도 updated_at을 바꿉니다. 다른 관리자가 고정을 바꾸면 열려 있던 수정 화면은 409를 받습니다.
  • 수정 응답이 명세대로 noticeId만 줘서, 이어서 다시 수정하려면 공지를 다시 조회해야 합니다.
  • 로컬에 DB가 없어 실제 MySQL로는 확인하지 못했습니다. 서비스 로직(충돌·404·삭제·고정)과 요청 검증은 임시 테스트로 확인했고 커밋하지 않았습니다. 소프트 삭제 반영과 JSON 컬럼 저장은 dev에서 확인이 필요합니다.
  • 본문(HTML)은 서버에서 정화하지 않고 그대로 저장합니다.
  • 이미지·첨부 파일은 존재만 확인합니다. 파일 카테고리가 맞는지(NOTICE_IMAGE/NOTICE_ATTACHMENT)는 검사하지 않습니다.

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

  • Notice.of(...)에 updatedAt 인자가 추가됐습니다(호출처는 NoticeJpaEntity.toDomain 한 곳). 학생 앱 공지 조회 동작은 그대로입니다.
  • NoticeService·NoticeRepository에 메서드가 추가됐습니다.
  • 스키마 변경·마이그레이션은 없습니다.
  • 새 공개 경로는 없고 /v1/admin/** 아래에만 추가됩니다.

🔀 Edge Case & 실패 시나리오

상황 응답
제목 공백·200자 초과, 본문 공백·16,000자 초과 400 INVALID_INPUT
category가 enum에 없는 값 400 INVALID_INPUT ("요청 본문 형식이 올바르지 않습니다.")
이미지·첨부 목록이 null이거나 null 원소 포함 400 INVALID_INPUT
수정에서 updatedAt 누락 400 INVALID_INPUT
존재하지 않는 파일 ID 포함 404 FILE_NOT_FOUND
없거나 이미 삭제된 공지 (수정·삭제·고정) 404 NOTICE_NOT_FOUND
요청의 updatedAt이 저장된 값과 다름 409 NOTICE_UPDATE_CONFLICT
토큰 없음 / ADMIN 아님 401 / 403
  • 이미지·첨부 목록은 빈 배열을 허용합니다. 같은 ID가 중복되면 순서를 유지한 채 하나로 줄입니다.
  • 요청 본문에 createdBy가 와도 무시합니다(부트 기본값이 알 수 없는 필드를 허용).

📋 검토한 대안과 선택 이유

  • 작성자를 요청 본문의 createdBy로 받기: 명세에는 있었지만 다른 관리자 이름으로 쓸 수 있어 제외하고 토큰의 관리자 ID를 씁니다.
  • 수정 충돌을 비관적 락(SELECT ... FOR UPDATE)으로 막기: 정확하지만 현재 코드베이스에 없는 패턴이고 락 정책을 정해야 해서 제외했습니다. 필요해지면 findById를 잠금 조회로 바꾸면 됩니다.
  • @Version 컬럼: 명세가 수정 시각(updatedAt)을 받도록 되어 있고 마이그레이션이 필요해 제외했습니다.
  • UseCase 없이 컨트롤러가 FileService를 직접 호출: 공지와 파일이 서로 다른 도메인 조합이라 컨벤션(6-1절)에 따라 UseCase를 두었습니다. 삭제·고정은 단일 도메인이라 컨트롤러가 직접 호출합니다.
  • 하드 삭제: 학생 앱 조회가 is_deleted를 기준으로 하고 기존 공지 설계가 소프트 삭제라 맞췄습니다.

💬 리뷰 포인트

  • [r] AdminNoticeController에 부서 제한이 없습니다. ADMIN이면 누구나 공지를 다룹니다. 홍보부 등으로 제한할지 정해 주세요(@RequireDepartment 한 줄).
  • [r] updatedAt 변환이 KST 가정입니다(NoticeUpdateRequest). DB와 앱의 타임존 설정이 코드에 없어서, 조회 API를 만들 때 같은 변환을 써야 합니다.
  • [c] NoticeServiceImpl.delete는 entity.delete()를 변경 감지로 저장하므로 @Transactional이 필수입니다. dev에서 실제로 삭제가 반영되는지 확인해 주세요.
  • [c] 본문 길이 제한 16,000자(TEXT 컬럼 65,535바이트 기준)가 에디터 사용 방식에 맞는지 확인해 주세요. base64 이미지를 본문에 넣는 에디터라면 너무 작습니다.
  • [a] 명세와 다른 점: 검증 오류 코드가 INVALID_INPUT이고(명세는 VALIDATION_ERROR), FILE_NOT_FOUND 메시지가 기존 문구("존재하지 않는 파일입니다.")입니다.

수정은 요청의 수정 시각이 저장된 값과 다르면 NOTICE_UPDATE_CONFLICT로 거부한다.
Notice에 수정 시각을 노출하고, 삭제는 소프트 삭제로 처리한다.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

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

⚙️ Run configuration
  • Configuration used: Repository: billilge/stream-server/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 37ce8286-2caf-44e5-9d2e-aadfe1e7e222

  • 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.

}

private void requireFilesExist(List<Long> imageIds, List<Long> attachmentIds) {
Set<Long> fileIds = new LinkedHashSet<>(imageIds);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkedHashSet 자료구조를 사용한 이유가 있나요?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일 ID의 중복을 막기 위해 Set을 사용했습니다. 다만 현재 구조에서는 넣은 순서를 기억할 필요가 없기 때문에 LinkedHashSet 대신 HashSet으로 수정했습니다!

AdminApiUser apiUser,
@Valid @RequestBody NoticeCreateRequest request
) {
Notice notice = adminNoticeWriteUseCase.create(request.toCommand(apiUser.userId()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apiUser.userId()는 Command 객체 안에 넣지 말고, usecase의 별도 인자로 받는 게 응집, 가독성 측면에서 좋을 거 같은데 어떻게 생각하시나요?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동의합니다! 반영해서 수정했습니다~

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 추가

2 participants