Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
13d6f67
feat: 플레이리스트 엔티티 초기 구현 및 생성 로직 추가
metDaisy Jun 30, 2026
6a445ce
feat: 사용자 요약 DTO 및 매퍼 추가
metDaisy Jun 30, 2026
bdae7b4
fix: 사용자 조회 시 N+1 문제 해결
metDaisy Jun 30, 2026
460ea3f
feat: 플레이리스트 관련 예외 클래스 추가
metDaisy Jun 30, 2026
6dbc0e0
feat: 플레이리스트 서비스 로직 구현
metDaisy Jun 30, 2026
c748766
feat: 플레이리스트 DTO 및 매퍼 정의
metDaisy Jun 30, 2026
7710d58
feat: 플레이리스트 리포지토리 초기 구현
metDaisy Jun 30, 2026
f43b8e3
refactor: Playlist 엔티티 필드 업데이트 로직 개선
metDaisy Jun 30, 2026
b9bd252
feat: 플레이리스트 API 컨트롤러 초기 구현
metDaisy Jun 30, 2026
6d3c4ee
feat: 구독 엔티티 및 관련 예외 클래스 추가
metDaisy Jul 1, 2026
8bbcce1
feat: 구독 기능 API 및 서비스 구현
metDaisy Jul 1, 2026
7cf541c
feat: 구독 취소 기능 추가
metDaisy Jul 1, 2026
a6203bf
feat: 플레이리스트 커서 기반 페이지네이션 구현
metDaisy Jul 1, 2026
9e77f6c
feat: 플레이리스트 커서 기반 조회 구현
metDaisy Jul 2, 2026
89d0843
refactor: CursorResponse 정렬 방향 값 처리 로직 개선
metDaisy Jul 2, 2026
f0c66fa
feat: 플레이리스트 항목 엔티티 및 DTO 정의
metDaisy Jul 2, 2026
252331a
feat: 플레이리스트 응답에 콘텐츠 목록 포함
metDaisy Jul 2, 2026
30ee167
chore: .gitignore에 IDE 설정 파일 추가
metDaisy Jul 3, 2026
4a365c2
refactor: 플레이리스트 조회 쿼리 및 엔티티 정의 개선
metDaisy Jul 3, 2026
24aca46
feat: 플레이리스트 콘텐츠 추가 및 삭제 기능 구현
metDaisy Jul 3, 2026
3987dac
feat: 플레이리스트 관련 Repository 및 Exception 추가
metDaisy Jul 3, 2026
5f7b273
feat: 플레이리스트 관련 API 인터페이스 구현
metDaisy Jul 3, 2026
fa50132
feat: 플레이리스트 구독 API 추가
metDaisy Jul 3, 2026
430aca5
test: 플레이리스트 관련 테스트 코드 추가
metDaisy Jul 3, 2026
c3e4a17
test: 플레이리스트 컨트롤러 테스트 코드 추가
metDaisy Jul 3, 2026
b65b4b0
fix: 플레이리스트 아이템의 position 컬럼 삭제
metDaisy Jul 3, 2026
2e1c63b
fix: PlaylistRepository에서 불필요한 메서드 제거
metDaisy Jul 3, 2026
a71837e
feat: 사용자 엔티티 그래프 로딩 추가
metDaisy Jul 3, 2026
0ecdf06
fix: MapStruct unmapped target policy 변경
metDaisy Jul 3, 2026
c3e9807
fix: 플레이리스트 관련 예외 처리 및 유효성 검사 개선
metDaisy Jul 3, 2026
62c287e
feat: 플레이리스트 구독 중복 예외 처리 및 메시지 수정
metDaisy Jul 3, 2026
a97f3fc
test: 플레이리스트 컨트롤러 테스트 코드 추가
metDaisy Jul 3, 2026
5eb3de6
refactor: 플레이리스트 커서 조회 로직 개선
metDaisy Jul 3, 2026
7af0d75
test: 구독 관련 테스트 코드 추가
metDaisy Jul 3, 2026
54eefb9
feat: 플레이리스트 조회 시 구독 여부 반환
metDaisy Jul 3, 2026
c86eed6
fix: 구독 취소 시 구독자 수 감소 로직 추가
metDaisy Jul 3, 2026
cee790b
refactor: 플레이리스트 소유자 인증 로직 변경
metDaisy Jul 3, 2026
2dd3f6e
feat: 커서 기반 플레이리스트 연속 조회 테스트 추가
metDaisy Jul 3, 2026
0264f1e
test: 알림 생성 시간 테스트 로직 개선
metDaisy Jul 3, 2026
8359a55
feat: QueryDSL 의존성 및 JaCoCo 설정 업데이트
metDaisy Jul 3, 2026
8966857
test: 플레이리스트 조회 테스트 추가
metDaisy Jul 3, 2026
3af4dae
fix: 플레이리스트 컨텐츠 추가 시 중복 검사 및 예외 처리
metDaisy Jul 3, 2026
7cc90c0
test: 플레이리스트 컨트롤러 테스트 코드 수정
metDaisy Jul 3, 2026
62b461a
플레이리스트 기능 개선 및 쿼리 최적화
metDaisy Jul 4, 2026
7618f18
refactor: 플레이리스트 콘텐츠 삭제 로직 개선
metDaisy Jul 4, 2026
9fbcde5
refactor: 플레이리스트 관련 이벤트 패키지 구조 변경
metDaisy Jul 4, 2026
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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,9 @@ $RECYCLE.BIN/

### 로컬 업로드 파일 디렉토리 ###
uploads/
uploads-test/
uploads-test/

### .vscode ###
bin/
.vscode/
.settings/
55 changes: 53 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ dependencies {
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
implementation 'org.mapstruct:mapstruct:1.6.3'
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
implementation 'io.github.openfeign.querydsl:querydsl-jpa:7.1'
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:7.1:jpa'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
// AWS SDK (S3)
Expand Down Expand Up @@ -71,6 +71,8 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
// .env
implementation 'me.paulschwarz:spring-dotenv:5.1.0'
}

tasks.named('test') {
Expand All @@ -82,12 +84,61 @@ jacoco {
toolVersion = '0.8.12'
}

def jacocoExcludePatterns = [
"**/*Application*",
"**/config/**",
"**/dto/**",
"**/entity/**",
"**/exception/**",
"**/event/**",
"**/mapper/**",
"**/constant/**",
"**/controller/api/**",
"**/Q*.class",
"**/*MapperImpl.class",
"**/*properties.class",
"**/*Decorator.class",
"**/MdcLoggingFilter.class",
"**/AuthUser.class",
]

tasks.withType(JacocoReportBase) {
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: jacocoExcludePatterns)
}))
}
}

def openJacocoReport = false // true 로 설정 시 테스트 완료 후 리포트 자동 열림

jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: jacocoExcludePatterns)
}))
}
doLast {
if (openJacocoReport) {
def reportFile = reports.html.entryPoint
if (reportFile.exists()) {
println "Opening JaCoCo Report: ${reportFile}"
def os = org.gradle.internal.os.OperatingSystem.current()
if (os.isWindows()) {
exec { commandLine 'cmd', '/c', "start \"\" \"${reportFile}\"" }
} else if (os.isMacOsX()) {
exec { commandLine 'open', reportFile }
} else {
exec { commandLine 'xdg-open', reportFile }
}
}
}
}
Comment thread
metDaisy marked this conversation as resolved.
}

jacocoTestCoverageVerification {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,19 @@ public record CursorResponse<T>(List<T> data,
boolean hasNext,
long totalCount,
String sortBy,
String sortDirection) {
String sortDirection) {

@Override
public String sortDirection() {
if (sortDirection == null) {
return null;
}
if (sortDirection.equalsIgnoreCase("ASC") || sortDirection.equalsIgnoreCase("ASCENDING")) {
return "ASCENDING";
}
if (sortDirection.equalsIgnoreCase("DESC") || sortDirection.equalsIgnoreCase("DESCENDING")) {
return "DESCENDING";
}
return sortDirection;
}
Comment thread
metDaisy marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.codeit.team5.mopl.global.mapper;

import org.mapstruct.InjectionStrategy;
import org.mapstruct.MapperConfig;
import org.mapstruct.MappingConstants;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.ReportingPolicy;

@MapperConfig(componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.WARN,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
injectionStrategy = InjectionStrategy.CONSTRUCTOR)
Comment thread
metDaisy marked this conversation as resolved.
public interface GlobalMapperConfig {

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
import com.codeit.team5.mopl.notification.entity.NotificationType;
import com.codeit.team5.mopl.notification.event.DirectMessageSentEvent;
import com.codeit.team5.mopl.watcher.event.WatchingSessionCreatedEvent;
import com.codeit.team5.mopl.playlist.entity.event.PlaylistSubscribedEvent;
import com.codeit.team5.mopl.playlist.entity.event.PlaylistUpdatedEvent;
import com.codeit.team5.mopl.user.event.RoleChangedEvent;
import com.codeit.team5.mopl.follow.event.UserFollowedEvent;
import com.codeit.team5.mopl.notification.service.NotificationService;
import com.codeit.team5.mopl.playlist.event.PlaylistSubscribedEvent;
import com.codeit.team5.mopl.playlist.event.PlaylistUpdatedEvent;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.codeit.team5.mopl.playlist.constant;

import com.codeit.team5.mopl.playlist.exception.PlaylistIncorrectSortByException;
import com.codeit.team5.mopl.playlist.exception.PlaylistSortByMismatchException;
import java.time.Instant;
import java.util.function.Function;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum PlaylistSortBy {
UPDATED_AT("updatedAt", Instant::parse),
SUBSCRIBE_COUNT("subscribeCount", Integer::parseInt),
;
private final String sortByType;
private final Function<String, ?> parser;

public static PlaylistSortBy from(String text) {
for (PlaylistSortBy value : PlaylistSortBy.values()) {
if (value.getSortByType().equalsIgnoreCase(text)) {
return value;
}
}
throw new PlaylistIncorrectSortByException(text);
}

public Object parse(String cursor) {
try {
return parser.apply(cursor);
} catch (Exception ex) {
throw new PlaylistSortByMismatchException(sortByType, cursor);
}

}
Comment thread
metDaisy marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.codeit.team5.mopl.playlist.controller;

import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.codeit.team5.mopl.auth.security.details.MoplPrincipal;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.playlist.controller.api.PlaylistControllerApi;
import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest;
import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest;
import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest;
import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse;
import com.codeit.team5.mopl.playlist.mapper.PlaylistMapper;
import com.codeit.team5.mopl.playlist.service.PlaylistService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api/playlists")
@RequiredArgsConstructor
public class PlaylistController implements PlaylistControllerApi {

private final PlaylistService service;
private final PlaylistMapper mapper;

@Override
@GetMapping("/{id}")
public ResponseEntity<PlaylistResponse> find(@AuthenticationPrincipal MoplPrincipal principal,
@PathVariable UUID id) {
UUID userId = principal.getId();
return ResponseEntity.status(HttpStatus.OK).body(service.find(id, userId));
}

@Override
@GetMapping
public ResponseEntity<CursorResponse<PlaylistResponse>> findCursor(
@AuthenticationPrincipal MoplPrincipal principal,
@ModelAttribute @Valid PlaylistCursorRequest request) {
UUID userId = principal.getId();
return ResponseEntity.status(HttpStatus.OK)
.body(service.findByCursor(mapper.toCommand(request), userId));
}
Comment thread
metDaisy marked this conversation as resolved.

@Override
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<PlaylistResponse> create(@AuthenticationPrincipal MoplPrincipal principal,
@RequestBody @Valid PlaylistCreateRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(service.create(principal.getId(), request));
}

@Override
@PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<PlaylistResponse> update(@AuthenticationPrincipal MoplPrincipal principal,
@PathVariable UUID id, @RequestBody PlaylistUpdateRequest request) {
return ResponseEntity.status(HttpStatus.OK)
.body(service.update(id, principal.getId(), request));
}
Comment thread
metDaisy marked this conversation as resolved.

@Override
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@AuthenticationPrincipal MoplPrincipal principal,
@PathVariable UUID id) {
service.delete(id, principal.getId());
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

@Override
@PostMapping("/{playlistId}/contents/{contentId}")
public ResponseEntity<Void> addContent(@AuthenticationPrincipal MoplPrincipal principal,
@PathVariable UUID playlistId, @PathVariable UUID contentId) {
service.addContent(principal.getId(), playlistId, contentId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

@Override
@DeleteMapping("/{playlistId}/contents/{contentId}")
public ResponseEntity<Void> removeContent(@AuthenticationPrincipal MoplPrincipal principal,
@PathVariable UUID playlistId, @PathVariable UUID contentId) {
service.removeContent(principal.getId(), playlistId, contentId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.codeit.team5.mopl.playlist.controller.api;

import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import com.codeit.team5.mopl.auth.security.details.MoplPrincipal;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.playlist.dto.request.PlaylistCreateRequest;
import com.codeit.team5.mopl.playlist.dto.request.PlaylistCursorRequest;
import com.codeit.team5.mopl.playlist.dto.request.PlaylistUpdateRequest;
import com.codeit.team5.mopl.playlist.dto.response.PlaylistResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "플레이리스트 관리")
public interface PlaylistControllerApi {

@Operation(summary = "플레이리스트 단건 조회")
ResponseEntity<PlaylistResponse> find(@Parameter(hidden = true) MoplPrincipal principal,
@PathVariable UUID id);

@Operation(summary = "플레이리스트 목록 조회 (커서 페이지네이션)")
ResponseEntity<CursorResponse<PlaylistResponse>> findCursor(
@Parameter(hidden = true) MoplPrincipal principal,
@ModelAttribute PlaylistCursorRequest request);

@Operation(summary = "플레이리스트 생성", description = "생성한 플레이리스트는 API 요청자 본인의 플레이리스트로 생성됩니다.")
ResponseEntity<PlaylistResponse> create(@Parameter(hidden = true) MoplPrincipal principal,
@RequestBody PlaylistCreateRequest request);

@Operation(summary = "플레이리스트 수정", description = "플레이리스트 소유자만 수정할 수 있습니다.")
ResponseEntity<PlaylistResponse> update(@Parameter(hidden = true) MoplPrincipal principal,
@PathVariable UUID id, @RequestBody PlaylistUpdateRequest request);

@Operation(summary = "플레이리스트 삭제", description = "플레이리스트 소유자만 삭제할 수 있습니다.")
ResponseEntity<Void> delete(@Parameter(hidden = true) MoplPrincipal principal,
@PathVariable UUID playlistId);
Comment thread
metDaisy marked this conversation as resolved.

@Operation(summary = "플레이리스트에 콘텐츠 추가", description = "플레이리스트 소유자만 콘텐츠를 추가할 수 있습니다.")
ResponseEntity<Void> addContent(@Parameter(hidden = true) MoplPrincipal principal,
@PathVariable UUID playlistId, @PathVariable UUID contentId);

@Operation(summary = "플레이리스트에서 콘텐츠 삭제", description = "플레이리스트 소유자만 콘텐츠를 삭제할 수 있습니다.")
ResponseEntity<Void> removeContent(@Parameter(hidden = true) MoplPrincipal principal,
@PathVariable UUID playlistId, @PathVariable UUID contentId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.codeit.team5.mopl.playlist.dto;

import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.playlist.entity.Playlist;
import java.util.List;

public record PlaylistContentsDto(Playlist playlist, List<Content> contents, boolean subscribedByMe) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.codeit.team5.mopl.playlist.dto;

import com.codeit.team5.mopl.playlist.constant.PlaylistSortBy;
import org.springframework.data.domain.Sort;
import java.util.UUID;
import lombok.Builder;

@Builder
public record PlaylistCursorCommand(String keywordLike, UUID ownerIdEqual, UUID subscriberIdEqual,
Object cursor, UUID idAfter, Integer limit, Sort.Direction sortDirection,
PlaylistSortBy sortBy) {
Comment thread
metDaisy marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.codeit.team5.mopl.playlist.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record PlaylistCreateRequest(@NotBlank @Size(max = 255) String title,
@NotBlank String description) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.codeit.team5.mopl.playlist.dto.request;

import java.util.UUID;
import org.springframework.data.domain.Sort;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public record PlaylistCursorRequest(String keywordLike,
UUID ownerIdEqual,
UUID subscriberIdEqual,
String cursor,
UUID idAfter,
@NotNull @Positive @Max(50) Integer limit,
@NotNull Sort.Direction sortDirection,
@NotNull String sortBy) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.codeit.team5.mopl.playlist.dto.request;

public record PlaylistUpdateRequest(String title, String description) {

}
Loading
Loading