-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Labels
Description
📌 이슈 유형
- ✨ 새 기능 추가 (feat)
- 🐛 버그 수정 (fix)
- 🔧 기능 개선 (refactor)
- 📚 문서 작업 (docs)
- 🧪 테스트 (test)
- 🏗️ 빌드/배포 (ci/build)
- 🔥 긴급 수정 (hotfix)
- 🧹 기타 작업 (chore)
[Feature] 기관 광고(Advertisement) 관리 시스템 구현
📋 이슈 개요
기관이 플랫폼 내에서 광고를 신청하고, 관리자가 승인/거절하여 광고를 게재하는 시스템을 구현합니다.
승인된 광고는 설정된 기간 동안 메인 페이지 등에 노출되어 기관 홍보 효과를 제공합니다.
🎯 핵심 구현 목표
✅ 반드시 구현해야 할 기능
- 기관 광고 신청 - 기관이 광고 유형, 기간, 내용을 선택하여 신청
- 관리자 광고 심사 - 관리자가 신청된 광고를 승인/거절
- 광고 상태 관리 - 광고 대기/진행/종료 상태 자동 관리
- 광고 목록 조회 - 기관/관리자별 광고 목록 조회
- 광고 취소 - 기관이 진행 전 광고 취소 가능
- 광고 연장 - 기존 광고 기간 연장 신청
📊 광고 상태(AdvertisementStatus) 플로우
[기관 신청] → REQUEST_PENDING (승인 대기)
↓
[관리자 심사]
↙ ↘
REQUEST_APPROVED REQUEST_REJECTED
(승인 완료) (승인 거부)
↓ ↓
ADVERTISEMENT_PENDING [종료]
(광고 대기중)
↓
[시작 시간 도래]
↓
ADVERTISEMENT_ACTIVE
(광고 진행중)
↓
[종료 시간 도래]
↓
ADVERTISEMENT_ENDED
(광고 종료)
※ 언제든지 → ADVERTISEMENT_CANCELED (광고 취소) 가능
🎨 광고 유형(AdvertisementType)
| 유형 | 설명 | 노출 위치 | 권장 크기 | 가격(예시) |
|---|---|---|---|---|
| MAIN_BANNER | 메인 페이지 최상단 배너 | 메인 상단 | 1200x400px | 300,000원/주 |
| PREMIUM_LIST | 기관 목록 상단 고정 노출 | 목록 최상위 | - | 200,000원/주 |
| SIDE_BANNER | 사이드바 배너 광고 | 우측 사이드 | 300x600px | 100,000원/주 |
| SEARCH_TOP | 검색 결과 상단 노출 | 검색 최상위 | - | 150,000원/주 |
📋 전체 API 엔드포인트 목록
Part 1: 기관 광고 신청 및 관리
| Method | Endpoint | 설명 | 권한 | 상태 |
|---|---|---|---|---|
| POST | /api/v1/institutions/{institutionId}/advertisements |
광고 신청 | OWNER | ❌ 구현 필요 |
| GET | /api/v1/institutions/{institutionId}/advertisements |
내 기관 광고 목록 | OWNER, MANAGER | ❌ 구현 필요 |
| GET | /api/v1/institutions/{institutionId}/advertisements/{adId} |
광고 상세 조회 | OWNER, MANAGER | ❌ 구현 필요 |
| PATCH | /api/v1/institutions/{institutionId}/advertisements/{adId}/cancel |
광고 취소 | OWNER | ❌ 구현 필요 |
| POST | /api/v1/institutions/{institutionId}/advertisements/{adId}/extend |
광고 기간 연장 신청 | OWNER | ❌ 구현 필요 |
Part 2: 관리자 광고 심사 및 관리
| Method | Endpoint | 설명 | 권한 | 상태 |
|---|---|---|---|---|
| GET | /api/v1/admin/advertisements |
전체 광고 목록 (심사용) | ADMIN | ❌ 구현 필요 |
| GET | /api/v1/admin/advertisements/pending |
승인 대기 광고 목록 | ADMIN | ❌ 구현 필요 |
| GET | /api/v1/admin/advertisements/{adId} |
광고 상세 조회 | ADMIN | ❌ 구현 필요 |
| PATCH | /api/v1/admin/advertisements/{adId}/approve |
광고 승인 | ADMIN | ❌ 구현 필요 |
| PATCH | /api/v1/admin/advertisements/{adId}/reject |
광고 거절 | ADMIN | ❌ 구현 필요 |
| PATCH | /api/v1/admin/advertisements/{adId}/force-end |
광고 강제 종료 | ADMIN | ❌ 구현 필요 |
Part 3: 공개 광고 조회 (프론트엔드 노출용)
| Method | Endpoint | 설명 | 권한 | 상태 |
|---|---|---|---|---|
| GET | /api/v1/advertisements/active |
현재 진행중인 광고 목록 | 공개 | ❌ 구현 필요 |
| GET | /api/v1/advertisements/active/type/{type} |
유형별 진행중 광고 조회 | 공개 | ❌ 구현 필요 |
📊 통계
총 API 개수: 13개
✅ 완료: 0개
❌ 구현 필요: 13개
Part별 개수:
- Part 1 (기관 광고 관리): 5개
- Part 2 (관리자 심사): 6개
- Part 3 (공개 조회): 2개
🔧 상세 구현 사항
1️⃣ 기관 광고 신청
Endpoint
POST /api/v1/institutions/{institutionId}/advertisements
Request DTO
@Schema(description = "광고 신청 요청")
public record AdvertisementCreateRequestDto(
@Schema(description = "광고 유형", example = "MAIN_BANNER")
@NotNull(message = "광고 유형은 필수입니다")
AdvertisementType type,
@Schema(description = "광고 시작 일시", example = "2025-11-20T00:00:00")
@NotNull(message = "시작 일시는 필수입니다")
@Future(message = "시작 일시는 현재 이후여야 합니다")
LocalDateTime startDateTime,
@Schema(description = "광고 종료 일시", example = "2025-11-27T23:59:59")
@NotNull(message = "종료 일시는 필수입니다")
@Future(message = "종료 일시는 현재 이후여야 합니다")
LocalDateTime endDateTime,
@Schema(description = "광고 제목", example = "서울 최고의 요양원")
@NotBlank(message = "광고 제목은 필수입니다")
@Size(max = 100, message = "제목은 100자 이하여야 합니다")
String title,
@Schema(description = "광고 설명", example = "전문 간호사 24시간 상주")
@Size(max = 500, message = "설명은 500자 이하여야 합니다")
String description,
@Schema(description = "광고 배너 이미지 URL", example = "https://...")
String bannerImageUrl
) {
public void validate() {
if (endDateTime.isBefore(startDateTime)) {
throw new BusinessException(ErrorCode.INVALID_ADVERTISEMENT_PERIOD);
}
if (endDateTime.isBefore(LocalDateTime.now().plusDays(1))) {
throw new BusinessException(ErrorCode.ADVERTISEMENT_PERIOD_TOO_SHORT);
}
}
}Response DTO
@Schema(description = "광고 신청 응답")
public record AdvertisementResponseDto(
@Schema(description = "광고 ID")
Long id,
@Schema(description = "기관 ID")
Long institutionId,
@Schema(description = "기관명")
String institutionName,
@Schema(description = "광고 유형")
AdvertisementType type,
@Schema(description = "광고 상태")
AdvertisementStatus status,
@Schema(description = "광고 제목")
String title,
@Schema(description = "광고 설명")
String description,
@Schema(description = "배너 이미지 URL")
String bannerImageUrl,
@Schema(description = "시작 일시")
LocalDateTime startDateTime,
@Schema(description = "종료 일시")
LocalDateTime endDateTime,
@Schema(description = "신청 일시")
LocalDateTime createdAt,
@Schema(description = "승인 일시 (승인된 경우)")
LocalDateTime approvedAt,
@Schema(description = "거절 사유 (거절된 경우)")
String rejectionReason
) {
public static AdvertisementResponseDto from(InstitutionAdvertisement ad) {
return new AdvertisementResponseDto(
ad.getId(),
ad.getInstitution().getId(),
ad.getInstitution().getName(),
ad.getType(),
ad.getStatus(),
ad.getTitle(),
ad.getDescription(),
ad.getBannerImageUrl(),
ad.getStartDateTime(),
ad.getEndDateTime(),
ad.getCreatedAt(),
ad.getApprovedAt(),
ad.getRejectionReason()
);
}
}검증 로직
- 기관 권한 확인: 요청한 사용자가 해당 기관의 OWNER인지 확인
- 기간 유효성 검증:
- 시작일이 현재보다 미래인지
- 종료일이 시작일보다 이후인지
- 최소 광고 기간 충족 (예: 최소 1일)
- 중복 광고 확인: 같은 기간에 같은 유형의 광고가 이미 신청되었는지 확인
- 광고 유형별 제한 확인:
- MAIN_BANNER는 동시에 최대 3개까지만 진행 가능
- PREMIUM_LIST는 동시에 최대 5개까지만 진행 가능
2️⃣ 내 기관 광고 목록 조회
Endpoint
GET /api/v1/institutions/{institutionId}/advertisements?page=0&size=20&status=REQUEST_PENDING
Query Parameters
page: 페이지 번호 (default: 0)size: 페이지 크기 (default: 20)status: 광고 상태 필터 (선택)type: 광고 유형 필터 (선택)
Response
@Schema(description = "광고 목록 응답")
public record AdvertisementListResponseDto(
List<AdvertisementSummaryDto> content,
int page,
int size,
long totalElements,
int totalPages
) {}
@Schema(description = "광고 요약 정보")
public record AdvertisementSummaryDto(
Long id,
AdvertisementType type,
AdvertisementStatus status,
String title,
LocalDateTime startDateTime,
LocalDateTime endDateTime,
LocalDateTime createdAt,
boolean isActive // 현재 진행중인지 여부
) {}3️⃣ 관리자 광고 승인
Endpoint
PATCH /api/v1/admin/advertisements/{adId}/approve
Request DTO
@Schema(description = "광고 승인 요청")
public record AdvertisementApproveRequestDto(
@Schema(description = "승인 메모 (선택)", example = "검토 완료, 승인합니다")
@Size(max = 500, message = "메모는 500자 이하여야 합니다")
String memo
) {}처리 로직
- 광고 상태가
REQUEST_PENDING인지 확인 - 상태를
REQUEST_APPROVED로 변경 - 시작 일시 체크:
- 시작 일시가 현재 이전이면 즉시
ADVERTISEMENT_ACTIVE로 변경 - 시작 일시가 미래면
ADVERTISEMENT_PENDING로 설정
- 시작 일시가 현재 이전이면 즉시
- 승인 일시(
approvedAt) 기록 - 관리자 활동 로그 저장
4️⃣ 관리자 광고 거절
Endpoint
PATCH /api/v1/admin/advertisements/{adId}/reject
Request DTO
@Schema(description = "광고 거절 요청")
public record AdvertisementRejectRequestDto(
@Schema(description = "거절 사유 (필수)", example = "부적절한 이미지 포함")
@NotBlank(message = "거절 사유는 필수입니다")
@Size(max = 500, message = "거절 사유는 500자 이하여야 합니다")
String rejectionReason
) {}처리 로직
- 광고 상태가
REQUEST_PENDING인지 확인 - 상태를
REQUEST_REJECTED로 변경 - 거절 사유 저장
- 기관에 알림 발송 (추후 구현)
5️⃣ 광고 취소
Endpoint
PATCH /api/v1/institutions/{institutionId}/advertisements/{adId}/cancel
Request DTO
@Schema(description = "광고 취소 요청")
public record AdvertisementCancelRequestDto(
@Schema(description = "취소 사유", example = "예산 부족")
@Size(max = 500, message = "취소 사유는 500자 이하여야 합니다")
String cancelReason
) {}검증 로직
- 기관 권한 확인 (OWNER만 가능)
- 광고 상태 확인:
ADVERTISEMENT_ACTIVE(진행중): 취소 불가 → 관리자에게 문의ADVERTISEMENT_PENDING(대기중): 취소 가능REQUEST_PENDING(승인 대기): 취소 가능REQUEST_APPROVED(승인 완료): 시작 전이면 취소 가능
- 상태를
ADVERTISEMENT_CANCELED로 변경
6️⃣ 현재 진행중인 광고 조회 (공개 API)
Endpoint
GET /api/v1/advertisements/active?type=MAIN_BANNER
Query Parameters
type: 광고 유형 필터 (선택)
Response
@Schema(description = "활성 광고 목록")
public record ActiveAdvertisementListDto(
List<ActiveAdvertisementDto> advertisements
) {}
@Schema(description = "활성 광고 정보")
public record ActiveAdvertisementDto(
Long id,
Long institutionId,
String institutionName,
AdvertisementType type,
String title,
String description,
String bannerImageUrl,
LocalDateTime endDateTime // 종료 예정 시간
) {}조회 조건
status = ADVERTISEMENT_ACTIVEstartDateTime <= 현재 시간 <= endDateTimedeleted = false
🔄 광고 상태 자동 전환 (스케줄러)
스케줄러 구현
@Scheduled(cron = "0 */5 * * * *") // 5분마다 실행
public void updateAdvertisementStatus() {
LocalDateTime now = LocalDateTime.now();
// 1. PENDING → ACTIVE (시작 시간 도래)
List<InstitutionAdvertisement> pendingAds = repository
.findByStatusAndStartDateTimeBefore(
AdvertisementStatus.ADVERTISEMENT_PENDING,
now
);
pendingAds.forEach(ad -> ad.updateStatus(AdvertisementStatus.ADVERTISEMENT_ACTIVE));
// 2. ACTIVE → ENDED (종료 시간 도래)
List<InstitutionAdvertisement> activeAds = repository
.findByStatusAndEndDateTimeBefore(
AdvertisementStatus.ADVERTISEMENT_ACTIVE,
now
);
activeAds.forEach(ad -> ad.updateStatus(AdvertisementStatus.ADVERTISEMENT_ENDED));
}🗂️ Entity 수정 사항
InstitutionAdvertisement Entity 추가 필드
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class InstitutionAdvertisement extends BaseEntity {
// 기존 필드...
// 추가 필드
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
private String bannerImageUrl;
private LocalDateTime approvedAt; // 승인 일시
@Column(length = 500)
private String rejectionReason; // 거절 사유
@Column(length = 500)
private String cancelReason; // 취소 사유
@Column(length = 500)
private String adminMemo; // 관리자 메모
// 비즈니스 로직
public void approve(String memo) {
if (this.status != AdvertisementStatus.REQUEST_PENDING) {
throw new BusinessException(ErrorCode.INVALID_ADVERTISEMENT_STATUS);
}
this.status = AdvertisementStatus.REQUEST_APPROVED;
this.approvedAt = LocalDateTime.now();
this.adminMemo = memo;
// 시작 시간이 이미 지났으면 바로 ACTIVE
if (this.startDateTime.isBefore(LocalDateTime.now())) {
this.status = AdvertisementStatus.ADVERTISEMENT_ACTIVE;
} else {
this.status = AdvertisementStatus.ADVERTISEMENT_PENDING;
}
}
public void reject(String reason) {
if (this.status != AdvertisementStatus.REQUEST_PENDING) {
throw new BusinessException(ErrorCode.INVALID_ADVERTISEMENT_STATUS);
}
this.status = AdvertisementStatus.REQUEST_REJECTED;
this.rejectionReason = reason;
}
public void cancel(String reason) {
if (this.status == AdvertisementStatus.ADVERTISEMENT_ACTIVE) {
throw new BusinessException(ErrorCode.CANNOT_CANCEL_ACTIVE_ADVERTISEMENT);
}
if (this.status == AdvertisementStatus.ADVERTISEMENT_ENDED ||
this.status == AdvertisementStatus.ADVERTISEMENT_CANCELED) {
throw new BusinessException(ErrorCode.ADVERTISEMENT_ALREADY_FINISHED);
}
this.status = AdvertisementStatus.ADVERTISEMENT_CANCELED;
this.cancelReason = reason;
}
public void updateStatus(AdvertisementStatus newStatus) {
this.status = newStatus;
}
public boolean isActive() {
return this.status == AdvertisementStatus.ADVERTISEMENT_ACTIVE &&
LocalDateTime.now().isAfter(this.startDateTime) &&
LocalDateTime.now().isBefore(this.endDateTime);
}
}🎯 ErrorCode 추가
// 광고 관련 에러 코드
ADVERTISEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "AD001", "광고를 찾을 수 없습니다"),
INVALID_ADVERTISEMENT_PERIOD(HttpStatus.BAD_REQUEST, "AD002", "광고 기간이 올바르지 않습니다"),
ADVERTISEMENT_PERIOD_TOO_SHORT(HttpStatus.BAD_REQUEST, "AD003", "광고 기간이 너무 짧습니다 (최소 1일)"),
DUPLICATE_ADVERTISEMENT_PERIOD(HttpStatus.BAD_REQUEST, "AD004", "해당 기간에 이미 신청된 광고가 있습니다"),
INVALID_ADVERTISEMENT_STATUS(HttpStatus.BAD_REQUEST, "AD005", "현재 광고 상태에서는 해당 작업을 수행할 수 없습니다"),
CANNOT_CANCEL_ACTIVE_ADVERTISEMENT(HttpStatus.BAD_REQUEST, "AD006", "진행중인 광고는 취소할 수 없습니다. 관리자에게 문의하세요"),
ADVERTISEMENT_ALREADY_FINISHED(HttpStatus.BAD_REQUEST, "AD007", "이미 종료되었거나 취소된 광고입니다"),
ADVERTISEMENT_TYPE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "AD008", "해당 광고 유형의 동시 진행 한도를 초과했습니다"),
ADVERTISEMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "AD009", "광고에 접근할 권한이 없습니다");✅ 구현 우선순위
1단계: 기본 광고 신청 및 승인 (필수)
- ✅ Entity 수정 (필드 추가, 비즈니스 로직)
- ✅ 광고 신청 API
- ✅ 내 기관 광고 목록 조회
- ✅ 관리자 승인/거절 API
- ✅ 광고 상세 조회
2단계: 광고 관리 및 조회 (중요)
- ✅ 광고 취소 API
- ✅ 현재 진행중인 광고 조회 (공개)
- ✅ 관리자 전체 광고 목록
- ✅ 스케줄러로 상태 자동 전환
3단계: 고급 기능 (선택)
- 광고 기간 연장 신청
- 관리자 광고 강제 종료
- 광고 통계 대시보드
📝 추가 고려사항
1. 광고 이미지 업로드
- 광고 신청 시 배너 이미지 업로드 필요
- 이미지 크기 및 형식 제한 (예: 최대 5MB, JPG/PNG)
- 기존 FileService 활용
2. 광고 비용 계산
- 광고 유형별 가격 정책 (추후 구현)
- 결제 연동 (추후 확장)
3. 알림 발송
- 광고 승인/거절 시 기관에 알림
- 광고 시작/종료 알림
4. 통계 및 분석
- 광고 조회수 추적
- 광고 클릭수 추적
- 광고 효과 분석 대시보드
🧪 테스트 시나리오
기관 테스트
-
광고 신청 성공
- OWNER 권한으로 광고 신청
- 유효한 기간 입력
- 광고 제목, 설명, 이미지 포함
-
광고 신청 실패
- 종료일이 시작일보다 이전
- 시작일이 과거
- 중복 기간 광고 존재
-
광고 취소
- PENDING 상태 광고 취소 성공
- ACTIVE 상태 광고 취소 실패
관리자 테스트
-
광고 승인
- 승인 대기 광고 승인
- 즉시 시작 (시작일이 과거인 경우)
-
광고 거절
- 거절 사유 입력 필수
- 거절 후 재신청 가능
-
광고 목록 조회
- 상태별 필터링
- 유형별 필터링
스케줄러 테스트
- PENDING → ACTIVE 전환
- ACTIVE → ENDED 전환
📌 참고사항
- 광고 기능은 기관 CRUD 완료 후 진행
- 관리자 시스템과 연동 필요
- 프론트엔드와 광고 노출 위치 협의 필요
🎯 배경 / 목적
✅ 완료 조건 (AC: Acceptance Criteria)
- 조건 1
- 조건 2
- 조건 3
🛠️ 해결 방안 / 구현 상세
🧩 작업 범위
- 변경 예상 파일/모듈:
- 주요 클래스/메서드:
- 데이터베이스 영향:
- API 변경사항:
🧪 테스트 계획
- 단위 테스트:
- 통합 테스트:
- 수동 테스트 시나리오:
🔐 보안/성능 고려사항
🚀 배포/릴리스 체크리스트
- 환경변수/설정(yml) 변경 없음 또는 문서화 완료
- 스키마 변경 없음 또는 마이그레이션 스크립트 준비
- 역호환성 확인
- API 문서(Swagger) 업데이트
🙋♂️ 담당자 정보
- 백엔드: @Uechann
- 프론트엔드: @jimini0823
- 리뷰어: @SongTaeKwon @clainyun
📎 참고 자료
- 관련 이슈: Institution crud api 구현 #11 #17
- 참고 문서:
- 디자인:
Reactions are currently unavailable