프론트 계약에 맞춘 공고, 서평 및 알림 API 수정 - #23
Merged
Merged
Conversation
프론트가 이미 보내고 있는 파라미터와 필드를 백엔드가 받지 못하고 있었다. 카테고리 손실 수정 - CampaignCategory를 프론트 CATEGORIES(9종)와 1:1로 맞춤. 기획/디자인· 자기계발·예술/디자인·학습/교육이 모두 ETC로 저장돼 다시 읽으면 '기타'가 되던 손실을 없앤다 - 라벨을 enum이 직접 들고 있어 입력 파싱과 출력 라벨이 어긋날 수 없다 - 기존 행을 읽을 수 있도록 SCIENCE는 @deprecated로 남긴다 쿼리 파라미터 enum 바인딩 - StringToEnumConverterFactory 추가. Spring 기본 변환기는 valueOf만 써서 @JsonCreator 별칭이 본문에서만 동작하고 쿼리 파라미터에서는 무시됐다. 프론트는 카테고리를 '자기계발' 같은 라벨로 보내므로 양쪽 규칙을 통일한다 검색 파라미터 - publisher(정확 일치), withinDays(남은 일수 상한), from/to + dateBasis (캘린더가 보는 달), sort=VIEWS/LATEST 추가 - POPULAR는 VIEWS의 별칭으로 남긴다 모집 조건·수집 출처 필드 - capacity, bookFormat, reviewChannels, reviewDueDate, requirements - source, sourceUrl, collectedAt, publishStatus, dedupeKey - applyUrl은 수집 공고가 못 찾는 경우가 있어 nullable로 완화 검수 큐 - 공개 목록/상세/출판사 목록은 PUBLISHED만 노출. DRAFT는 링크를 알아도 404 - 관리자 목록에 publishStatus·query 필터 - PATCH /admin/campaigns/{id}/publish-status, /status 추가 - GET /api/campaigns/category-counts 추가 (홈 카테고리 타일 집계)
프론트에는 화면이 있는데 받을 API가 없던 것들이다.
공지사항 관리 (ROLE_ADMIN)
- GET/POST/PUT/DELETE /api/admin/notices
- 로그인한 관리자를 작성자로 기록하고, 수정 시 작성자·생성시각은 보존한다
- 사용자 화면에는 공지 목록이 있었지만 쓸 수 있는 화면이 없었다
문의 관리 (ROLE_ADMIN)
- GET /api/admin/inquiries (status 필터, 오래된 순)
- POST /api/admin/inquiries/{id}/answer
- 관리자 목록에만 작성자 이메일·닉네임을 채워 내린다(사용자 조회에는 null)
- 작성자 조회는 @EntityGraph로 N+1을 피한다
- 사용자는 문의를 남길 수 있었지만 답변할 화면이 없었다
이메일 수신 동의
- PATCH /api/me/email-subscription
- Member.emailSubscribed는 가입 시에만 정해지고 이후 바꿀 방법이 없었다
서평은 이 서비스의 최종 산출물인데 참여자가 남길 방법이 없었다.
관리자가 대신 입력하는 구조라 확장되지도 않았다.
사용자
- POST /api/reviews — 원문 링크와 함께 제출. 한 공고에 한 번만
(DB 유니크 제약 + 사전 검사)
- PUT/DELETE /api/reviews/{id} — 본인 것만. 수정하면 다시 확인 대기로 돌아간다
- GET /api/reviews/me — 확인 대기·반려된 것도 본인은 볼 수 있다
공개
- GET /api/reviews?campaignId= — 인증(APPROVED)되고 숨기지 않은(VISIBLE)
서평만. 비로그인도 볼 수 있어야 해서 이 경로만 GET을 열었다
(/api/reviews/me와 쓰기 동작은 인증 유지)
관리자
- GET /api/admin/reviews — campaignId·submissionStatus 필터, 오래된 순
- POST /api/admin/reviews/{id}/decision — 인증/반려. 반려는 사유 필수
- PATCH /api/admin/reviews/{id}/status — 노출/숨김
- DELETE /api/admin/reviews/{id}
- ErrorCode에 R001~R003 추가
알림 큐
- 구독 설정은 저장되고 있었지만 알림이 쌓이는 곳이 없어 헤더의 벨이 항상 0이었다
- Notification 엔티티와 GET /api/me/notifications,
POST /{id}/read, POST /read-all 추가
- 공고가 게시되는 순간 구독 조건에 맞는 회원에게 발송(NotificationFanoutService).
검수 대기로 등록하면 보내지 않고, DRAFT → PUBLISHED로 넘어갈 때만 보낸다
(수정할 때마다 다시 보내지 않는다)
- 조건은 축끼리 AND, 축 안의 값은 OR. 아무 조건도 고르지 않았으면 보내지 않는다
- 구독 값이 enum 이름('REVIEWER')으로도 라벨('서평단'·'IT/개발')로도 저장될 수 있어
양쪽을 모두 비교한다
- 남의 알림은 읽음 처리할 수 없다(존재 여부도 알리지 않도록 같은 404로 응답)
이미지 업로드
- POST /api/admin/uploads/images (multipart). 표지를 data URL로 본문에 실어
보내던 것을 실제 업로드로 바꾼다
- ImageStorage 인터페이스 + LocalImageStorage. EmailSender/DiscordSender와 같은
방식으로, 운영에서는 오브젝트 스토리지 구현으로 교체한다
- 원본 파일명을 쓰지 않고 UUID로 저장하며 날짜별 디렉터리로 나눈다
- 확장자·content-type·용량을 모두 검사한다
- ErrorCode에 NT001, U001~U003 추가
dev는 ddl-auto=update라 컬럼·테이블은 자동으로 추가되지만 기존 컬럼의 타입은 바꾸지 않는다. campaign.category가 MySQL ENUM이라 새 카테고리 값을 넣으면 실패하므로, 데이터가 있는 DB에 필요한 ALTER를 정리해 둔다.
프론트가 카테고리 값 공간을 백엔드 enum과 1:1로 맞춰 두었다. 라벨은 표시용이라 바뀔 수 있고, 프론트가 필터 쿼리에 그대로 되돌려 보내는 값이기도 하다. - CampaignResponse.category와 category-counts의 키를 enum 이름으로 - 라벨은 CampaignCategory.getLabel()로 남겨 두고 표시에만 쓴다
신청 버튼을 누르면 외부 폼으로 나가기 때문에 서비스는 실제 신청 여부를 알 수 없다.
그래서 누가 무엇에 신청했는지 아무 기록도 남지 않았고, 신청 → 발표 → 서평으로
이어지는 흐름의 가운데가 비어 있었다. 사용자가 직접 표시하는 자기 신고로 채운다.
- GET /api/me/applications — 발표일·서평 마감을 바로 보여줄 수 있도록
공고 정보를 통째로 담아 반환. status로 걸러 볼 수 있다
- GET /api/me/applications/ids — 카드·상세의 '신청함' 표시용
- POST /api/me/applications — 신청 표시 (기본 APPLIED)
- PATCH /api/me/applications/{campaignId} — 발표 결과를 직접 표시 (당첨/미당첨)
- DELETE /api/me/applications/{campaignId} — 표시 해제
- 한 사람이 한 공고에 1건 (유니크 제약 + 사전 검사)
- 회원별로 격리된다. 남의 기록은 보이지도 바꿀 수도 없다
- ErrorCode에 AP001·AP002 추가
CD.yml이 deploy/docker-compose.yml, deploy/Caddyfile 을 복사하려 했는데
레포에는 deploy/dev/* 만 있어서 scp 스텝이 실패하는 상태였다. 운영용 구성을
실제로 만들고 경로를 맞춘다.
deploy/prod 신규
- caddy + web(SPA) + app + mysql + redis. dev와 달리 소스 빌드 없이
GHCR 이미지를 pull 한다
- TLS는 Caddy가 자동 발급·갱신 (dev의 certbot 수동 갱신을 대체)
- SPRING_JPA_HIBERNATE_DDL_AUTO를 주지 않는다. application-prod.yml이
ddl-auto: none 이므로 스키마는 마이그레이션으로만 바꾼다
- /actuator/* 는 Caddy가 404로 막는다. 헬스체크는 컨테이너 내부에서만 돈다
(prometheus 엔드포인트가 외부에 열려 있었을 것)
- application-prod.yml은 ${DB.PROD.URL}처럼 점이 든 이름을 읽는데 셸 변수로는
점을 쓸 수 없다. .env에는 밑줄 이름으로 담고 compose가 점 이름으로 바꿔
컨테이너에 넣는다 (상대 바인딩에 기대지 않음)
- 업로드 이미지는 uploads_data 볼륨으로 뺀다. 컨테이너 안에 쓰면 재배포 때 사라진다
- backup-mysql.sh: mysqldump + 14일 보관. 0바이트면 실패로 처리
CD.yml
- scp 경로를 deploy/prod/* 로, strip_components 2로 수정
- .env.images 에서 APP_IMAGE_TAG 키만 갈아끼운다. 프론트 CD가 같은 파일의
WEB_IMAGE_TAG 를 쓰므로 서로 덮어쓰면 안 된다
팀이 dev에서 이미 nginx를 쓰고 있어 도구를 하나로 통일한다.
Caddy가 해주던 인증서 자동 갱신은 cron + renew-cert.sh 로 대체한다.
- deploy/prod/Caddyfile 삭제, nginx.conf 추가
- /api/* → app:8080, /uploads/ → app:8080, /actuator/ → 404, 그 외 → web:80
- server_name _ 로 두고 인증서는 --cert-name bookpool 로 고정해
도메인이 바뀌어도 이 파일은 그대로다
- client_max_body_size 10m (표지 이미지 업로드 5MB가 기본값 1m에 걸린다)
- init-cert.sh: 최초 발급. nginx.conf가 인증서를 참조하므로 nginx를 띄우기 전에
standalone 방식으로 받아야 한다
- renew-cert.sh: webroot 방식 갱신 + nginx -s reload. cron 하루 2회
- compose에서 caddy 서비스 제거, nginx 서비스로 교체.
certs/ · certbot-www/ 는 바인드 마운트 (백업·확인이 쉽다)
- 항상 통과하던 가짜 헬스체크는 제거했다. 진입점이라 죽으면 바로 드러나고,
301/404를 성공으로 볼지 애매해 거짓 신호가 되기 쉽다
- CD.yml이 복사할 파일 목록을 nginx.conf와 스크립트들로 갱신
/bookpool -> /home/park/bookpool 로 루트에서 바로 보이게 하는 선택 절차. docker compose 는 입력한 디렉터리 이름으로 프로젝트명을 정하므로 링크 이름을 반드시 bookpool 로 맞춰야 한다. 다른 이름을 주면 별개 프로젝트로 인식돼 빈 MySQL이 새로 뜬다.
심볼릭 링크 대신 루트에 실제 디렉터리를 둔다. 배포 계정(park)이 CD로 파일을 쓰므로 chown 이 필요하다. - CD.yml: scp target 과 작업 디렉터리를 /bookpool 로. 홈에 만들던 mkdir 제거 (디렉터리는 서버 준비 단계에서 chown 과 함께 만든다) - cron 예시·주석의 경로를 /bookpool 로 통일 - README: 심볼릭 링크 안내를 실디렉터리 생성으로 교체 디렉터리 이름이 그대로 compose 프로젝트명이 되므로 bookpool 을 유지해야 한다. 볼륨명(bookpool_mysql_data)도 여기서 나온다.
이 기능은 책 서평을 옮겨 적는 곳이 아니라 모집에 참여한 경험을 남기는 곳이다. 서평을 아직 안 썼거나 링크를 공개하고 싶지 않은 참여자도 후기를 남길 수 있어야 해서 channel과 url을 선택으로 바꾼다. 엔티티의 두 컬럼은 원래 nullable이라 스키마 변경은 없다.
공개 조회가 APPROVED만 내려줬는데 승인 화면이 백오피스 내비게이션에 없어 도달할 수 없었다. 결과적으로 후기를 남겨도 공고에 영영 뜨지 않았다. 노출 여부는 ReviewStatus 하나가 쥐게 하고, submissionStatus는 서평 원문을 확인해 줬는지만 나타내는 표시로 남긴다. 사전 승인을 없애면서 생기는 두 구멍을 함께 막는다. - reject()가 submissionStatus만 바꿔 반려한 후기가 그대로 공개되던 것 -> HIDDEN으로 내린다 - 반려로 가려진 후기를 사유대로 고쳐도 계속 가려져 있던 것 -> update()에서 다시 VISIBLE로
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📄 작업 내용
channel,url)를 선택으로, 공개 조회에서 사전 승인 조건 제거✅ PR 유형
🧪 테스트 내용
API 테스트 8개 파일 추가 (
ReviewApiTest,NotificationApiTest,ApplicationApiTest등).🙋🏻 참고 사항
ReviewStatus.HIDDEN으로 내립니다.submissionStatus는 이제 노출 여부가 아니라 서평 원문을 확인해 줬는지만 나타냅니다.reject()가HIDDEN으로 내리고, 작성자가 내용을 고치면update()가 다시VISIBLE로 되돌립니다.🔗 관련 이슈