Skip to content

프론트 계약에 맞춘 공고, 서평 및 알림 API 수정 - #23

Merged
pug9483 merged 13 commits into
devfrom
feat/fe-contract
Aug 30, 2026
Merged

pug9483 merged 13 commits into
devfrom
feat/fe-contract

Conversation

@pug9483

@pug9483 pug9483 commented Aug 30, 2026

Copy link
Copy Markdown
Member

📄 작업 내용

  • 공고 검색·모집 조건을 프론트 계약에 맞춤 (출판사 정확 일치, 마감 필터, 날짜 범위, 모집 인원·도서 형태·서평 채널)
  • 서평 제출·인증 도메인 추가
  • 알림 큐와 이미지 업로드 추가
  • 백오피스 공지·문의 관리, 이메일 수신 동의 토글 추가
  • 신청 상태 추적 추가
  • 카테고리를 한국어 라벨 대신 enum 이름으로 응답 (프론트가 필터 쿼리에 그대로 되돌려 보내는 값)
  • 참여 후기 전환 — 서평 링크(channel, url)를 선택으로, 공개 조회에서 사전 승인 조건 제거
  • 운영 배포 구성(Nginx + certbot)과 CD 경로 정리

✅ PR 유형

  • Feature
  • Bug Fix
  • Refactor
  • Test
  • Chore
  • Docs

🧪 테스트 내용

  • 로컬 테스트 완료
  • API 테스트 완료
  • UI 테스트 완료

API 테스트 8개 파일 추가 (ReviewApiTest, NotificationApiTest, ApplicationApiTest 등).

🙋🏻 참고 사항

  • 참여 후기는 사후 검열로 바뀌었습니다. 남기면 바로 공고에 노출되고, 부적절한 건은 관리자가 ReviewStatus.HIDDEN으로 내립니다. submissionStatus는 이제 노출 여부가 아니라 서평 원문을 확인해 줬는지만 나타냅니다.
  • 그에 맞춰 reject()HIDDEN으로 내리고, 작성자가 내용을 고치면 update()가 다시 VISIBLE로 되돌립니다.

🔗 관련 이슈

pug9483 added 13 commits August 29, 2026 21:11
프론트가 이미 보내고 있는 파라미터와 필드를 백엔드가 받지 못하고 있었다.

카테고리 손실 수정
- 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로
@pug9483 pug9483 self-assigned this Aug 30, 2026
@pug9483 pug9483 added feature 새로운 기능 추가 bug 버그 수정 및 오류 대응 refactor 기능 변화 없는 구조 개선 chore 설정, 빌드, 패키지, 환경 정리 등 기타 작업 docs 0e8a16 test 테스트 추가 및 테스트 코드 개선 labels Aug 30, 2026
@pug9483
pug9483 merged commit 3c8f46a into dev Aug 30, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 버그 수정 및 오류 대응 chore 설정, 빌드, 패키지, 환경 정리 등 기타 작업 docs 0e8a16 feature 새로운 기능 추가 refactor 기능 변화 없는 구조 개선 test 테스트 추가 및 테스트 코드 개선

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant