Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
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))
);
}
}
Empty file.
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 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 -20

Repository: 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.java

Repository: 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 -50

Repository: 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/null

Repository: 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.java

Repository: 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.java

Repository: 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 -10

Repository: 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 10

Repository: 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.java

Repository: 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 -5

Repository: 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 명칭 불일치 해결 필요

엔드포인트는 /{mediaId}로 호출하는데 응답은 contentsId를 반환합니다. 같은 콘텐츠 도메인의 목록 조회(ContentsListResponse)에서는 mediaId를 사용하므로, 일관성을 위해 상세 조회도 mediaId로 통일하거나 mediaId 필드를 추가로 제공하는 것을 권장합니다.

수정 예시
-        `@Schema`(type = "Long", description = "콘텐츠 ID", example = "1")
-        Long contentsId,
+        `@Schema`(type = "Long", description = "미디어 ID", example = "1")
+        Long mediaId,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Schema(type = "Long", description = "콘텐츠 ID", example = "1")
Long contentsId,
`@Schema`(type = "Long", description = "미디어 ID", example = "1")
Long mediaId,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java`
around lines 12 - 13, The response field name in ContentsDetailResponse (the
field contentsId) doesn't match the request path parameter name mediaId and
differs from ContentsListResponse which uses mediaId; update
ContentsDetailResponse to either rename contentsId to mediaId or add an
additional mediaId field while keeping contentsId (to preserve compatibility),
and update related serializers/constructors/factory methods that populate
ContentsDetailResponse (look for usages of the ContentsDetailResponse class and
its constructor/mapper) so the returned JSON contains mediaId consistently with
the endpoint and ContentsListResponse.


@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,

@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();
}
}
Empty file.
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/back-office")
@RequestMapping("/back-office/admin/members")
@RequiredArgsConstructor
public class BackOfficeMemberController implements BackOfficeMemberApi {

private final BackOfficeMemberService backOfficeMemberService;

@Override
@GetMapping("/admin/members")
@GetMapping
public ResponseEntity<SuccessResponse<PageResponse<MemberListResponse>>> getMemberList(
@RequestParam(value = "page", defaultValue = "0") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size,
Expand All @@ -32,7 +32,7 @@ public ResponseEntity<SuccessResponse<PageResponse<MemberListResponse>>> getMemb
}

@Override
@PatchMapping("/admin/members/{memberId}/role")
@PatchMapping("/{memberId}/role")
public ResponseEntity<Void> changeRole(
@PathVariable("memberId") Long memberId,
@Valid @RequestBody ChangeRoleRequest changeRoleRequest
Expand Down
Loading