-
Notifications
You must be signed in to change notification settings - Fork 0
[OT-73][FEAT]: 백오피스 콘텐츠 관리 API 추가 #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2d6149b
ac67586
b2587f8
033e25f
e157761
dea9bca
baebafe
799cee1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package com.ott.api_admin.content.controller; | ||
|
|
||
| import com.ott.api_admin.content.dto.response.ContentsDetailResponse; | ||
| import com.ott.api_admin.content.dto.response.ContentsListResponse; | ||
| import com.ott.common.web.exception.ErrorResponse; | ||
| import com.ott.common.web.response.PageResponse; | ||
| import com.ott.common.web.response.SuccessResponse; | ||
| import com.ott.domain.common.PublicStatus; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.media.ArraySchema; | ||
| import io.swagger.v3.oas.annotations.media.Content; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||
| import io.swagger.v3.oas.annotations.responses.ApiResponses; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
|
|
||
| @Tag(name = "BackOffice Contents API", description = "[백오피스] 콘텐츠 관리 API") | ||
| public interface BackOfficeContentsApi { | ||
|
|
||
| @Operation(summary = "콘텐츠 목록 조회", description = "콘텐츠 목록을 페이징으로 조회합니다. - ADMIN 권한 필요.") | ||
| @ApiResponses(value = { | ||
| @ApiResponse( | ||
| responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", | ||
| content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentsListResponse.class)))} | ||
| ), | ||
| @ApiResponse( | ||
| responseCode = "200", description = "콘텐츠 목록 조회 성공", | ||
| content = {@Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))} | ||
| ), | ||
| @ApiResponse( | ||
| responseCode = "400", description = "콘텐츠 목록 조회 실패", | ||
| content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} | ||
| ), | ||
| @ApiResponse( | ||
| responseCode = "403", description = "접근 권한 없음 (ADMIN만 접근 가능)", | ||
| content = {@Content(mediaType = "application/json")} | ||
| ) | ||
| }) | ||
| ResponseEntity<SuccessResponse<PageResponse<ContentsListResponse>>> getContents( | ||
| @Parameter(description = "조회할 페이지의 번호를 입력해주세요. **page는 0부터 시작합니다**", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, | ||
| @Parameter(description = "한 페이지 당 최대 항목 개수를 입력해주세요. 기본값은 10입니다.", required = true) @RequestParam(value = "size", defaultValue = "10") Integer size, | ||
| @Parameter(description = "제목 부분일치 검색어. 미입력 시 전체 목록을 조회합니다.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord, | ||
| @Parameter(description = "공개 여부. 공개/비공개로 나뉩니다.", required = false, example = "PUBLIC") @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus | ||
| ); | ||
|
|
||
| @Operation(summary = "콘텐츠 상세 조회", description = "콘텐츠 상세 정보를 조회합니다. - ADMIN 권한 필요.") | ||
| @ApiResponses(value = { | ||
| @ApiResponse( | ||
| responseCode = "200", description = "콘텐츠 상세 조회 성공", | ||
| content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsDetailResponse.class))} | ||
| ), | ||
| @ApiResponse( | ||
| responseCode = "400", description = "콘텐츠 상세 조회 실패", | ||
| content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} | ||
| ), | ||
| @ApiResponse( | ||
| responseCode = "403", description = "접근 권한 없음 (ADMIN만 접근 가능)", | ||
| content = {@Content(mediaType = "application/json")} | ||
| ) | ||
| }) | ||
| ResponseEntity<SuccessResponse<ContentsDetailResponse>> getContentsDetail( | ||
| @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable Long mediaId | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.ott.api_admin.content.controller; | ||
|
|
||
| import com.ott.api_admin.content.dto.response.ContentsDetailResponse; | ||
| import com.ott.api_admin.content.dto.response.ContentsListResponse; | ||
| import com.ott.api_admin.content.service.BackOfficeContentsService; | ||
| import com.ott.common.web.response.PageResponse; | ||
| import com.ott.common.web.response.SuccessResponse; | ||
| import com.ott.domain.common.PublicStatus; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/back-office/admin/contents") | ||
| @RequiredArgsConstructor | ||
| public class BackOfficeContentsController implements BackOfficeContentsApi { | ||
|
|
||
| private final BackOfficeContentsService backOfficeContentsService; | ||
|
|
||
| @Override | ||
| @GetMapping | ||
| public ResponseEntity<SuccessResponse<PageResponse<ContentsListResponse>>> getContents( | ||
| @RequestParam(value = "page", defaultValue = "0") Integer page, | ||
| @RequestParam(value = "size", defaultValue = "10") Integer size, | ||
| @RequestParam(value = "searchWord", required = false) String searchWord, | ||
| @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus | ||
| ) { | ||
| return ResponseEntity.ok( | ||
| SuccessResponse.of(backOfficeContentsService.getContents(page, size, searchWord, publicStatus)) | ||
| ); | ||
| } | ||
|
|
||
| @Override | ||
| @GetMapping("/{mediaId}") | ||
| public ResponseEntity<SuccessResponse<ContentsDetailResponse>> getContentsDetail( | ||
| @PathVariable Long mediaId | ||
| ) { | ||
| return ResponseEntity.ok( | ||
| SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,57 @@ | ||||||||||
| package com.ott.api_admin.content.dto.response; | ||||||||||
|
|
||||||||||
| import com.ott.domain.common.PublicStatus; | ||||||||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||||||||
|
|
||||||||||
| import java.time.LocalDate; | ||||||||||
| import java.util.List; | ||||||||||
|
|
||||||||||
| @Schema(description = "콘텐츠 상세 조회 응답") | ||||||||||
| public record ContentsDetailResponse( | ||||||||||
|
|
||||||||||
| @Schema(type = "Long", description = "콘텐츠 ID", example = "1") | ||||||||||
| Long contentsId, | ||||||||||
|
Comment on lines
+12
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's explore the directory structure to understand the codebase layout
find apps/api-admin/src -type f -name "*.java" | grep -E "(content|Controller)" | head -20Repository: OpenTheTaste/backend Length of output: 914 🏁 Script executed: # Let's read the ContentsDetailResponse.java file to see its full content
cat -n apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.javaRepository: OpenTheTaste/backend Length of output: 2421 🏁 Script executed: # Search for API endpoints related to content detail or media
rg -n "contentsId|mediaId" apps/api-admin/src --type java -B 2 -A 2 | head -50Repository: OpenTheTaste/backend Length of output: 6192 🏁 Script executed: # Look for the Controller that handles content/media detail requests
find apps/api-admin/src -type f -name "*Controller.java" | xargs grep -l "mediaId\|contentsId" 2>/dev/nullRepository: OpenTheTaste/backend Length of output: 260 🏁 Script executed: # Read the BackOfficeContentsController to see the endpoint definition
cat -n apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.javaRepository: OpenTheTaste/backend Length of output: 2360 🏁 Script executed: # Read the BackOfficeContentsMapper to understand how data is mapped
cat -n apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.javaRepository: OpenTheTaste/backend Length of output: 2617 🏁 Script executed: # Check the Contents entity and Media entity structure
find apps -type f -name "Contents.java" -o -name "Media.java" | head -10Repository: OpenTheTaste/backend Length of output: 46 🏁 Script executed: # Look at the method that retrieves contents detail
grep -n "getContentsDetail\|toContentsDetailResponse" apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java -A 10Repository: OpenTheTaste/backend Length of output: 783 🏁 Script executed: # Let's check the ContentsListResponse to compare ID naming
cat -n apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.javaRepository: OpenTheTaste/backend Length of output: 1090 🏁 Script executed: # Check what the Contents and Media entities look like to understand their relationship
find apps/domain -type f \( -name "Contents.java" -o -name "Media.java" \) | head -5Repository: OpenTheTaste/backend Length of output: 110 🏁 Script executed: # Let's verify the API contract one more time by looking at how mediaId is used
rg -n "mediaId" apps/api-admin/src/main/java/com/ott/api_admin/content -A 2 -B 2 | grep -E "(getContentsDetail|ContentsDetailResponse|@Schema|Long)"Repository: OpenTheTaste/backend Length of output: 1758 요청 경로의 mediaId와 응답 필드의 contentsId 명칭 불일치 해결 필요 엔드포인트는 수정 예시- `@Schema`(type = "Long", description = "콘텐츠 ID", example = "1")
- Long contentsId,
+ `@Schema`(type = "Long", description = "미디어 ID", example = "1")
+ Long mediaId,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "포스터 URL", example = "https://cdn.example.com/poster.jpg") | ||||||||||
| String posterUrl, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "썸네일 URL", example = "https://cdn.example.com/thumb.jpg") | ||||||||||
| String thumbnailUrl, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") | ||||||||||
| String title, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") | ||||||||||
| String description, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "출연진", example = "송강호, 이선균") | ||||||||||
| String actors, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "소속 시리즈 제목 (없으면 null)", example = "비밀의 숲") | ||||||||||
| String seriesTitle, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "업로더 닉네임", example = "관리자") | ||||||||||
| String uploaderNickname, | ||||||||||
|
|
||||||||||
| @Schema(type = "Integer", description = "영상 길이(초)", example = "7200") | ||||||||||
| Integer duration, | ||||||||||
|
|
||||||||||
| @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") | ||||||||||
| Integer videoSize, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "카테고리명", example = "드라마") | ||||||||||
| String categoryName, | ||||||||||
|
|
||||||||||
| @Schema(type = "List<String>", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") | ||||||||||
| List<String> tagNameList, | ||||||||||
|
|
||||||||||
| @Schema(type = "String", description = "공개 여부", example = "PUBLIC") | ||||||||||
| PublicStatus publicStatus, | ||||||||||
|
|
||||||||||
| @Schema(type = "Long", description = "북마크 수", example = "150") | ||||||||||
| Long bookmarkCount, | ||||||||||
|
|
||||||||||
| @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") | ||||||||||
| LocalDate uploadedDate | ||||||||||
| ) { | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.ott.api_admin.content.dto.response; | ||
|
|
||
| import com.ott.domain.common.PublicStatus; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| @Schema(description = "콘텐츠 목록 조회 응답") | ||
| public record ContentsListResponse( | ||
|
|
||
| @Schema(type = "Long", description = "미디어 ID", example = "1") | ||
| Long mediaId, | ||
|
|
||
| @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") | ||
| String poster_url, | ||
phonil marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") | ||
| String title, | ||
|
|
||
| @Schema(type = "String", description = "공개 여부", example = "PUBLIC") | ||
| PublicStatus publicStatus, | ||
|
|
||
| @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") | ||
| LocalDate uploadedDate | ||
| ) { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package com.ott.api_admin.content.mapper; | ||
|
|
||
| import com.ott.api_admin.content.dto.response.ContentsDetailResponse; | ||
| import com.ott.api_admin.content.dto.response.ContentsListResponse; | ||
| import com.ott.domain.contents.domain.Contents; | ||
| import com.ott.domain.media.domain.Media; | ||
| import com.ott.domain.media_tag.domain.MediaTag; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Component | ||
| public class BackOfficeContentsMapper { | ||
|
|
||
| public ContentsListResponse toContentsListResponse(Media media) { | ||
| return new ContentsListResponse( | ||
| media.getId(), | ||
| media.getPosterUrl(), | ||
| media.getTitle(), | ||
| media.getPublicStatus(), | ||
| media.getCreatedDate().toLocalDate() | ||
| ); | ||
| } | ||
|
|
||
| public ContentsDetailResponse toContentsDetailResponse(Contents contents, Media media, String uploaderNickname, String seriesTitle, List<MediaTag> mediaTagList) { | ||
| String categoryName = extractCategoryName(mediaTagList); | ||
| List<String> tagNameList = extractTagNameList(mediaTagList); | ||
|
|
||
| return new ContentsDetailResponse( | ||
| contents.getId(), | ||
| media.getPosterUrl(), | ||
| media.getThumbnailUrl(), | ||
| media.getTitle(), | ||
| media.getDescription(), | ||
| contents.getActors(), | ||
| seriesTitle, | ||
| uploaderNickname, | ||
| contents.getDuration(), | ||
| contents.getVideoSize(), | ||
| categoryName, | ||
| tagNameList, | ||
| media.getPublicStatus(), | ||
| media.getBookmarkCount(), | ||
| media.getCreatedDate().toLocalDate() | ||
| ); | ||
| } | ||
|
|
||
| private String extractCategoryName(List<MediaTag> mediaTagList) { | ||
| return mediaTagList.stream() | ||
| .findFirst() | ||
| .map(mt -> mt.getTag().getCategory().getName()) | ||
| .orElse(null); | ||
| } | ||
|
|
||
| private List<String> extractTagNameList(List<MediaTag> mediaTagList) { | ||
| return mediaTagList.stream() | ||
| .map(mt -> mt.getTag().getName()) | ||
| .toList(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package com.ott.api_admin.content.service; | ||
|
|
||
| import com.ott.api_admin.content.dto.response.ContentsDetailResponse; | ||
| import com.ott.api_admin.content.dto.response.ContentsListResponse; | ||
| import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; | ||
| import com.ott.common.web.exception.BusinessException; | ||
| import com.ott.common.web.exception.ErrorCode; | ||
| import com.ott.common.web.response.PageInfo; | ||
| import com.ott.common.web.response.PageResponse; | ||
| import com.ott.domain.common.MediaType; | ||
| import com.ott.domain.common.PublicStatus; | ||
| import com.ott.domain.contents.domain.Contents; | ||
| import com.ott.domain.contents.repository.ContentsRepository; | ||
| import com.ott.domain.media.domain.Media; | ||
| import com.ott.domain.media.repository.MediaRepository; | ||
| import com.ott.domain.media_tag.domain.MediaTag; | ||
| import com.ott.domain.media_tag.repository.MediaTagRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Service | ||
| public class BackOfficeContentsService { | ||
|
|
||
| private final BackOfficeContentsMapper backOfficeContentsMapper; | ||
|
|
||
| private final MediaRepository mediaRepository; | ||
| private final MediaTagRepository mediaTagRepository; | ||
| private final ContentsRepository contentsRepository; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public PageResponse<ContentsListResponse> getContents(int page, int size, String searchWord, PublicStatus publicStatus) { | ||
| Pageable pageable = PageRequest.of(page, size); | ||
|
|
||
| // 미디어 중 콘텐츠 대상 페이징 | ||
| Page<Media> mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatus(pageable, MediaType.CONTENTS, searchWord, publicStatus); | ||
|
|
||
| List<ContentsListResponse> responseList = mediaPage.getContent().stream() | ||
| .map(backOfficeContentsMapper::toContentsListResponse) | ||
| .toList(); | ||
|
|
||
| PageInfo pageInfo = PageInfo.toPageInfo( | ||
| mediaPage.getNumber(), | ||
| mediaPage.getTotalPages(), | ||
| mediaPage.getSize() | ||
| ); | ||
| return PageResponse.toPageResponse(pageInfo, responseList); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public ContentsDetailResponse getContentsDetail(Long mediaId) { | ||
| // 1. Contents + Media + Uploader + Series + Series.media 한 번에 조회 | ||
| Contents contents = contentsRepository.findWithMediaAndUploaderByMediaId(mediaId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); | ||
|
|
||
| Media media = contents.getMedia(); | ||
| String uploaderNickname = media.getUploader().getNickname(); | ||
|
|
||
| // 2. 소속 시리즈 제목 및 태그 추출 | ||
| Long originMediaId = mediaId; | ||
| String seriesTitle = null; | ||
| if (contents.getSeries() != null) { | ||
| Media originMedia = contents.getSeries().getMedia(); | ||
| originMediaId = originMedia.getId(); | ||
| seriesTitle = originMedia.getTitle(); | ||
| } | ||
|
|
||
| // 3. 태그 조회 | ||
| List<MediaTag> mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMediaId); | ||
|
|
||
| return backOfficeContentsMapper.toContentsDetailResponse(contents, media, uploaderNickname, seriesTitle, mediaTagList); | ||
| } | ||
| } | ||
phonil marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Uh oh!
There was an error while loading. Please reload this page.