From 594d94a69c75717451940d53b963bb4a4775fc41 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 18 Feb 2026 14:21:22 +0900 Subject: [PATCH 001/257] =?UTF-8?q?[FEAT]:=20admin,=20transcode=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EA=B0=80=20api-user=20depends=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-user/Dockerfile | 9 +++-- apps/api-user/build.gradle | 15 ++++++++ .../ott/api_user/config/SecurityConfig.java | 23 +++++++++++++ .../src/main/resources/application.yml | 10 ++++++ docker-compose.yml | 34 +++++++++++++------ 5 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java diff --git a/apps/api-user/Dockerfile b/apps/api-user/Dockerfile index 692ca0f..42e97cf 100644 --- a/apps/api-user/Dockerfile +++ b/apps/api-user/Dockerfile @@ -3,8 +3,13 @@ WORKDIR /app COPY . . RUN gradle :apps:api-user:bootJar --no-daemon -FROM eclipse-temurin:21-jre +FROM eclipse-temurin:21-jre-jammy WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + COPY --from=build /app/apps/api-user/build/libs/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index faf3af7..800db85 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -16,4 +16,19 @@ dependencies { implementation 'org.flywaydb:flyway-mysql' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' +} + + +/** + * Flyway가 infra 모듈의 db/migration 리소스를 확실히 인식하도록 설정 + */ +tasks.named('processResources') { + from(project(':modules:infra').sourceSets.main.resources.srcDirs) { + include 'db/migration/**' + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java new file mode 100644 index 0000000..a23941f --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -0,0 +1,23 @@ +package com.ott.api_user.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health/**", "/actuator/info").permitAll() + .anyRequest().permitAll() + ) + .httpBasic(Customizer.withDefaults()) + .build(); + } + +} diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index 9adde2b..dd946ca 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -25,3 +25,13 @@ spring: hibernate: show_sql: true format_sql: true + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + probes: + enabled: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 600fd61..e349c88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,14 +21,14 @@ services: timeout: 5s retries: 5 - # ============ 앱 ============ - api-admin: - build: # 이미지를 다운받는게 아니라 해당 경로에서 빌드 + # ============ Flyway 마이그레이션 담당 (먼저 기동) ============ + api-user: + build: context: . - dockerfile: apps/api-admin/Dockerfile - container_name: ott-api-admin + dockerfile: apps/api-user/Dockerfile + container_name: ott-api-user ports: - - "8081:8081" + - "8080:8080" environment: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} @@ -36,14 +36,22 @@ services: depends_on: mysql: condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1" ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s - api-user: - build: + + # ============ Flyway 완류 이후 빌드하는 앱 ============ + api-admin: + build: # 이미지를 다운받는게 아니라 해당 경로에서 빌드 context: . - dockerfile: apps/api-user/Dockerfile - container_name: ott-api-user + dockerfile: apps/api-admin/Dockerfile + container_name: ott-api-admin ports: - - "8080:8080" + - "8081:8081" environment: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} @@ -51,6 +59,8 @@ services: depends_on: mysql: condition: service_healthy + api-user: + condition: service_healthy transcoder: build: @@ -66,6 +76,8 @@ services: depends_on: mysql: condition: service_healthy + api-user: + condition: service_healthy volumes: mysql-data: From 5fb56b0f2fa06b8cdeaaece330d64fde347f59ff Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 19 Feb 2026 09:19:45 +0900 Subject: [PATCH 002/257] =?UTF-8?q?[FEAT]:=20JWT=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B3=B5=ED=86=B5=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/common-security/build.gradle | 10 +- .../filter/JwtAuthenticationFilter.java | 78 ++++++++++++++ .../handler/JwtAccessDeniedHandler.java | 41 +++++++ .../handler/JwtAuthenticationEntryPoint.java | 45 ++++++++ .../common/security/jwt/JwtTokenProvider.java | 101 ++++++++++++++++++ 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java create mode 100644 modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java create mode 100644 modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java create mode 100644 modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java diff --git a/modules/common-security/build.gradle b/modules/common-security/build.gradle index 1d63f8f..292b489 100644 --- a/modules/common-security/build.gradle +++ b/modules/common-security/build.gradle @@ -1,7 +1,15 @@ dependencies { implementation project(':modules:domain') + implementation project(':modules:common-web') + + // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' -} \ No newline at end of file +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..54f738e --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,78 @@ +package com.ott.common.security.filter; + +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.ErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * 들어오는 요청을 가로채서 토큰을 꺼내서 provider에게 검증을 요청 + * provider에서 정상임을 반환하면 Authentication을 securityContext에 넣음 + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 토큰 꺼내옴 + String token = resolveToken(request); + + // 토큰을 검증하여 인증 없음, 만료됨, 유효x일 경우 에러코드를 저장 + if(token != null) { + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(token); + + if(errorCode == null) { + Long memberId = jwtTokenProvider.getMemberId(token); + + // auth: ["ROLE_USER"] + List authorities = jwtTokenProvider.getAuthorities(token); + + // Authentication을 만듬 + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberId, // 추후 UserDetails?로 변경 예정 + null, + authorities.stream() + .map(SimpleGrantedAuthority::new) + .toList() + ); + // Authenication을 SecurityContext에 넣음 + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + // 실패할 경우 해당 에러코드를 reqeust에 넣음 + request.setAttribute(JwtAuthenticationEntryPoint.ERROR_CODE, errorCode); + } + } + + filterChain.doFilter(request, response); + } + + // request에서 토큰 빼오기 + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } + + +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..23f0246 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,41 @@ +package com.ott.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +// 403 권한 없음 +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.FORBIDDEN, accessDeniedException.getMessage()); + + response.setStatus(ErrorCode.FORBIDDEN.getStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..6ba704d --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,45 @@ +package com.ott.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +// 401 인증 안됨, 유효하지않음, 만료됨 +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + public static final String ERROR_CODE = "AUTH_ERROR_CODE"; + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + // Filter에서 받아온 002, 003 에러일 경우 해당 에러 사용 + Object attribute = request.getAttribute(ERROR_CODE); + ErrorCode errorCode = (attribute instanceof ErrorCode) ? (ErrorCode) attribute : ErrorCode.UNAUTHORIZED; + ErrorResponse errorResponse = ErrorResponse.of(errorCode, authException.getMessage()); + + response.setStatus(errorCode.getStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java b/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..98421fa --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java @@ -0,0 +1,101 @@ +package com.ott.common.security.jwt; + +import com.ott.common.web.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.List; + + +/** + * Access/Refresh JWT 생성 + * JWT 검증, JWT 파싱(claim 추출) + */ +@Component +public class JwtTokenProvider { + + private static final String CLAIM_AUTH = "auth"; + + private final SecretKey key; + private final long accessTokenExpiry; + private final long refreshTokenExpiry; + + + public JwtTokenProvider( + @Value("${jwt.secret}") String base64Secret, + @Value("${jwt.access-token-expiry}")long accessTokenExpiry, + @Value("${jwt.refresh-token-expiry}")long refreshTokenExpiry) { + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); + this.accessTokenExpiry = accessTokenExpiry; + this.refreshTokenExpiry = refreshTokenExpiry; + } + + // Access JWT 생성 + public String createAccessToken(Long memberId, List authorities) { + return createToken(memberId, authorities, accessTokenExpiry); + } + + // Refresh JWT 생성 + public String createRefreshToken(Long memberId, List authorities) { + return createToken(memberId, authorities, refreshTokenExpiry); + } + + // JWT 생성 + // header는 자동으로 생김 + // claim -> sub, auth, iat(issued at), exp(expiration)이 들어감 + // signature -> Ec(header+payload) + private String createToken(Long memberId, List authorities, long expiryMillis) { + Date now = new Date(); + return Jwts.builder() + .subject(String.valueOf(memberId)) + .claim(CLAIM_AUTH, authorities) // ["ROLE_MEMBER", "ROLE_ADMIN", "ROLE_EDITOR"] + .issuedAt(now) + .expiration(new Date(now.getTime() + expiryMillis)) + .signWith(key) // 서명 + .compact(); + } + + // Claim 파싱 및 검증 + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) //header, payload, signature 분리 후 디코딩 후 json 파싱 + // 검증 성공 시 Jwt 반환 + .getPayload(); // payload만 추출 + } + + public Long getMemberId(String token) { + return Long.parseLong(getClaims(token).getSubject()); + } + + + // claims중에서 auth를 꺼내와서 해당 토큰의 ROLE확인 -> ["ROLE_USER"] + @SuppressWarnings("unchecked") + public List getAuthorities(String token) { + Object auth = getClaims(token).get(CLAIM_AUTH); + if (auth == null) return List.of(); + return (List) auth; + } + + // validate 결과를 ErrorCode로 변환 002, 003에 대한 에러 코드를 알아야됨 + public ErrorCode validateAndGetErrorCode(String token) { + try { + getClaims(token); + return null; + } catch (ExpiredJwtException e) { + return ErrorCode.EXPIRED_TOKEN; // A003 + } catch (JwtException | IllegalArgumentException e) { + return ErrorCode.INVALID_TOKEN; // A002 + } + } + +} From 582fa74e90a0d6f46bb8cf6af26c3020dbdcb13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 11:49:39 +0900 Subject: [PATCH 003/257] =?UTF-8?q?[FEAT}:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색/필터 없는 기본 목록 조회 API --- .../controller/BackOfficeMemberApi.java | 44 +++++++++++++++++++ .../BackOfficeMemberController.java | 31 +++++++++++++ .../dto/response/MemberListResponse.java | 26 +++++++++++ .../service/BackOfficeMemberService.java | 44 +++++++++++++++++++ .../member/repository/MemberRepository.java | 7 +++ 5 files changed, 152 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java new file mode 100644 index 0000000..941d57f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java @@ -0,0 +1,44 @@ +package com.ott.api_admin.member.controller; + +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +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.RequestParam; + +@Tag(name = "BackOffice Member API", description = "[백오피스] 사용자 관리 API") +public interface BackOfficeMemberApi { + + @Operation(summary = "사용자 목록 조회", description = "사용자 목록을 페이징으로 조회합니다. - ADMIN 권한 필요.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = MemberListResponse.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>> getMemberList( + @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 + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java new file mode 100644 index 0000000..4a7291b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.member.controller; + +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.api_admin.member.service.BackOfficeMemberService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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") +@RequiredArgsConstructor +public class BackOfficeMemberController implements BackOfficeMemberApi { + + private final BackOfficeMemberService backOfficeMemberService; + + @Override + @GetMapping("/admin/members") + public ResponseEntity>> getMemberList( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeMemberService.getMemberList(page, size)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java new file mode 100644 index 0000000..907d72e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/response/MemberListResponse.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.member.dto.response; + +import com.ott.domain.member.domain.Role; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "사용자 목록 조회 응답") +public record MemberListResponse( + + @Schema(type = "Long", description = "사용자 ID", example = "1") + Long memberId, + + @Schema(type = "String", description = "닉네임", example = "홍길동") + String nickname, + + @Schema(type = "String", description = "이메일", example = "user@example.com") + String email, + + @Schema(type = "String", description = "역할", example = "MEMBER") + Role role, + + @Schema(type = "String", description = "가입일", example = "2026-01-15") + LocalDate createdDate +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java new file mode 100644 index 0000000..3f96bf2 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java @@ -0,0 +1,44 @@ +package com.ott.api_admin.member.service; + +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.api_admin.member.mapper.BackOfficeMemberMapper; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class BackOfficeMemberService { + + private final BackOfficeMemberMapper backOfficeMemberMapper; + + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public PageResponse getMemberList(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdDate").descending()); + + Page memberPage = memberRepository.findAll(pageable); + + List responseList = memberPage.getContent().stream() + .map(backOfficeMemberMapper::toMemberListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + memberPage.getNumber(), + memberPage.getTotalPages(), + memberPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..8bdd8ab --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.member.repository; + +import com.ott.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { +} From 94a13c2e24c63cdaba53d00a2711bc13a97c87b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 11:51:47 +0900 Subject: [PATCH 004/257] =?UTF-8?q?[CHORE]:=20.gitkeep=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_admin/member/controller/.gitkeep | 0 .../api-admin/src/main/java/com/ott/api_admin/member/dto/.gitkeep | 0 .../src/main/java/com/ott/api_admin/member/service/.gitkeep | 0 .../src/main/java/com/ott/api_admin/series/controller/.gitkeep | 0 .../api-admin/src/main/java/com/ott/api_admin/series/dto/.gitkeep | 0 .../src/main/java/com/ott/api_admin/series/service/.gitkeep | 0 6 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/controller/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/dto/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/service/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/controller/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/service/.gitkeep diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/.gitkeep deleted file mode 100644 index e69de29..0000000 From 72dea929381d3fb0b69aff94120442cbc49ad064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 12:49:27 +0900 Subject: [PATCH 005/257] =?UTF-8?q?[FEAT]:=20MemberMapper=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/mapper/BackOfficeMemberMapper.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java new file mode 100644 index 0000000..5423c31 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/mapper/BackOfficeMemberMapper.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.member.mapper; + +import com.ott.api_admin.member.dto.response.MemberListResponse; +import com.ott.domain.member.domain.Member; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeMemberMapper { + + public MemberListResponse toMemberListResponse(Member member) { + return new MemberListResponse( + member.getId(), + member.getNickname(), + member.getEmail(), + member.getRole(), + member.getCreatedDate().toLocalDate() + ); + } +} From eedea235c92f84bb8ac8a1de443c17521227cbb6 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:50:32 +0900 Subject: [PATCH 006/257] =?UTF-8?q?[FEAT]=20=EC=8B=9C=EB=A6=AC=EC=A6=88/?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=86=B5=ED=95=A9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/search/controller/SearchApi.java | 36 ++++++++ .../search/controller/SearchController.java | 32 +++++++ .../search/dto/SearchItemResponse.java | 32 +++++++ .../search/service/SearchService.java | 84 +++++++++++++++++++ docker-compose.yml | 6 +- .../ott/common/web/exception/ErrorCode.java | 3 +- .../repository/ContentsRepository.java | 24 ++++++ .../series/repository/SeriesRepository.java | 21 +++++ 8 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java new file mode 100644 index 0000000..e3e55e9 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java @@ -0,0 +1,36 @@ +package com.ott.api_user.search.controller; + +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +@Tag(name = "Search API", description = "통합 검색 API입니다.") +public interface SearchApi { + + @Operation(summary = "통합 검색 API", description = "콘텐츠와 시리즈를 통합하여 최신순으로 검색합니다.") + @ApiResponses(value = { + @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)) }) + }) + @GetMapping + ResponseEntity> search( + @Parameter(description = "검색어를 입력해주세요.", required = true) @RequestParam(value = "searchWord") String searchWord, + + @Parameter(description = "조회할 페이지 번호를 입력해주세요. page는 0부터 시작합니다", required = true) @RequestParam(value = "page", defaultValue = "0") Integer page, + + @Parameter(description = "한 페이지 당 최대 항목 개수를 입력해주세요. 기본값은 24입니다.", required = true) @RequestParam(value = "size", defaultValue = "24") Integer size); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java new file mode 100644 index 0000000..2d2bcb8 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java @@ -0,0 +1,32 @@ +package com.ott.api_user.search.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.search.service.SearchService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import jakarta.persistence.criteria.CriteriaBuilder.In; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/search") +public class SearchController implements SearchApi { + private final SearchService searchService; + + @Override + public ResponseEntity> search( + @RequestParam String searchWord, + @RequestParam Integer page, + @RequestParam Integer size) { + PageResponse response = searchService.search(searchWord, page, size); + return ResponseEntity.ok(SuccessResponse.of(response)); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java b/apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java new file mode 100644 index 0000000..da47881 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/dto/SearchItemResponse.java @@ -0,0 +1,32 @@ +package com.ott.api_user.search.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "검색 결과 항목 응답 DTO") +public class SearchItemResponse { + @Schema(description = "항목 타입 (콘텐츠 또는 시리즈)", example = "CONTENTS") + private String type; + + @Schema(description = "콘텐츠 또는 시리즈의 고유 ID", example = "101") + private Long id; + + @Schema(description = "제목", example = "비밀의 숲") + private String title; + + @Schema(description = "포스터 이미지 URL", example = "https://cdn.ott.com/posters/101.jpg") + private String posterUrl; + + @JsonIgnore // JSON 응답에서는 제외 + @Schema(description = "서버 내부 정렬용 생성일시", hidden = true) + private LocalDateTime createdAt; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java new file mode 100644 index 0000000..c553d06 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java @@ -0,0 +1,84 @@ +package com.ott.api_user.search.service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import com.ott.api_user.search.dto.SearchItemResponse; +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.Status; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.series.repository.SeriesRepository; +import lombok.RequiredArgsConstructor; + +// 최신순 정렬을 위해 DB 페이징 방식 대신, +// 검색 결과를 모두 가져와서 Java Stream으로 정렬 후, 페이지네이션 처리하는 방식으로 변경 +// 추후 검색 대상이 늘어나거나 데이터 양이 많아질 경우, Querydsl 으로 검색 쿼리 최적화 필요! +@Service +@RequiredArgsConstructor +public class SearchService { + private final ContentsRepository contentsRepository; + private final SeriesRepository seriesRepository; + + public PageResponse search(String searchWord, int page, int size) { + + if (searchWord.length() < 2) { + throw new BusinessException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); + } + + // 사용자가 흔한 검색어 입력 시 너무 많은 데이터를 가져올 수 있으므로 + // 일단 최대 100개까지만 가져오도록 제한 + Pageable limit = PageRequest.of(0, 100); + + // 에피소드 제외, 시리즈와 단일 콘텐츠만 검색 + List contentsList = contentsRepository.searchLatest(searchWord, Status.ACTIVE, limit); + List seriesList = seriesRepository.searchLatest(searchWord, Status.ACTIVE, limit); + + // 컨텐츠+시리즈 통합 정렬 + List allResults = Stream.concat( + contentsList.stream().map(c -> SearchItemResponse.builder() + .type("CONTENTS") + .id(c.getId()) + .title(c.getTitle()) + .posterUrl(c.getPosterUrl()) + .createdAt(c.getCreatedDate()) + .build()), + seriesList.stream().map(s -> SearchItemResponse.builder() + .type("SERIES") + .id(s.getId()) + .title(s.getTitle()) + .posterUrl(s.getPosterUrl()) + .createdAt(s.getCreatedDate()) + .build())) + .sorted(Comparator.comparing(SearchItemResponse::getCreatedAt).reversed()) // 통합 최신순 정렬 + .toList(); + + // 페이징 계산 (직접 자르기) + int totalElements = allResults.size(); + int totalPages = (int) Math.ceil((double) totalElements / size); + + int start = Math.min(page * size, totalElements); + int end = Math.min(start + size, totalElements); + + List pagedResult = allResults.subList(start, end); + + PageInfo pageInfo = PageInfo.builder() + .currentPage(page) + .totalPage(totalPages) + .pageSize(size) + .build(); + + return PageResponse.toPageResponse(pageInfo, pagedResult); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 600fd61..b21894b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,10 +16,10 @@ services: volumes: - mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u${MYSQL_USER} -p${MYSQL_PASSWORD} --silent"] interval: 10s timeout: 5s - retries: 5 + retries: 20 # ============ 앱 ============ api-admin: @@ -70,4 +70,4 @@ services: volumes: mysql-data: - + \ No newline at end of file diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 97c1dff..5e96180 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -38,7 +38,8 @@ public enum ErrorCode { // ========== Business (B) - 비즈니스 ========== CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), - SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"); + SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), + SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"),; private final HttpStatus status; private final String code; diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java new file mode 100644 index 0000000..47ec769 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -0,0 +1,24 @@ +package com.ott.domain.contents.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; + +public interface ContentsRepository extends JpaRepository { + + // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 미포함 콘텐츠 검색 (최신순 정렬) + @Query("SELECT c FROM Contents c " + + "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "AND c.status = :status " + + "AND c.series IS NULL " + + "ORDER BY c.createdDate DESC") + List searchLatest(@Param("searchWord") String searchWord, @Param("status") Status status, + Pageable pageable); + +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java new file mode 100644 index 0000000..d456df4 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -0,0 +1,21 @@ +package com.ott.domain.series.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.Status; +import com.ott.domain.series.domain.Series; + +public interface SeriesRepository extends JpaRepository { + @Query("SELECT s FROM Series s " + + "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "AND s.status = :status " + + "ORDER BY s.createdDate DESC") + List searchLatest(@Param("keyword") String keyword, + @Param("status") Status status, + Pageable pageable); +} \ No newline at end of file From 42b5fed3b10d3b295ae09429b060e2b61509bdc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 13:52:42 +0900 Subject: [PATCH 007/257] =?UTF-8?q?[BUILD]:=20QueryDSL=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/domain/build.gradle | 6 ++++++ .../com/ott/global/config/QueryDslConfig.java | 15 +++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java diff --git a/modules/domain/build.gradle b/modules/domain/build.gradle index e110483..6882920 100644 --- a/modules/domain/build.gradle +++ b/modules/domain/build.gradle @@ -2,4 +2,10 @@ apply plugin: 'java-library' dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java b/modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java new file mode 100644 index 0000000..813e615 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/global/config/QueryDslConfig.java @@ -0,0 +1,15 @@ +package com.ott.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} From cc2c6dce784f7e13e2d24d37846315d90abe6d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 13:53:02 +0900 Subject: [PATCH 008/257] =?UTF-8?q?[FEAT]:=20Repository=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/MemberRepository.java | 2 +- .../repository/MemberRepositoryCustom.java | 10 ++++++ .../repository/MemberRepositoryImpl.java | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java index 8bdd8ab..233f787 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -3,5 +3,5 @@ import com.ott.domain.member.domain.Member; import org.springframework.data.jpa.repository.JpaRepository; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { } diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java new file mode 100644 index 0000000..5f9529e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.member.repository; + +import com.ott.domain.member.domain.Member; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface MemberRepositoryCustom { + + Page findMemberList(Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java new file mode 100644 index 0000000..e58fc45 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.ott.domain.member.repository; + +import com.ott.domain.member.domain.Member; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +import static com.ott.domain.member.domain.QMember.member; + +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMemberList(Pageable pageable) { + List memberList = queryFactory + .selectFrom(member) + .orderBy(member.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(member.count()) + .from(member); + + return PageableExecutionUtils.getPage(memberList, pageable, countQuery::fetchOne); + } +} From 418bbe4f0a9db00580d7826ba6f3223bb7d38c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 13:53:19 +0900 Subject: [PATCH 009/257] =?UTF-8?q?[FEAT]:=20Service=EC=97=90=EC=84=9C=20R?= =?UTF-8?q?epository=20=EC=82=AC=EC=9A=A9=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/member/service/BackOfficeMemberService.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java index 3f96bf2..3f65f07 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java @@ -10,7 +10,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,9 +25,9 @@ public class BackOfficeMemberService { @Transactional(readOnly = true) public PageResponse getMemberList(int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDate").descending()); + Pageable pageable = PageRequest.of(page, size); - Page memberPage = memberRepository.findAll(pageable); + Page memberPage = memberRepository.findMemberList(pageable); List responseList = memberPage.getContent().stream() .map(backOfficeMemberMapper::toMemberListResponse) From bfcd41129c86caf7c9b8b98eaf8b7b9e334bd5af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 14:39:56 +0900 Subject: [PATCH 010/257] =?UTF-8?q?[FEAT]:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=20?= =?UTF-8?q?=EA=B2=80=EC=83=89/=ED=95=84=ED=84=B0=EB=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeMemberApi.java | 5 +++- .../BackOfficeMemberController.java | 7 +++-- .../service/BackOfficeMemberService.java | 5 ++-- .../repository/MemberRepositoryCustom.java | 3 ++- .../repository/MemberRepositoryImpl.java | 27 +++++++++++++++++-- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java index 941d57f..70afa59 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java @@ -12,6 +12,7 @@ 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 com.ott.domain.member.domain.Role; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestParam; @@ -39,6 +40,8 @@ public interface BackOfficeMemberApi { }) ResponseEntity>> getMemberList( @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 = "한 페이지 당 최대 항목 개수를 입력해주세요. 기본값은 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 = "MEMBER") @RequestParam(value = "role", required = false) Role role ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java index 4a7291b..5420383 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -4,6 +4,7 @@ import com.ott.api_admin.member.service.BackOfficeMemberService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import com.ott.domain.member.domain.Role; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -22,10 +23,12 @@ public class BackOfficeMemberController implements BackOfficeMemberApi { @GetMapping("/admin/members") public ResponseEntity>> getMemberList( @RequestParam(value = "page", defaultValue = "0") Integer page, - @RequestParam(value = "size", defaultValue = "10") Integer size + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord, + @RequestParam(value = "role", required = false) Role role ) { return ResponseEntity.ok( - SuccessResponse.of(backOfficeMemberService.getMemberList(page, size)) + SuccessResponse.of(backOfficeMemberService.getMemberList(page, size, searchWord, role)) ); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java index 3f65f07..4a0199d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java @@ -5,6 +5,7 @@ import com.ott.common.web.response.PageInfo; import com.ott.common.web.response.PageResponse; import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; import com.ott.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -24,10 +25,10 @@ public class BackOfficeMemberService { private final MemberRepository memberRepository; @Transactional(readOnly = true) - public PageResponse getMemberList(int page, int size) { + public PageResponse getMemberList(int page, int size, String searchWord, Role role) { Pageable pageable = PageRequest.of(page, size); - Page memberPage = memberRepository.findMemberList(pageable); + Page memberPage = memberRepository.findMemberList(pageable, searchWord, role); List responseList = memberPage.getContent().stream() .map(backOfficeMemberMapper::toMemberListResponse) diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java index 5f9529e..ef6eb78 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryCustom.java @@ -1,10 +1,11 @@ package com.ott.domain.member.repository; import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface MemberRepositoryCustom { - Page findMemberList(Pageable pageable); + Page findMemberList(Pageable pageable, String searchWord, Role role); } diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java index e58fc45..5ab8faa 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepositoryImpl.java @@ -1,12 +1,15 @@ package com.ott.domain.member.repository; import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Role; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; import java.util.List; @@ -18,9 +21,13 @@ public class MemberRepositoryImpl implements MemberRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page findMemberList(Pageable pageable) { + public Page findMemberList(Pageable pageable, String searchWord, Role role) { List memberList = queryFactory .selectFrom(member) + .where( + nicknameContains(searchWord), + roleEq(role) + ) .orderBy(member.createdDate.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -28,8 +35,24 @@ public Page findMemberList(Pageable pageable) { JPAQuery countQuery = queryFactory .select(member.count()) - .from(member); + .from(member) + .where( + nicknameContains(searchWord), + roleEq(role) + ); return PageableExecutionUtils.getPage(memberList, pageable, countQuery::fetchOne); } + + private BooleanExpression nicknameContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return member.nickname.contains(searchWord); + return null; + } + + private BooleanExpression roleEq(Role role) { + if (role != null) + return member.role.eq(role); + return null; + } } From 4fa6f715deca408cb1b7769adbf1d94f9740d321 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:09:09 +0900 Subject: [PATCH 011/257] =?UTF-8?q?[FIX]=20=EA=B2=80=EC=83=89=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20CORS=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- .../com/ott/common/web/config/WebMvcConfig.java | 7 +++---- .../contents/repository/ContentsRepository.java | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index fe8f8e8..dd117ae 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ out/ ### Node ### node_modules/ package-lock.json -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml + diff --git a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java index f697302..1022ffe 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java +++ b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java @@ -10,14 +10,13 @@ public class WebMvcConfig implements WebMvcConfigurer { private final long MAX_AGE_SECS = 3600; -// @Value("${app.cors.allowed-origins}") -// private String[] allowedOrigins; + // @Value("${app.cors.allowed-origins}") + // private String[] allowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") -// .allowedOrigins(allowedOrigins) - .allowedOriginPatterns() + .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 47ec769..6c0d3fb 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -12,13 +12,13 @@ public interface ContentsRepository extends JpaRepository { - // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 미포함 콘텐츠 검색 (최신순 정렬) - @Query("SELECT c FROM Contents c " + - "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND c.status = :status " + - "AND c.series IS NULL " + - "ORDER BY c.createdDate DESC") - List searchLatest(@Param("searchWord") String searchWord, @Param("status") Status status, - Pageable pageable); + // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) + @Query("SELECT c FROM Contents c " + + "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "AND c.status = :status " + + "AND c.series IS NULL " + + "ORDER BY c.createdDate DESC") + List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, + Pageable pageable); } \ No newline at end of file From 76395c2fdbc90f2f7b48ea81087bb31d957881ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 15:52:00 +0900 Subject: [PATCH 012/257] =?UTF-8?q?[FEAT]:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=97=AD=ED=95=A0=20=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeMemberApi.java | 29 ++++++++++++++++++- .../BackOfficeMemberController.java | 17 ++++++++--- .../member/dto/request/ChangeRoleRequest.java | 14 +++++++++ .../service/BackOfficeMemberService.java | 11 +++++++ .../ott/common/web/exception/ErrorCode.java | 12 +++++++- .../com/ott/domain/member/domain/Member.java | 7 +++++ .../com/ott/domain/member/domain/Role.java | 5 ++++ 7 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java index 70afa59..1421d06 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java @@ -1,9 +1,11 @@ package com.ott.api_admin.member.controller; +import com.ott.api_admin.member.dto.request.ChangeRoleRequest; import com.ott.api_admin.member.dto.response.MemberListResponse; 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.member.domain.Role; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -12,8 +14,9 @@ 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 com.ott.domain.member.domain.Role; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Member API", description = "[백오피스] 사용자 관리 API") @@ -44,4 +47,28 @@ ResponseEntity>> getMemberList( @Parameter(description = "닉네임 부분일치 검색어. 미입력 시 전체 목록을 조회합니다.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord, @Parameter(description = "역할 필터. 미입력 시 전체 역할을 조회합니다.", required = false, example = "MEMBER") @RequestParam(value = "role", required = false) Role role ); + + @Operation(summary = "사용자 역할 변경", description = "사용자의 역할을 변경합니다. EDITOR ↔ SUSPENDED 전환만 가능합니다. - ADMIN 권한 필요.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "역할 변경 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Void.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")} + ), + @ApiResponse( + responseCode = "404", description = "사용자를 찾을 수 없음", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> changeRole( + @Parameter(description = "사용자 ID", required = true, example = "1") @PathVariable Long memberId, + @RequestBody ChangeRoleRequest changeRoleRequest + ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java index 5420383..df96d05 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -1,16 +1,15 @@ package com.ott.api_admin.member.controller; +import com.ott.api_admin.member.dto.request.ChangeRoleRequest; import com.ott.api_admin.member.dto.response.MemberListResponse; import com.ott.api_admin.member.service.BackOfficeMemberService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import com.ott.domain.member.domain.Role; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/back-office") @@ -31,4 +30,14 @@ public ResponseEntity>> getMemb SuccessResponse.of(backOfficeMemberService.getMemberList(page, size, searchWord, role)) ); } + + @Override + @PatchMapping("/admin/members/{memberId}/role") + public ResponseEntity> changeRole( + @PathVariable("memberId") Long memberId, + @Valid @RequestBody ChangeRoleRequest changeRoleRequest + ) { + backOfficeMemberService.changeRole(memberId, changeRoleRequest); + return ResponseEntity.noContent().build(); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java new file mode 100644 index 0000000..51188d6 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/dto/request/ChangeRoleRequest.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.member.dto.request; + +import com.ott.domain.member.domain.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "사용자 역할 변경 요청") +public record ChangeRoleRequest( + + @NotNull(message = "변경할 역할은 필수입니다.") + @Schema(description = "변경할 역할 (EDITOR 또는 SUSPENDED)", example = "SUSPENDED") + Role role +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java index 4a0199d..7e6bfb3 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/service/BackOfficeMemberService.java @@ -1,7 +1,10 @@ package com.ott.api_admin.member.service; +import com.ott.api_admin.member.dto.request.ChangeRoleRequest; import com.ott.api_admin.member.dto.response.MemberListResponse; import com.ott.api_admin.member.mapper.BackOfficeMemberMapper; +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.member.domain.Member; @@ -41,4 +44,12 @@ public PageResponse getMemberList(int page, int size, String ); return PageResponse.toPageResponse(pageInfo, responseList); } + + @Transactional + public void changeRole(Long memberId, ChangeRoleRequest changeRoleRequest) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + member.changeRole(changeRoleRequest.role()); + } } diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 97c1dff..497858b 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -22,23 +22,33 @@ public enum ErrorCode { INVALID_TYPE(HttpStatus.BAD_REQUEST, "C003", "타입이 올바르지 않습니다"), MISSING_BODY(HttpStatus.BAD_REQUEST, "C004", "요청 본문이 없습니다"), JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "C005", "JSON 형식이 올바르지 않습니다"), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "C006", "리소스를 찾을 수 없습니다"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C007", "허용되지 않은 메서드입니다"), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C999", "서버 오류가 발생했습니다"), // ========== Auth (A) - 인증/인가 ========== UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "인증이 필요합니다"), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰입니다"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "만료된 토큰입니다"), + FORBIDDEN(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없습니다"), // ========== User (U) - 사용자 ========== USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다"), + DUPLICATE_EMAIL(HttpStatus.CONFLICT, "U002", "이미 존재하는 이메일입니다"), // ========== Business (B) - 비즈니스 ========== CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), - SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"); + SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), + + INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B003", "허용되지 않는 역할 변경입니다") + + + ; private final HttpStatus status; private final String code; diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 0c617d7..91b7e40 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -49,4 +49,11 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + + public void changeRole(Role targetRole) { + if (!this.role.canTransitionTo(targetRole)) + throw new IllegalArgumentException("불가능한 상태 전이입니다."); + + this.role = targetRole; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java index ca28eac..3e9e8f2 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Role.java @@ -13,4 +13,9 @@ public enum Role { String key; String value; + + public boolean canTransitionTo(Role targetRole) { + return (this == EDITOR && targetRole == SUSPENDED) + || (this == SUSPENDED && targetRole == EDITOR); + } } \ No newline at end of file From dc116ba2c11a8d92d6ca1ba2fa10d6d1ea22bff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 15:52:22 +0900 Subject: [PATCH 013/257] =?UTF-8?q?[CHORE]:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=EC=97=90=20IllegalArgumentException?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/common/web/exception/GlobalExceptionHandler.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java b/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java index de153a8..607e8b5 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -74,6 +75,14 @@ protected ResponseEntity handleHttpRequestMethodNotSupportedExcep return ResponseEntity.status(ErrorCode.METHOD_NOT_ALLOWED.getStatus()).body(response); } + // [Exception] 메소드에 전달된 인수가 유효하지 않은 경우 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { + log.error("handleIllegalArgumentException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(Exception.class) protected ResponseEntity handleException(Exception ex) { log.error("Unhandled Exception: {}", ex.getMessage(), ex); From 9061344f0d5c1a6493fcf5b5bea79261c7c3dccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 16:44:40 +0900 Subject: [PATCH 014/257] =?UTF-8?q?[CHORE]:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=20=EB=AF=B8=ED=8F=AC=ED=95=A8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/domain/member/domain/Member.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 91b7e40..de2d465 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -52,7 +52,7 @@ public class Member extends BaseEntity { public void changeRole(Role targetRole) { if (!this.role.canTransitionTo(targetRole)) - throw new IllegalArgumentException("불가능한 상태 전이입니다."); + throw new IllegalArgumentException(); this.role = targetRole; } From 6a491b39f73b5ef1a8bf17029275b2407c3eb4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 17:00:24 +0900 Subject: [PATCH 015/257] =?UTF-8?q?[CHORE]:=20code=20rabbit=20=EA=B0=84?= =?UTF-8?q?=EB=8B=A8=ED=95=9C=20=EC=88=98=EC=A0=95=20=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_admin/member/controller/BackOfficeMemberApi.java | 2 +- .../api_admin/member/controller/BackOfficeMemberController.java | 2 +- .../src/main/java/com/ott/domain/member/domain/Member.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java index 1421d06..c767c87 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberApi.java @@ -67,7 +67,7 @@ ResponseEntity>> getMemberList( content = {@Content(mediaType = "application/json")} ) }) - ResponseEntity> changeRole( + ResponseEntity changeRole( @Parameter(description = "사용자 ID", required = true, example = "1") @PathVariable Long memberId, @RequestBody ChangeRoleRequest changeRoleRequest ); diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java index df96d05..4650d6d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -33,7 +33,7 @@ public ResponseEntity>> getMemb @Override @PatchMapping("/admin/members/{memberId}/role") - public ResponseEntity> changeRole( + public ResponseEntity changeRole( @PathVariable("memberId") Long memberId, @Valid @RequestBody ChangeRoleRequest changeRoleRequest ) { diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index de2d465..7e37f6e 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -52,7 +52,7 @@ public class Member extends BaseEntity { public void changeRole(Role targetRole) { if (!this.role.canTransitionTo(targetRole)) - throw new IllegalArgumentException(); + throw new IllegalArgumentException("Invalid role transition: " + this.role + " -> " + targetRole); this.role = targetRole; } From 8c0918e207b1b4efd45a3361f9565936113b4f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 20:11:07 +0900 Subject: [PATCH 016/257] =?UTF-8?q?[REFACTOR]:=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=20=EB=8F=99=EC=A0=81=20=EC=BF=BC=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BackOfficeSeriesService.java | 6 +-- .../series/repository/SeriesRepository.java | 15 +++---- .../repository/SeriesRepositoryCustom.java | 10 +++++ .../repository/SeriesRepositoryImpl.java | 45 +++++++++++++++++++ 4 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 1c693fc..b808ce7 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -36,12 +36,10 @@ public class BackOfficeSeriesService { @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDate").descending()); + Pageable pageable = PageRequest.of(page, size); // 1. keyword 유무에 따라 분기 / 시리즈 대상 페이징 - Page seriesPage = StringUtils.hasText(searchWord) - ? seriesRepository.findByTitleContaining(searchWord, pageable) - : seriesRepository.findAll(pageable); + Page seriesPage = seriesRepository.findSeriesList(pageable, searchWord); // 2. 조회된 시리즈 ID 목록 추출 List seriesIdList = seriesPage.getContent().stream() diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index 8c40ff5..e04b10f 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -2,7 +2,6 @@ import java.util.List; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,16 +10,14 @@ import com.ott.domain.common.Status; import com.ott.domain.series.domain.Series; -public interface SeriesRepository extends JpaRepository { - - Page findByTitleContaining(String keyword, Pageable pageable); +public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) @Query("SELECT s FROM Series s " + - "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND s.status = :status " + - "ORDER BY s.createdDate DESC") + "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "AND s.status = :status " + + "ORDER BY s.createdDate DESC") List searchLatest(@Param("keyword") String keyword, - @Param("status") Status status, - Pageable pageable); + @Param("status") Status status, + Pageable pageable); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java new file mode 100644 index 0000000..f5fc2b6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.series.repository; + +import com.ott.domain.series.domain.Series; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SeriesRepositoryCustom { + + Page findSeriesList(Pageable pageable, String keyword); +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java new file mode 100644 index 0000000..6d38069 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.ott.domain.series.repository; + +import com.ott.domain.series.domain.Series; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ott.domain.series.domain.QSeries.series; + +@RequiredArgsConstructor +public class SeriesRepositoryImpl implements SeriesRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findSeriesList(Pageable pageable, String searchWord) { + List seriesList = queryFactory + .selectFrom(series) + .where(titleContains(searchWord)) + .orderBy(series.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(series.count()) + .from(series) + .where(titleContains(searchWord)); + + return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return series.title.contains(searchWord); + return null; + } +} From 12f1457736e14737d34206d03914b81d79fc7835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 19 Feb 2026 20:11:56 +0900 Subject: [PATCH 017/257] =?UTF-8?q?[CHORE]:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/common/web/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index d2f5d5e..fb7c39a 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -44,7 +44,7 @@ public enum ErrorCode { // ========== Business (B) - 비즈니스 ========== CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), - SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"); + SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다") From 24e9f2c1ef69c2243d1c7d129c36c8393d9183cf Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:12:29 +0900 Subject: [PATCH 018/257] =?UTF-8?q?[FEAT]=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20DTO=20=EB=B0=8F=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../series/dto/SeriesDetailResponse.java | 46 +++++++++++++++++++ .../src/main/resources/application.yml | 2 +- git | 0 .../repository/BookmarkRepository.java | 15 ++++++ .../repository/CategoryRepository.java | 21 +++++++++ .../likes/repository/LikesRepository.java | 15 ++++++ .../series/repository/SeriesRepository.java | 14 ++++-- .../domain/tag/repository/TagRepository.java | 18 ++++++++ 8 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java create mode 100644 git create mode 100644 modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java new file mode 100644 index 0000000..a5764ad --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java @@ -0,0 +1,46 @@ +package com.ott.api_user.series.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "시리즈 상세 조회 응답 DTO") +public class SeriesDetailResponse { + + @Schema(description = "시리즈 고유 ID", example = "101") + private Long id; + + @Schema(description = "시리즈 제목", example = "비밀의 숲") + private String title; + + @Schema(description = "시리즈 설명", example = "검경 수사극의 새로운 지평을 연 드라마") + private String description; + + @Schema(description = "출연진", example = "송혜교, 이도현, 임지연") + private String actors; + + @Schema(description = "시리즈 포스터 이미지 URL", example = "https://cdn.ott.com/posters/101.jpg") + private String posterUrl; + + @Schema(description = "썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + + @Schema(description = "카테고리", example = "드라마") + private String category; + + @Schema(description = "태그 목록", example = "드라마, 범죄, 수사") + private List tags; + + @Schema(description = "사용자 북마크 여부", example = "true") + private Boolean isBookmarked; + + @Schema(description = "사용자 좋아요 여부", example = "true") + private Boolean isLiked; + +} diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index 9adde2b..b7ef0c2 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} # 로컬 포트를 3307로 변경 username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} diff --git a/git b/git new file mode 100644 index 0000000..e69de29 diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java new file mode 100644 index 0000000..6524689 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,15 @@ +package com.ott.domain.bookmark.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.common.Status; +import com.ott.domain.common.TargetType; + +public interface BookmarkRepository extends JpaRepository { + boolean existsByMemberIdAndTargetIdAndTargetTypeAndStatus( + Long memberId, + Long targetId, + TargetType targetType, + Status status); +} diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java new file mode 100644 index 0000000..574e5e5 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -0,0 +1,21 @@ +package com.ott.domain.category.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.category.domain.Category; + +public interface CategoryRepository extends JpaRepository { + + // 하나의 시리즈는 하나의 카테고리를 갖지만 + // 일단 List 형태로 처리 + @Query("SELECT DISTINCT c.name FROM Category c" + + "JOIN Tag t ON c.id = t.category.id" + + "JOIN SeriesTag st ON t.id = st.tagId" + + "WHERE st.seriesId = :seriesId" + + "AND c.status = 'ACTIVE'") + List findCategoryNameBySeriesId(@Param("seriesId") Long seriesId); +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java new file mode 100644 index 0000000..9a085e9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -0,0 +1,15 @@ +package com.ott.domain.likes.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ott.domain.common.Status; +import com.ott.domain.common.TargetType; +import com.ott.domain.likes.domain.Likes; + +public interface LikesRepository extends JpaRepository { + boolean existsByMemberIdAndTargetIdAndTargetTypeAndStatus( + Long memberId, + Long targetId, + TargetType targetType, + Status status); +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index e04b10f..fc8f5b9 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -1,6 +1,7 @@ package com.ott.domain.series.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -14,10 +15,13 @@ public interface SeriesRepository extends JpaRepository, SeriesRep // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) @Query("SELECT s FROM Series s " + - "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND s.status = :status " + - "ORDER BY s.createdDate DESC") + "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "AND s.status = :status " + + "ORDER BY s.createdDate DESC") List searchLatest(@Param("keyword") String keyword, - @Param("status") Status status, - Pageable pageable); + @Param("status") Status status, + Pageable pageable); + + // 시리즈 단건 조회 + Optional findByIdAndStatusAndPublicStatus(Long id, Status status, Boolean publicStatus); } diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java new file mode 100644 index 0000000..a97db07 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -0,0 +1,18 @@ +package com.ott.domain.tag.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.tag.domain.Tag; + +public interface TagRepository extends JpaRepository { + + @Query("SELECT t.name FROM Tag t" + + "JOIN SeriesTag st ON t.id = st.tagId" + + "WHERE st.seriesId = :seriesId" + + "AND t.status = 'ACTIVE'") + List findTagNamesBySeriesId(@Param("seriesId") Long seriesId); +} \ No newline at end of file From e9b6d993390660893f0ea4fb10bf8b85209940cc Mon Sep 17 00:00:00 2001 From: fuxxu_bin <101037608+yubin012@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:40:43 +0900 Subject: [PATCH 019/257] =?UTF-8?q?README=20=EC=B4=88=EC=95=88=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1650d7c..0f34b0c 100644 --- a/README.md +++ b/README.md @@ -1 +1,219 @@ -# backend \ No newline at end of file +# 🎬 O+T (Open the Taste) - Backend System + +## 📌 1. Project Overview +**O+T(오쁠티)** 는 단순 알고리즘 추천의 한계를 보완하고 사용자의 콘텐츠 탐색 피로도를 낮추기 위해 기획된 숏폼/롱폼 연계 OTT 플랫폼입니다. +본 레포지토리는 서비스의 백엔드 API 서버 및 비동기 영상 트랜스코딩 시스템을 포함하고 있습니다. + +핵심 비즈니스 로직은 **에디터/관리자 기반의 숏폼 업로드**와 **숏폼에서 본편(롱폼)으로의 즉각적인 전환(CTA)** 을 지원하는데 맞춰줘 있습니다. +기술적으로는 대용량 영상 처리로 읺ㄴ API 서버 부하를 방지하고 HLS 기반의 적응형 스트리밍(ABR) 을 안정적으로 제공하는 인프라 및 소프트웨어 아키텍처 설계에 집중했습니다. + + +## 2. 시스템 및 인프라 아키텍처 +### 🏗️ 전체 인프라 아키텍처 (System Architecture) + +```mermaid +flowchart LR + %% ================= Clients ================= + user["일반 사용자
(Client)"] + admin["관리자
(Client)"] + + %% ================= VPC ================= + subgraph vpc["VPC 10.0.0.0/20
Region: ap-northeast-2"] + direction LR + + %% Public + subgraph public["Public Subnets x2
(ALB 전용)"] + alb["ALB :80
- default → user-api (8080)
- /admin/* → admin-api (8081)"] + end + + %% Private App + subgraph private_app["Private App Subnet x1
(EC2 3대)"] + user_api["EC2 user-api
:8080
일반 기능 / 조회 API"] + admin_api["EC2 admin-api
:8081
Presigned URL 발급
(업로드 전용 관리)"] + worker["EC2 worker
SQS Consumer
Transcoding Server"] + end + + %% Private DB + subgraph private_db["Private DB Subnets x2
(RDS Subnet Group)"] + rds["RDS MySQL 8.0
db.t3.micro
Private / No Public Access"] + end + + %% VPC Endpoints + subgraph endpoints["VPC Endpoints (No NAT)"] + s3_ep["Gateway Endpoint
S3"] + sqs_ep["Interface Endpoint
SQS"] + ssm_ep["Interface Endpoint
SSM"] + ec2msg_ep["Interface Endpoint
EC2Messages"] + ssmm_ep["Interface Endpoint
SSMMessages"] + end + end + + %% ================= AWS Managed Services ================= + subgraph aws["AWS Managed Services
(Outside VPC)"] + s3_content["S3 Content Bucket
${project}-content-${random}
원본 & 트랜스코딩 저장"] + s3_deploy["S3 Deploy Bucket
${project}-deploy-${random}
배포 아티팩트"] + lambda["Lambda (python3.12)
s3_to_sqs
ObjectCreated Trigger"] + sqs["SQS transcode_queue
(Standard Queue)"] + dlq["SQS transcode_dlq
maxReceiveCount = 5"] + end + + %% ================= API Routing ================= + user -->|"일반 API 요청"| alb + admin -->|"관리자 API 요청"| alb + + alb -->|"default"| user_api + alb -->|"/admin/*"| admin_api + + %% ================= Database ================= + user_api -->|"조회/메타 데이터"| rds + admin_api -->|"업로드 메타 관리"| rds + worker -->|"상태 업데이트"| rds + + %% ================= Presigned Upload (핵심 구조) ================= + admin_api -. "Presigned PUT URL 발급
(S3 업로드용)" .-> admin + admin -. "직접 업로드 (PUT)
contents/{id}/origin/{file}.mp4" .-> s3_content + + %% ================= Event Driven Pipeline ================= + s3_content -->|"ObjectCreated (.mp4)"| lambda + lambda -->|"SendMessage
{bucket, key, videoId}"| sqs + sqs --> dlq + + %% ================= Worker Data Flow ================= + worker -->|"Poll 메시지"| sqs_ep + sqs_ep --> sqs + + worker -->|"원본 다운로드 / 결과 업로드"| s3_ep + s3_ep --> s3_content +``` + +위 다이어그램은 O+T 서비스의 핵심 인프라 구성도로, 네트워크 보안 강화, 비용 최적화, 미디어 처리의 비동기화에 초점을 맞추어 설계되었습니다. + +#### 1. 네트워크 격리 및 보안(VPC & Subnate) +- 외부의 모든 클라이언트 트래픽은 Public Subnet에 위치한 ALB(Application Load Balancer) 1곳을 통해서만 인입됩니다. + +- 실제 비즈니스 로직이 실행되는 3대의 EC2(User API, Admin API, Transcoder Worker)와 데이터가 저장되는 RDS MySQL은 모두 Private Subnet에 완벽히 격리하여 외부 인터넷으로부터의 직접적인 접근을 원천 차단했습니다. + +#### 2. 도메인별 트래픽 라우팅 분리 +- ALB의 경로 기반 라우팅(Path-based Routing) 규칙을 적용하여 물리적인 서버 인스턴스를 분리했습니다. + +- 에디터 전용 업로드 및 관리자 요청(/admin/*)은 Admin API 인스턴스(8081 포트)로, 검색/피드 조회/스트리밍 등 트래픽이 집중되는 일반 대고객 요청은 User API 인스턴스(8080 포트)로 전달하여 도메인 간 간섭을 최소화했습니다. + +#### 3. No-NAT 기반 프라이빗 통신(VPC Endpoints) +- Private Subnet 내부의 서버가 외부 AWS Managed Service(S3, SQS 등)와 통신하기 위해 필수적인 NAT Gateway를 과감히 제거했습니다. (월 고정 비용 절감) + +- 대신 AWS 내부망 전용선인 VPC Endpoints를 구축했습니다. 대용량 영상의 다운로드/업로드는 무료인 S3 Gateway Endpoint를 거치며, 작업 대기열 확인은 SQS Interface Endpoint를 통해 퍼블릭 인터넷망 노출 없이 안전하고 빠르게 처리됩니다. + +#### 4. 서버리스 이벤트 브릿지(Event-Driven Pipeline) +- Admin API가 S3 Presigned URL을 발급하면, 클라이언트는 서버를 거치지 않고 S3 버킷으로 원본 영상을 직행시킵니다. + +- 영상이 S3에 도착하면 발생하는 ObjectCreated 이벤트를 AWS Lambda가 즉시 낚아채어, 메타데이터와 함께 **SQS(Standard Queue)**로 트랜스코딩 작업 메시지를 밀어 넣습니다. + + +#### 5. 보안 접속 및 CI/CD 배포 자동화(AWS SSM) +- 보안 위협이 될 수 있는 외부 SSH 포트(22) 개방이나 별도의 Bastion Host(점프 서버) 구축을 배제했습니다. + +- SSM Interface Endpoint를 통해 AWS Systems Manager(Session Manager, Run Command)로 Private EC2에 안전하게 접속하며, GitHub Actions와 연동하여 무중단 자동 배포 파이프라인을 구동합니다. + + + +### 📁 소프트웨어 아키텍처 (Multi-Module Monorepo) +영상 트랜스코딩(FFmpeg)은 CPU 자원을 극도로 소모하는 작업입니다. 단일 모놀리식 구조에서 API 요청 처리와 인코딩 작업을 병행할 경우, 인코딩 부하가 일반 사용자 API의 응답 지연 및 장애로 전파될 위험이 있습니다. +이를 방지하고 개발 효율성을 높이기 위해 멀티 모듈 모노레포 및 레이어드 아키텍처를 채택했습니다. + +- **배포 단위 분리 (apps/):** + - api-user: 일반 사용자의 콘텐츠 검색, 재생, 통계 조회를 전담하는 API 서버. + + - api-admin: 관리자 및 에디터의 메타데이터 관리, 영상 업로드(Presigned URL 발급)를 전담하는 백오피스 서버. + + - transcoder: 외부 요청을 직접 받지 않고, SQS 메시지를 폴링하여 비동기로 영상을 변환하는 워커(Worker) 서버. + +- **공통 모듈 분리 (modules/):** + - 각 서버에서 공통으로 사용하는 도메인(Entity, Repository), 인프라 연동(S3, SQS 설정), 웹 공통(예외 처리, 응답 DTO), 보안(JWT, OAuth) 로직을 분리하여 코드 중복을 제거했습니다. + +``` +repo-root/ +├── apps/ ← 실제 배포 단위 (각각 독립 JAR) +│ ├── api-admin/ ← 관리자/에디터 API 서버 +│ ├── api-user/ ← 사용자 API 서버 +│ └── transcoder/ ← 트랜스코딩 워커 +│ +├── modules/ ← 공유 모듈 (단독 실행 불가, 앱에서 의존) +│ ├── domain/ ← 전체 Entity + Repository (JPA) +│ ├── infra/ ← JPA 설정 + S3 설정 +│ ├── common-web/ ← 예외처리, 응답 포맷 +│ └── common-security/ ← JWT, OAuth +│ +├── settings.gradle +└── docker-compose.yml + + +---------------------------------------- + + +repo-root/ +├── apps/ +│ ├── api-admin/ # 백오피스 서버 (JAR) +│ │ └── src/main/java/com/ott/admin/ +│ │ ├── content/ +│ │ │ ├── controller/ +│ │ │ ├── service/ +│ │ │ └── dto/ +│ ├── api-user/ # 사용자 API 서버 (JAR) +│ │ └── src/main/java/com/ott/user/ +│ │ ├── auth/ +│ │ │ ├── controller/ +│ │ │ ├── service/ +│ │ │ └── dto/ +│ │ ├── content/ +│ │ │ ├── controller/ +│ │ │ ├── service/ +│ │ │ └── dto/ +│ │ └── config/ +│ │ +│ └── transcoder/ # 트랜스코딩 워커 (JAR) +│ └── src/main/java/com/ott/transcode/ +│ ├── worker/ +│ ├── service/ +│ └── config/ +│ +├── modules/ +│ ├── domain/ # 전체 도메인 (Entity + Repository) +│ │ └── src/main/java/com/ott/domain/ +│ │ ├── content/ +│ │ │ ├── entity/ +│ │ │ └── repository/ +│ │ └── series/ +│ │ ├── entity/ +│ │ └── repository/ +│ │ +│ ├── infra/ # DB + S3 설정 +│ │ └── src/main/java/com/ott/infra/ +│ │ ├── db/ +│ │ │ ├── config/ +│ │ │ └── BaseEntity.java +│ │ └── s3/ +│ │ ├── config/ +│ │ └── S3FileService.java +│ │ +│ ├── common-web/ # 웹 공통 +│ │ └── src/main/java/com/ott/common/web/ +│ │ ├── exception/ +│ │ └── response/ +│ │ +│ └── common-security/ # 인증/인가 공통 +│ └── src/main/java/com/ott/common/security/ +│ ├── jwt/ +│ └── oauth/ +│ +├── docker-compose.yml +├── settings.gradle +└── build.gradle +``` + + +## 3. 핵심 기술 및 비즈니스 로직 +### 3.1 이벤트 기반 미디어 처리 파이프라인 + + + +### 3-2 스트리밍(영상 재생) 파이프라인 From 65c2d181c6a7da156c1098f8798ab00f829ab2d5 Mon Sep 17 00:00:00 2001 From: fuxxu_bin <101037608+yubin012@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:20:39 +0900 Subject: [PATCH 020/257] =?UTF-8?q?[DOCS]:=20=EB=A6=AC=EB=93=9C=EB=AF=B8?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0f34b0c..7252f44 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# 🎬 O+T (Open the Taste) - Backend System - ## 📌 1. Project Overview **O+T(오쁠티)** 는 단순 알고리즘 추천의 한계를 보완하고 사용자의 콘텐츠 탐색 피로도를 낮추기 위해 기획된 숏폼/롱폼 연계 OTT 플랫폼입니다. 본 레포지토리는 서비스의 백엔드 API 서버 및 비동기 영상 트랜스코딩 시스템을 포함하고 있습니다. @@ -7,9 +5,25 @@ 핵심 비즈니스 로직은 **에디터/관리자 기반의 숏폼 업로드**와 **숏폼에서 본편(롱폼)으로의 즉각적인 전환(CTA)** 을 지원하는데 맞춰줘 있습니다. 기술적으로는 대용량 영상 처리로 읺ㄴ API 서버 부하를 방지하고 HLS 기반의 적응형 스트리밍(ABR) 을 안정적으로 제공하는 인프라 및 소프트웨어 아키텍처 설계에 집중했습니다. +
+ +## 🛠️ 2. 기술 스택 (Tech Stack) +1. **언어 및 프레임워크:** Java, Spring Boot, Spring Data JPA, QueryDSL + +2. **데이터베이스:** MySQL 8.0, Flyway + +3. **로깅 및 모니터링:** Prometheus, Grafana, Loki + +4. **인프라:** AWS (EC2, RDS, S3, Lambda, ALB, VPC Endpoint) + +5. **메시지 큐:** AWS SQS (or RabbitMQ) -## 2. 시스템 및 인프라 아키텍처 -### 🏗️ 전체 인프라 아키텍처 (System Architecture) +6. **CI/CD 및 기타:** GitHub Actions, Docker, FFmpeg (Media Processing) + +
+ +## 3. 시스템 및 인프라 아키텍처 +### 3.1 🏗️ 전체 인프라 아키텍처 (System Architecture) ```mermaid flowchart LR @@ -116,7 +130,7 @@ flowchart LR -### 📁 소프트웨어 아키텍처 (Multi-Module Monorepo) +### 3.2 📁 소프트웨어 아키텍처 (Multi-Module Monorepo) 영상 트랜스코딩(FFmpeg)은 CPU 자원을 극도로 소모하는 작업입니다. 단일 모놀리식 구조에서 API 요청 처리와 인코딩 작업을 병행할 경우, 인코딩 부하가 일반 사용자 API의 응답 지연 및 장애로 전파될 위험이 있습니다. 이를 방지하고 개발 효율성을 높이기 위해 멀티 모듈 모노레포 및 레이어드 아키텍처를 채택했습니다. @@ -210,10 +224,218 @@ repo-root/ └── build.gradle ``` +
+ +## 4. 핵심 기술 및 비즈니스 로직 +### 4.1 업로드 및 트랜스코딩 프로세스 (Event-Driven Ingest) +대용량 영상 파일 업로드 시 API 서버의 I/O 병목을 방지하기 위해 다이렉트 업로드 및 비동기 큐잉 방식을 적용했습니다. + +image + + +1. 업로드 URL 발급 요청: 에디터/관리자가 API 서버(api-admin)에 업로드용 Pre-signed URL을 요청합니다. + +2. Pre-signed URL 발급: API 서버가 S3용 Pre-signed URL을 생성 후 클라이언트에 반환합니다. + +3. 원본 영상 업로드: 클라이언트가 발급받은 Pre-signed URL을 사용하여 S3에 원본 영상을 직접 업로드합니다. + +4. 업로드 완료 이벤트 발행: S3 ObjectCreated 이벤트가 발생하면 EventBridge/Lambda를 거쳐 SQS 큐에 업로드 완료 이벤트(작업 메시지)가 적재됩니다. + +5. 트랜스코더 이벤트 소비: 격리된 트랜스코딩 서버(Worker)가 SQS 큐 메시지를 수신(폴링)합니다. + +6. 트랜스코딩 작업 수행: FFmpeg를 구동하여 원본 영상을 기반으로 해상도 및 비트레이트별(360p, 720p, 1080p) 인코딩을 동시 수행합니다. + +7. HLS 패키징: 스트리밍이 가능한 HLS 형식으로 패키징하여 .m3u8(Playlist) 및 .ts(Segment) 파일들을 생성합니다. + +8. 결과물 업로드: 패키징이 완료된 최종 HLS 결과물을 S3에 업로드하고 데이터베이스 상태를 업데이트합니다. + + +### 4.1 스트리밍(영상 재생) 파이프라인 (HLS & ABR) +사용자의 디바이스 및 실시간 네트워크 환경에 맞춰 최적의 화질을 끊김 없이 제공하는 ABR(Adaptive Bitrate) 재생 프로세스입니다. + +```mermade +sequenceDiagram + autonumber + title HLS 스트리밍 재생 흐름 + + actor User as 사용자 + participant Player as 비디오 플레이어
(hls.js) + participant ABR as ABR 엔진 + participant Buffer as 버퍼 관리자 + participant CDN as CDN
(CloudFront) + participant S3 as Origin
(S3) + + %% 1. 초기화 및 Master Playlist 요청 + rect rgb(232, 245, 233) + Note over User, S3: 1. 초기화 및 Master Playlist 요청 + User->>Player: 영상 재생 클릭 + activate Player + Player->>CDN: GET /video/{id}/master.m3u8 + activate CDN + + alt 캐시 히트 + CDN-->>Player: master.m3u8 반환 + else 캐시 미스 + CDN->>S3: master.m3u8 요청 + activate S3 + S3-->>CDN: master.m3u8 + deactivate S3 + CDN->>CDN: 캐시 저장 + CDN-->>Player: master.m3u8 반환 + end + deactivate CDN + + Player->>Player: 화질 목록 파싱
(360p, 720p, 1080p) + end + + %% 2. 초기 화질 선택 + rect rgb(227, 242, 253) + Note over User, S3: 2. 초기 화질 선택 + Player->>ABR: 초기 화질 결정 요청 + activate ABR + ABR->>ABR: 네트워크 대역폭 측정
(3 Mbps) + ABR->>ABR: 안전 마진 적용
(3 × 0.8 = 2.4 Mbps) + ABR-->>Player: 720p 선택
(BANDWIDTH=2500000) + deactivate ABR + end + + %% 3. Media Playlist 요청 + rect rgb(255, 243, 224) + Note over User, S3: 3. Media Playlist 요청 + Player->>CDN: GET /video/{id}/720p/playlist.m3u8 + activate CDN + CDN-->>Player: 720p playlist.m3u8 + deactivate CDN + Player->>Player: 세그먼트 목록 파싱
(segment_000.ts ~ segment_00N.ts) + end + + %% 4. 세그먼트 순차 요청 및 재생 + rect rgb(232, 245, 233) + Note over User, S3: 4. 세그먼트 순차 요청 및 재생 + loop 세그먼트 다운로드 (정상 상태) + Player->>CDN: GET /video/{id}/720p/segment_000.ts + activate CDN + CDN-->>Player: segment_000.ts (10초 분량) + deactivate CDN + + Player->>Buffer: 세그먼트 추가 + activate Buffer + Buffer->>Buffer: 디코딩 & 버퍼링 + Buffer-->>Player: 버퍼 상태 (25초) + deactivate Buffer + + Player->>ABR: 다운로드 통계 전달
(속도, 시간) + ABR->>ABR: 대역폭 업데이트 + end + Player->>User: ▶️ 재생 시작 + end + + %% 5. 네트워크 상태 변화 감지 + rect rgb(255, 235, 238) + Note over User, S3: 5. 네트워크 상태 변화 감지 + Note over CDN: 네트워크 대역폭 저하
3 Mbps → 1 Mbps + + Player->>CDN: GET /video/{id}/720p/segment_003.ts + activate CDN + CDN-->>Player: segment_003.ts
(다운로드 지연 발생) + deactivate CDN + + Player->>ABR: 다운로드 통계 전달
(속도 저하 감지) + activate ABR + ABR->>ABR: 대역폭 재측정
(1 Mbps) + ABR->>Buffer: 버퍼 상태 확인 + Buffer-->>ABR: 현재 버퍼: 15초 + ABR->>ABR: 화질 전환 결정
(1 × 0.8 = 0.8 Mbps) + ABR-->>Player: 360p로 전환 지시
(BANDWIDTH=800000) + deactivate ABR + end + + %% 6. 화질 전환 (ABR) + rect rgb(252, 228, 236) + Note over User, S3: 6. 화질 전환 (ABR) + Player->>CDN: GET /video/{id}/360p/playlist.m3u8 + activate CDN + CDN-->>Player: 360p playlist.m3u8 + deactivate CDN + + Player->>Player: 현재 재생 위치 확인
(segment_004부터 필요) + + Player->>CDN: GET /video/{id}/360p/segment_004.ts + activate CDN + CDN-->>Player: segment_004.ts (360p) + deactivate CDN + + Player->>Buffer: 360p 세그먼트 추가 + Buffer->>Buffer: 끊김 없이 이어서 재생
(Seamless Switching) + + Note over Player, Buffer: 720p segment_003 → 360p segment_004
화질은 낮아지지만 버퍼링 없음 + end + + %% 7. 네트워크 복구 시 + rect rgb(232, 245, 233) + Note over User, S3: 7. 네트워크 복구 시 + Note over CDN: 네트워크 대역폭 복구
1 Mbps → 4 Mbps + + loop 세그먼트 다운로드 (복구 후) + Player->>CDN: GET /video/{id}/360p/segment_005.ts + CDN-->>Player: segment_005.ts (빠른 다운로드) + Player->>ABR: 다운로드 통계 전달 + ABR->>ABR: 대역폭 재측정 (4 Mbps) + ABR->>Buffer: 버퍼 상태 확인 + Buffer-->>ABR: 현재 버퍼: 30초 + end + + ABR->>ABR: 화질 상향 결정
(버퍼 충분 + 대역폭 여유) + ABR-->>Player: 720p로 복귀 지시 + + Player->>CDN: GET /video/{id}/720p/playlist.m3u8 + CDN-->>Player: 720p playlist.m3u8 + + Player->>CDN: GET /video/{id}/720p/segment_006.ts + CDN-->>Player: segment_006.ts (720p) + + Note over Player: 다시 720p로 화질 복귀 + deactivate Player + end + +``` + + +### 🎥 패키징 결과물 (디렉토리 구조) +FFmpeg를 통해 인코딩 및 HLS 패키징이 완료된 영상 데이터는 다음과 같은 구조로 S3 버킷에 적재됩니다. +``` +입력 (원본) 출력 (HLS) +─────────────────────────────────────────────────────────────── + +interview.mp4 → transcoded/{videoId}/ +├── H.264 또는 기타 코덱 ├── master.m3u8 +├── 1080p ├── 360p/ +├── 10 Mbps │ ├── playlist.m3u8 +└── 5분 단일 파일 │ ├── segment_000.ts (1MB) + │ ├── segment_001.ts + │ └── ... + ├── 720p/ + │ ├── playlist.m3u8 + │ ├── segment_000.ts (3MB) + │ └── ... + └── 1080p/ + ├── playlist.m3u8 + ├── segment_000.ts (6MB) + └── ... +``` + + +
+ +## [Next Step / 향후 계획] +1차 MVP 구현 이후, 운영 안정성을 극대화하기 위해 다음과 같은 고도화를 계획하고 있습니다. + +- **모니터링 강화:** Prometheus와 Grafana를 연동하여 트랜스코딩 워커의 CPU 임계치 초과 및 ABR 대역폭 전환 통계를 시각화. + +- **DR (재해 복구):** S3 Cross-Region Replication(교차 리전 복제)을 활용한 최소한의 영상 데이터 백업 아키텍처 구상. -## 3. 핵심 기술 및 비즈니스 로직 -### 3.1 이벤트 기반 미디어 처리 파이프라인 +- **Redis 도입 (캐싱 및 DB 쓰기 부하 분산):** 10초 단위의 이어보기 위치 갱신 데이터를 인메모리로 처리 후 DB에 일괄 저장(Write-Behind)하여 쓰기 부하를 방지하고, 실시간 인기 차트 등 조회 빈도가 높은 피드를 캐싱하여 응답 속도를 극대화할 계획 +- **Kafka 도입 :** 기존 SQS 기반의 단순 대기열을 넘어, 영상 업로드 시 트랜스코딩, 영상 분석, 썸네일 추출 등 다수의 독립적인 워커(Worker)들이 이벤트를 동시에 소비(Pub/Sub)하고 처리할 수 있는 확장성 높은 이벤트 스트리밍 아키텍처를 구축할 예정 -### 3-2 스트리밍(영상 재생) 파이프라인 From dff344c21263f14d59e33e2a47c370d08049d571 Mon Sep 17 00:00:00 2001 From: fuxxu_bin <101037608+yubin012@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:30:38 +0900 Subject: [PATCH 021/257] =?UTF-8?q?[DOCS]:=20=EB=A6=AC=EB=93=9C=EB=AF=B8?= =?UTF-8?q?=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7252f44..b583067 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **O+T(오쁠티)** 는 단순 알고리즘 추천의 한계를 보완하고 사용자의 콘텐츠 탐색 피로도를 낮추기 위해 기획된 숏폼/롱폼 연계 OTT 플랫폼입니다. 본 레포지토리는 서비스의 백엔드 API 서버 및 비동기 영상 트랜스코딩 시스템을 포함하고 있습니다. -핵심 비즈니스 로직은 **에디터/관리자 기반의 숏폼 업로드**와 **숏폼에서 본편(롱폼)으로의 즉각적인 전환(CTA)** 을 지원하는데 맞춰줘 있습니다. -기술적으로는 대용량 영상 처리로 읺ㄴ API 서버 부하를 방지하고 HLS 기반의 적응형 스트리밍(ABR) 을 안정적으로 제공하는 인프라 및 소프트웨어 아키텍처 설계에 집중했습니다. +핵심 비즈니스 로직은 **에디터/관리자 기반의 숏폼 업로드**와 **숏폼에서 본편(롱폼)으로의 즉각적인 전환(CTA)** 을 지원하는데 맞춰져 있습니다. +기술적으로는 대용량 영상 처리로 인한 API 서버 부하를 방지하고 HLS 기반의 적응형 스트리밍(ABR) 을 안정적으로 제공하는 인프라 및 소프트웨어 아키텍처 설계에 집중했습니다.
@@ -253,7 +253,7 @@ repo-root/ ### 4.1 스트리밍(영상 재생) 파이프라인 (HLS & ABR) 사용자의 디바이스 및 실시간 네트워크 환경에 맞춰 최적의 화질을 끊김 없이 제공하는 ABR(Adaptive Bitrate) 재생 프로세스입니다. -```mermade +```mermaid sequenceDiagram autonumber title HLS 스트리밍 재생 흐름 From 94b31e5e5513c835742cf63ba1dcf61399f9ece5 Mon Sep 17 00:00:00 2001 From: maru Date: Fri, 20 Feb 2026 14:37:47 +0900 Subject: [PATCH 022/257] =?UTF-8?q?[DOCS]=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index b583067..6a7c400 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ flowchart LR 위 다이어그램은 O+T 서비스의 핵심 인프라 구성도로, 네트워크 보안 강화, 비용 최적화, 미디어 처리의 비동기화에 초점을 맞추어 설계되었습니다. -#### 1. 네트워크 격리 및 보안(VPC & Subnate) +#### 1. 네트워크 격리 및 보안(VPC & Subnet) - 외부의 모든 클라이언트 트래픽은 Public Subnet에 위치한 ALB(Application Load Balancer) 1곳을 통해서만 인입됩니다. - 실제 비즈니스 로직이 실행되는 3대의 EC2(User API, Admin API, Transcoder Worker)와 데이터가 저장되는 RDS MySQL은 모두 Private Subnet에 완벽히 격리하여 외부 인터넷으로부터의 직접적인 접근을 원천 차단했습니다. @@ -204,7 +204,6 @@ repo-root/ │ │ └── src/main/java/com/ott/infra/ │ │ ├── db/ │ │ │ ├── config/ -│ │ │ └── BaseEntity.java │ │ └── s3/ │ │ ├── config/ │ │ └── S3FileService.java From 9eaf1c2efb501cde6e62006b88cca80d3e61dfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Fri, 20 Feb 2026 18:48:08 +0900 Subject: [PATCH 023/257] =?UTF-8?q?[REFACTOR]:=20V2=20DDL=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20.gitignore=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + .../migration/V2__media_table_inheritance.sql | 195 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql diff --git a/.gitignore b/.gitignore index dd117ae..3271517 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ node_modules/ package-lock.json pnpm-lock.yaml +modules/infra/src/main/resources/db/seed/ + +private_docs/ +AGENTS.md +CLAUDE.md diff --git a/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql b/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql new file mode 100644 index 0000000..5d65e54 --- /dev/null +++ b/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql @@ -0,0 +1,195 @@ +-- ============================================================ +-- V2: 클래스 테이블 상속 마이그레이션 +-- series, contents, short_form → media 공통 부모 도입 +-- ============================================================ + +-- 1. media, media_tag 테이블 생성 +CREATE TABLE IF NOT EXISTS media +( + id BIGINT AUTO_INCREMENT NOT NULL, + uploader_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + poster_url TEXT NOT NULL, + thumbnail_url TEXT NULL, + + bookmark_count BIGINT NOT NULL DEFAULT 0, + likes_count BIGINT NOT NULL DEFAULT 0, + media_type ENUM ('SERIES','CONTENTS','SHORT_FORM') NOT NULL, + public_status ENUM ('PUBLIC','PRIVATE') NOT NULL, + + created_date DATETIME NOT NULL, + modified_date DATETIME NOT NULL, + status ENUM ('DELETE','ACTIVE') NOT NULL, + + CONSTRAINT pk_media PRIMARY KEY (id) +) engine = InnoDB; + +CREATE TABLE IF NOT EXISTS media_tag +( + id BIGINT AUTO_INCREMENT NOT NULL, + tag_id BIGINT NOT NULL, + media_id BIGINT NOT NULL, + + created_date DATETIME NOT NULL, + modified_date DATETIME NOT NULL, + status ENUM ('DELETE','ACTIVE') NOT NULL, + + CONSTRAINT pk_media_tag PRIMARY KEY (id) +) engine = InnoDB; + + +-- 2. 신규 테이블 FK 설정 +ALTER TABLE media + ADD CONSTRAINT fk_media_to_member + FOREIGN KEY (uploader_id) + REFERENCES member (id); + +ALTER TABLE media_tag + ADD CONSTRAINT fk_media_tag_to_tag + FOREIGN KEY (tag_id) + REFERENCES tag (id); + +ALTER TABLE media_tag + ADD CONSTRAINT fk_media_tag_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + + +-- 3. media 상세 테이블에 media_id 컬럼 추가 + 제약조건 +-- series +ALTER TABLE series + ADD COLUMN media_id BIGINT NULL AFTER id; + +ALTER TABLE series + ADD CONSTRAINT uk_series_media UNIQUE (media_id); + +ALTER TABLE series + ADD CONSTRAINT fk_series_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +-- contents +ALTER TABLE contents + ADD COLUMN media_id BIGINT NULL AFTER id; + +ALTER TABLE contents + ADD CONSTRAINT uk_contents_media UNIQUE (media_id); + +ALTER TABLE contents + ADD CONSTRAINT fk_contents_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +-- short_form +ALTER TABLE short_form + ADD COLUMN media_id BIGINT NULL AFTER id; + +ALTER TABLE short_form + ADD CONSTRAINT uk_short_form_media UNIQUE (media_id); + +ALTER TABLE short_form + ADD CONSTRAINT fk_short_form_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + + +-- 4. bookmark: target_type + target_id → media_id +ALTER TABLE bookmark + ADD COLUMN media_id BIGINT NULL; + +ALTER TABLE bookmark + ADD CONSTRAINT fk_bookmark_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE bookmark + DROP COLUMN target_id; + +ALTER TABLE bookmark + DROP COLUMN target_type; + + +-- 5. likes: target_type + target_id → media_id +ALTER TABLE likes + ADD COLUMN media_id BIGINT NULL; + +ALTER TABLE likes + ADD CONSTRAINT fk_likes_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE likes + DROP COLUMN target_id; + +ALTER TABLE likes + DROP COLUMN target_type; + + +-- 6. ingest_job: contents_id + short_form_id → media_id +ALTER TABLE ingest_job + ADD COLUMN media_id BIGINT NULL; + +ALTER TABLE ingest_job + ADD CONSTRAINT fk_ingest_job_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE ingest_job + DROP FOREIGN KEY fk_ingest_job_to_contents; + +ALTER TABLE ingest_job + DROP FOREIGN KEY fk_ingest_job_to_short_form; + +ALTER TABLE ingest_job + DROP COLUMN contents_id; + +ALTER TABLE ingest_job + DROP COLUMN short_form_id; + + +-- 7. series_tag + contents_tag → media_tag 통합 (테이블 삭제) +DROP TABLE series_tag; +DROP TABLE contents_tag; + + +-- 8. 상세 테이블에서 media로 이동한 컬럼 제거 +-- series +ALTER TABLE series + DROP FOREIGN KEY fk_series_to_member_uploader; + +ALTER TABLE series + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN thumbnail_url, + DROP COLUMN bookmark_count, + DROP COLUMN likes_count, + DROP COLUMN public_status; + +-- contents +ALTER TABLE contents + DROP FOREIGN KEY fk_contents_to_member_uploader; + +ALTER TABLE contents + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN thumbnail_url, + DROP COLUMN bookmark_count, + DROP COLUMN likes_count, + DROP COLUMN public_status; + +-- short_form +ALTER TABLE short_form + DROP FOREIGN KEY fk_short_form_to_member_uploader; + +ALTER TABLE short_form + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN bookmark_count, + DROP COLUMN public_status; From 12a38ee4c32bd667a674758f272c7c5a4666dcd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Fri, 20 Feb 2026 18:48:23 +0900 Subject: [PATCH 024/257] =?UTF-8?q?[REFACTOR]:=20Media=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/domain/common/MediaType.java | 15 +++++ .../com/ott/domain/media/domain/Media.java | 65 +++++++++++++++++++ .../ott/domain/media_tag/domain/MediaTag.java | 39 +++++++++++ 3 files changed, 119 insertions(+) create mode 100644 modules/domain/src/main/java/com/ott/domain/common/MediaType.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/domain/Media.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java diff --git a/modules/domain/src/main/java/com/ott/domain/common/MediaType.java b/modules/domain/src/main/java/com/ott/domain/common/MediaType.java new file mode 100644 index 0000000..2ca4d5e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/common/MediaType.java @@ -0,0 +1,15 @@ +package com.ott.domain.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum MediaType { + SERIES("SERIES", "SERIES"), + CONTENTS("CONTENTS", "CONTENTS"), + SHORT_FORM("SHORT_FORM", "SHORT_FORM"); + + String key; + String value; +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java new file mode 100644 index 0000000..4ebf89a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -0,0 +1,65 @@ +package com.ott.domain.media.domain; + +import com.ott.domain.common.BaseEntity; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.member.domain.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Getter +@Table(name = "media") +public class Media extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploader_id", nullable = false) + private Member uploader; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") + private String posterUrl; + + @Column(name = "thumbnail_url", columnDefinition = "TEXT") + private String thumbnailUrl; + + @Column(name = "bookmark_count", nullable = false) + private Long bookmarkCount; + + @Column(name = "likes_count", nullable = false) + private Long likesCount; + + @Enumerated(EnumType.STRING) + @Column(name = "media_type", nullable = false) + private MediaType mediaType; + + @Enumerated(EnumType.STRING) + @Column(name = "public_status", nullable = false) + private PublicStatus publicStatus; +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java b/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java new file mode 100644 index 0000000..f6e4e28 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java @@ -0,0 +1,39 @@ +package com.ott.domain.media_tag.domain; + +import com.ott.domain.common.BaseEntity; +import com.ott.domain.media.domain.Media; +import com.ott.domain.tag.domain.Tag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Getter +@Table(name = "media_tag") +public class MediaTag extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; +} From edaa047b6370c8975ab9cb7dcbb653e0ea210d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Fri, 20 Feb 2026 18:58:05 +0900 Subject: [PATCH 025/257] =?UTF-8?q?[REFACTOR]:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/domain/bookmark/domain/Bookmark.java | 14 +++----- .../ott/domain/contents/domain/Contents.java | 34 +++--------------- .../domain/ingest_job/domain/IngestJob.java | 11 ++---- .../com/ott/domain/likes/domain/Likes.java | 14 +++----- .../com/ott/domain/series/domain/Series.java | 35 +++---------------- .../domain/short_form/domain/ShortForm.java | 28 +++------------ 6 files changed, 26 insertions(+), 110 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java b/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java index 3877f8e..d7ec9b7 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java @@ -1,12 +1,9 @@ package com.ott.domain.bookmark.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.TargetType; +import com.ott.domain.media.domain.Media; import com.ott.domain.member.domain.Member; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -36,10 +33,7 @@ public class Bookmark extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "target_id", nullable = false) - private Long targetId; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false) - private TargetType targetType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index a68a151..105653b 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -1,19 +1,17 @@ package com.ott.domain.contents.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import com.ott.domain.series.domain.Series; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -33,45 +31,23 @@ public class Contents extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") private Series series; - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; - @Column(name = "actors", nullable = false) private String actors; - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - - @Column(name = "thumbnail_url", nullable = false, columnDefinition = "TEXT") - private String thumbnailUrl; - @Column(name = "duration") private Integer duration; @Column(name = "video_size") private Integer videoSize; - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Column(name = "likes_count", nullable = false) - private Long likesCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; - @Column(name = "origin_url", nullable = false, columnDefinition = "TEXT") private String originUrl; diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java index 1285548..4db15b3 100644 --- a/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java @@ -1,8 +1,7 @@ package com.ott.domain.ingest_job.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.media.domain.Media; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -33,12 +32,8 @@ public class IngestJob extends BaseEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "short_form_id") - private ShortForm shortForm; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "contents_id") - private Contents contents; + @JoinColumn(name = "media_id", nullable = false) + private Media media; @Enumerated(EnumType.STRING) @Column(name = "ingest_status", nullable = false) diff --git a/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java b/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java index eac7832..45dd194 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java @@ -1,12 +1,9 @@ package com.ott.domain.likes.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.TargetType; +import com.ott.domain.media.domain.Media; import com.ott.domain.member.domain.Member; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -36,10 +33,7 @@ public class Likes extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "target_id", nullable = false) - private Long targetId; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false) - private TargetType targetType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java index 51b3511..d2af923 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java +++ b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java @@ -1,18 +1,15 @@ package com.ott.domain.series.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -32,32 +29,10 @@ public class Series extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; - - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @Column(name = "actors", nullable = false) private String actors; - - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - - @Column(name = "thumbnail_url", nullable = false, columnDefinition = "TEXT") - private String thumbnailUrl; - - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Column(name = "likes_count", nullable = false) - private Long likesCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 1d18dba..4358dcb 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -1,20 +1,18 @@ package com.ott.domain.short_form.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; import com.ott.domain.contents.domain.Contents; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import com.ott.domain.series.domain.Series; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -34,9 +32,9 @@ public class ShortForm extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") @@ -46,28 +44,12 @@ public class ShortForm extends BaseEntity { @JoinColumn(name = "contents_id") private Contents contents; - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; - - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - @Column(name = "duration") private Integer duration; @Column(name = "video_size") private Integer videoSize; - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; - @Column(name = "origin_url", nullable = false, columnDefinition = "TEXT") private String originUrl; From 132cf7105023c205442f568e8f146443a551a8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Fri, 20 Feb 2026 18:59:31 +0900 Subject: [PATCH 026/257] =?UTF-8?q?[REFACTOR]:=20Tag=20=EB=B0=8F=20Target?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=82=AD=EC=A0=9C=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/domain/common/TargetType.java | 15 ------- .../contents_tag/domain/ContentsTag.java | 39 ------------------- .../domain/series_tag/domain/SeriesTag.java | 39 ------------------- .../repository/SeriesTagRepository.java | 17 -------- 4 files changed, 110 deletions(-) delete mode 100644 modules/domain/src/main/java/com/ott/domain/common/TargetType.java delete mode 100644 modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java delete mode 100644 modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java delete mode 100644 modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java diff --git a/modules/domain/src/main/java/com/ott/domain/common/TargetType.java b/modules/domain/src/main/java/com/ott/domain/common/TargetType.java deleted file mode 100644 index 5b8e572..0000000 --- a/modules/domain/src/main/java/com/ott/domain/common/TargetType.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ott.domain.common; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@AllArgsConstructor -@Getter -public enum TargetType { - SHORT_FORM("SHORT_FORM", "SHORT_FORM"), - CONTENTS("CONTENTS", "CONTENTS"), - SERIES("SERIES", "SERIES"); - - String key; - String value; -} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java b/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java deleted file mode 100644 index 85a96d7..0000000 --- a/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.ott.domain.contents_tag.domain; - -import com.ott.domain.common.BaseEntity; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.tag.domain.Tag; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Builder -@Getter -@Table(name = "contents_tag") -public class ContentsTag extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id", nullable = false) - private Tag tag; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "contents_id", nullable = false) - private Contents contents; -} diff --git a/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java b/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java deleted file mode 100644 index ed492da..0000000 --- a/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.ott.domain.series_tag.domain; - -import com.ott.domain.common.BaseEntity; -import com.ott.domain.series.domain.Series; -import com.ott.domain.tag.domain.Tag; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Builder -@Getter -@Table(name = "series_tag") -public class SeriesTag extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id", nullable = false) - private Tag tag; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "series_id", nullable = false) - private Series series; -} diff --git a/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java b/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java deleted file mode 100644 index 3eb20ca..0000000 --- a/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ott.domain.series_tag.repository; - -import com.ott.domain.series_tag.domain.SeriesTag; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface SeriesTagRepository extends JpaRepository { - - @Query("SELECT st FROM SeriesTag st " - + "JOIN FETCH st.tag t " - + "JOIN FETCH t.category " - + "WHERE st.series.id IN :seriesIds") - List findWithTagAndCategoryBySeriesIds(@Param("seriesIds") List seriesIds); -} From 1046109b2c53cfec81fecb730df89d581f7b8ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Fri, 20 Feb 2026 20:04:58 +0900 Subject: [PATCH 027/257] =?UTF-8?q?[CHORE]:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackOfficeSeriesController.java | 76 ++++---- .../series/mapper/BackOfficeSeriesMapper.java | 118 ++++++------ .../service/BackOfficeSeriesService.java | 160 ++++++++--------- .../search/controller/SearchController.java | 58 +++--- .../search/service/SearchService.java | 170 +++++++++--------- .../repository/ContentsRepository.java | 48 ++--- .../series/repository/SeriesRepository.java | 46 ++--- .../repository/SeriesRepositoryImpl.java | 90 +++++----- 8 files changed, 383 insertions(+), 383 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 30fccf5..c2ac3a0 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -1,38 +1,38 @@ -package com.ott.api_admin.series.controller; - -import com.ott.api_admin.series.dto.response.SeriesDetailResponse; -import com.ott.api_admin.series.dto.response.SeriesListResponse; -import com.ott.api_admin.series.service.BackOfficeSeriesService; -import com.ott.common.web.response.PageResponse; -import com.ott.common.web.response.SuccessResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/back-office") -@RequiredArgsConstructor -public class BackOfficeSeriesController implements BackOfficeSeriesApi { - - private final BackOfficeSeriesService backOfficeSeriesService; - - @Override - @GetMapping("/admin/series") - public ResponseEntity>> getSeries( - @RequestParam(value = "page", defaultValue = "0") Integer page, - @RequestParam(value = "size", defaultValue = "10") Integer size, - @RequestParam(value = "searchWord", required = false) String searchWord - ) { - return ResponseEntity.ok( - SuccessResponse.of(backOfficeSeriesService.getSeries(page, size, searchWord)) - ); - } - - @Override - @GetMapping("/admin/series/{seriesId}") - public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { - return ResponseEntity.ok( - SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) - ); - } -} +//package com.ott.api_admin.series.controller; +// +//import com.ott.api_admin.series.dto.response.SeriesDetailResponse; +//import com.ott.api_admin.series.dto.response.SeriesListResponse; +//import com.ott.api_admin.series.service.BackOfficeSeriesService; +//import com.ott.common.web.response.PageResponse; +//import com.ott.common.web.response.SuccessResponse; +//import lombok.RequiredArgsConstructor; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.*; +// +//@RestController +//@RequestMapping("/back-office") +//@RequiredArgsConstructor +//public class BackOfficeSeriesController implements BackOfficeSeriesApi { +// +// private final BackOfficeSeriesService backOfficeSeriesService; +// +// @Override +// @GetMapping("/admin/series") +// public ResponseEntity>> getSeries( +// @RequestParam(value = "page", defaultValue = "0") Integer page, +// @RequestParam(value = "size", defaultValue = "10") Integer size, +// @RequestParam(value = "searchWord", required = false) String searchWord +// ) { +// return ResponseEntity.ok( +// SuccessResponse.of(backOfficeSeriesService.getSeries(page, size, searchWord)) +// ); +// } +// +// @Override +// @GetMapping("/admin/series/{seriesId}") +// public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { +// return ResponseEntity.ok( +// SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) +// ); +// } +//} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 11191bc..d1a4b90 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -1,59 +1,59 @@ -package com.ott.api_admin.series.mapper; - -import com.ott.api_admin.series.dto.response.SeriesDetailResponse; -import com.ott.api_admin.series.dto.response.SeriesListResponse; -import com.ott.domain.series.domain.Series; -import com.ott.domain.series_tag.domain.SeriesTag; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class BackOfficeSeriesMapper { - - public SeriesListResponse toSeriesListResponse(Series series, List seriesTagList) { - String categoryName = extractCategoryName(seriesTagList); - List tagNameList = extractTagNameList(seriesTagList); - - return new SeriesListResponse( - series.getId(), - series.getThumbnailUrl(), - series.getTitle(), - categoryName, - tagNameList, - series.getPublicStatus() - ); - } - - public SeriesDetailResponse toSeriesDetailResponse(Series series, List seriesTagList) { - String categoryName = extractCategoryName(seriesTagList); - List tagNameList = extractTagNameList(seriesTagList); - - return new SeriesDetailResponse( - series.getId(), - series.getTitle(), - series.getDescription(), - categoryName, - tagNameList, - series.getPublicStatus(), - series.getUploader().getNickname(), - series.getBookmarkCount(), - series.getActors(), - series.getPosterUrl(), - series.getThumbnailUrl() - ); - } - - private String extractCategoryName(List seriesTagList) { - return seriesTagList.stream() - .findFirst() - .map(st -> st.getTag().getCategory().getName()) - .orElse(null); - } - - private List extractTagNameList(List seriesTagList) { - return seriesTagList.stream() - .map(st -> st.getTag().getName()) - .toList(); - } -} +//package com.ott.api_admin.series.mapper; +// +//import com.ott.api_admin.series.dto.response.SeriesDetailResponse; +//import com.ott.api_admin.series.dto.response.SeriesListResponse; +//import com.ott.domain.series.domain.Series; +//import com.ott.domain.series_tag.domain.SeriesTag; +//import org.springframework.stereotype.Component; +// +//import java.util.List; +// +//@Component +//public class BackOfficeSeriesMapper { +// +// public SeriesListResponse toSeriesListResponse(Series series, List seriesTagList) { +// String categoryName = extractCategoryName(seriesTagList); +// List tagNameList = extractTagNameList(seriesTagList); +// +// return new SeriesListResponse( +// series.getId(), +// series.getThumbnailUrl(), +// series.getTitle(), +// categoryName, +// tagNameList, +// series.getPublicStatus() +// ); +// } +// +// public SeriesDetailResponse toSeriesDetailResponse(Series series, List seriesTagList) { +// String categoryName = extractCategoryName(seriesTagList); +// List tagNameList = extractTagNameList(seriesTagList); +// +// return new SeriesDetailResponse( +// series.getId(), +// series.getTitle(), +// series.getDescription(), +// categoryName, +// tagNameList, +// series.getPublicStatus(), +// series.getUploader().getNickname(), +// series.getBookmarkCount(), +// series.getActors(), +// series.getPosterUrl(), +// series.getThumbnailUrl() +// ); +// } +// +// private String extractCategoryName(List seriesTagList) { +// return seriesTagList.stream() +// .findFirst() +// .map(st -> st.getTag().getCategory().getName()) +// .orElse(null); +// } +// +// private List extractTagNameList(List seriesTagList) { +// return seriesTagList.stream() +// .map(st -> st.getTag().getName()) +// .toList(); +// } +//} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index b808ce7..0615ec6 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,80 +1,80 @@ -package com.ott.api_admin.series.service; - -import com.ott.api_admin.series.dto.response.SeriesDetailResponse; -import com.ott.api_admin.series.dto.response.SeriesListResponse; -import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; -import com.ott.common.web.exception.BusinessException; -import com.ott.common.web.exception.ErrorCode; -import com.ott.domain.series.repository.SeriesRepository; -import com.ott.domain.series_tag.repository.SeriesTagRepository; -import com.ott.common.web.response.PageInfo; -import com.ott.common.web.response.PageResponse; -import com.ott.domain.series.domain.Series; -import com.ott.domain.series_tag.domain.SeriesTag; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Service -public class BackOfficeSeriesService { - - private final BackOfficeSeriesMapper backOfficeSeriesMapper; - - private final SeriesRepository seriesRepository; - private final SeriesTagRepository seriesTagRepository; - - @Transactional(readOnly = true) - public PageResponse getSeries(int page, int size, String searchWord) { - Pageable pageable = PageRequest.of(page, size); - - // 1. keyword 유무에 따라 분기 / 시리즈 대상 페이징 - Page seriesPage = seriesRepository.findSeriesList(pageable, searchWord); - - // 2. 조회된 시리즈 ID 목록 추출 - List seriesIdList = seriesPage.getContent().stream() - .map(Series::getId) - .toList(); - - // 3. IN절로 태그 일괄 조회 - Map> tagListBySeriesId = seriesIdList.isEmpty() - ? Collections.emptyMap() - : seriesTagRepository.findWithTagAndCategoryBySeriesIds(seriesIdList).stream() - .collect(Collectors.groupingBy(st -> st.getSeries().getId())); - - List responseList = seriesPage.getContent().stream() - .map(series -> backOfficeSeriesMapper.toSeriesListResponse( - series, - tagListBySeriesId.getOrDefault(series.getId(), List.of()) - )) - .toList(); - - PageInfo pageInfo = PageInfo.toPageInfo( - seriesPage.getNumber(), - seriesPage.getTotalPages(), - seriesPage.getSize() - ); - return PageResponse.toPageResponse(pageInfo, responseList); - } - - @Transactional(readOnly = true) - public SeriesDetailResponse getSeriesDetail(Long seriesId) { - Series series = seriesRepository.findById(seriesId) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - - List seriesTagList = seriesTagRepository - .findWithTagAndCategoryBySeriesIds(List.of(seriesId)); - - return backOfficeSeriesMapper.toSeriesDetailResponse(series, seriesTagList); - } -} +//package com.ott.api_admin.series.service; +// +//import com.ott.api_admin.series.dto.response.SeriesDetailResponse; +//import com.ott.api_admin.series.dto.response.SeriesListResponse; +//import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; +//import com.ott.common.web.exception.BusinessException; +//import com.ott.common.web.exception.ErrorCode; +//import com.ott.domain.series.repository.SeriesRepository; +//import com.ott.domain.series_tag.repository.SeriesTagRepository; +//import com.ott.common.web.response.PageInfo; +//import com.ott.common.web.response.PageResponse; +//import com.ott.domain.series.domain.Series; +//import com.ott.domain.series_tag.domain.SeriesTag; +//import lombok.RequiredArgsConstructor; +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.PageRequest; +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.domain.Sort; +//import org.springframework.stereotype.Service; +//import org.springframework.transaction.annotation.Transactional; +//import org.springframework.util.StringUtils; +// +//import java.util.Collections; +//import java.util.List; +//import java.util.Map; +//import java.util.stream.Collectors; +// +//@RequiredArgsConstructor +//@Service +//public class BackOfficeSeriesService { +// +// private final BackOfficeSeriesMapper backOfficeSeriesMapper; +// +// private final SeriesRepository seriesRepository; +// private final SeriesTagRepository seriesTagRepository; +// +// @Transactional(readOnly = true) +// public PageResponse getSeries(int page, int size, String searchWord) { +// Pageable pageable = PageRequest.of(page, size); +// +// // 1. keyword 유무에 따라 분기 / 시리즈 대상 페이징 +// Page seriesPage = seriesRepository.findSeriesList(pageable, searchWord); +// +// // 2. 조회된 시리즈 ID 목록 추출 +// List seriesIdList = seriesPage.getContent().stream() +// .map(Series::getId) +// .toList(); +// +// // 3. IN절로 태그 일괄 조회 +// Map> tagListBySeriesId = seriesIdList.isEmpty() +// ? Collections.emptyMap() +// : seriesTagRepository.findWithTagAndCategoryBySeriesIds(seriesIdList).stream() +// .collect(Collectors.groupingBy(st -> st.getSeries().getId())); +// +// List responseList = seriesPage.getContent().stream() +// .map(series -> backOfficeSeriesMapper.toSeriesListResponse( +// series, +// tagListBySeriesId.getOrDefault(series.getId(), List.of()) +// )) +// .toList(); +// +// PageInfo pageInfo = PageInfo.toPageInfo( +// seriesPage.getNumber(), +// seriesPage.getTotalPages(), +// seriesPage.getSize() +// ); +// return PageResponse.toPageResponse(pageInfo, responseList); +// } +// +// @Transactional(readOnly = true) +// public SeriesDetailResponse getSeriesDetail(Long seriesId) { +// Series series = seriesRepository.findById(seriesId) +// .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); +// +// List seriesTagList = seriesTagRepository +// .findWithTagAndCategoryBySeriesIds(List.of(seriesId)); +// +// return backOfficeSeriesMapper.toSeriesDetailResponse(series, seriesTagList); +// } +//} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java index 177213f..f6573ba 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java @@ -1,29 +1,29 @@ -package com.ott.api_user.search.controller; - -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.ott.api_user.search.service.SearchService; -import com.ott.common.web.response.PageResponse; -import com.ott.common.web.response.SuccessResponse; -import lombok.RequiredArgsConstructor; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestParam; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/search") -public class SearchController implements SearchApi { - private final SearchService searchService; - - @Override - public ResponseEntity> search( - @RequestParam String searchWord, - @RequestParam Integer page, - @RequestParam Integer size) { - PageResponse response = searchService.search(searchWord, page, size); - return ResponseEntity.ok(SuccessResponse.of(response)); - } - -} +//package com.ott.api_user.search.controller; +// +//import org.springframework.web.bind.annotation.RequestMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import com.ott.api_user.search.service.SearchService; +//import com.ott.common.web.response.PageResponse; +//import com.ott.common.web.response.SuccessResponse; +//import lombok.RequiredArgsConstructor; +// +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.RequestParam; +// +//@RestController +//@RequiredArgsConstructor +//@RequestMapping("/search") +//public class SearchController implements SearchApi { +// private final SearchService searchService; +// +// @Override +// public ResponseEntity> search( +// @RequestParam String searchWord, +// @RequestParam Integer page, +// @RequestParam Integer size) { +// PageResponse response = searchService.search(searchWord, page, size); +// return ResponseEntity.ok(SuccessResponse.of(response)); +// } +// +//} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java index 8e559c7..f442da8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java @@ -1,85 +1,85 @@ -package com.ott.api_user.search.service; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Stream; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import com.ott.api_user.search.dto.SearchItemResponse; -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.Status; -import com.ott.domain.contents.repository.ContentsRepository; -import com.ott.domain.series.domain.Series; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.series.repository.SeriesRepository; -import lombok.RequiredArgsConstructor; - -// 최신순 정렬을 위해 DB 페이징 방식 대신, -// 검색 결과를 모두 가져와서 Java Stream으로 정렬 후, 페이지네이션 처리하는 방식으로 변경 -// 추후 검색 대상이 늘어나거나 데이터 양이 많아질 경우, Querydsl 으로 검색 쿼리 최적화 필요! -@Service -@RequiredArgsConstructor -public class SearchService { - private final ContentsRepository contentsRepository; - private final SeriesRepository seriesRepository; - - public PageResponse search(String searchWord, int page, int size) { - - if (searchWord == null || searchWord.length() < 2) { - throw new BusinessException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); - } - - // 사용자가 흔한 검색어 입력 시 너무 많은 데이터를 가져올 수 있으므로 - // 일단 최대 100개까지만 가져오도록 제한 - Pageable limit = PageRequest.of(0, 100); - - // 에피소드 제외, 시리즈와 단일 콘텐츠만 검색 - List contentsList = contentsRepository.searchLatest(searchWord, Status.ACTIVE, limit); - List seriesList = seriesRepository.searchLatest(searchWord, Status.ACTIVE, limit); - - // 컨텐츠+시리즈 통합 정렬 - List allResults = Stream.concat( - contentsList.stream().map(c -> SearchItemResponse.builder() - .type("CONTENTS") - .id(c.getId()) - .title(c.getTitle()) - .posterUrl(c.getPosterUrl()) - .createdAt(c.getCreatedDate()) - .build()), - seriesList.stream().map(s -> SearchItemResponse.builder() - .type("SERIES") - .id(s.getId()) - .title(s.getTitle()) - .posterUrl(s.getPosterUrl()) - .createdAt(s.getCreatedDate()) - .build())) - .filter(item -> item.getCreatedAt() != null) - .sorted(Comparator.comparing(SearchItemResponse::getCreatedAt).reversed()) // 통합 최신순 정렬 - .toList(); - - // 페이징 계산 (직접 자르기) - int totalElements = allResults.size(); - int totalPages = (int) Math.ceil((double) totalElements / size); - - int start = Math.min(page * size, totalElements); - int end = Math.min(start + size, totalElements); - - List pagedResult = allResults.subList(start, end); - - PageInfo pageInfo = PageInfo.builder() - .currentPage(page) - .totalPage(totalPages) - .pageSize(size) - .build(); - - return PageResponse.toPageResponse(pageInfo, pagedResult); - } -} +//package com.ott.api_user.search.service; +// +//import java.util.ArrayList; +//import java.util.Comparator; +//import java.util.List; +//import java.util.stream.Stream; +// +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.PageRequest; +//import org.springframework.data.domain.Pageable; +//import org.springframework.stereotype.Service; +// +//import com.ott.api_user.search.dto.SearchItemResponse; +//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.Status; +//import com.ott.domain.contents.repository.ContentsRepository; +//import com.ott.domain.series.domain.Series; +//import com.ott.domain.contents.domain.Contents; +//import com.ott.domain.series.repository.SeriesRepository; +//import lombok.RequiredArgsConstructor; +// +//// 최신순 정렬을 위해 DB 페이징 방식 대신, +//// 검색 결과를 모두 가져와서 Java Stream으로 정렬 후, 페이지네이션 처리하는 방식으로 변경 +//// 추후 검색 대상이 늘어나거나 데이터 양이 많아질 경우, Querydsl 으로 검색 쿼리 최적화 필요! +//@Service +//@RequiredArgsConstructor +//public class SearchService { +// private final ContentsRepository contentsRepository; +// private final SeriesRepository seriesRepository; +// +// public PageResponse search(String searchWord, int page, int size) { +// +// if (searchWord == null || searchWord.length() < 2) { +// throw new BusinessException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); +// } +// +// // 사용자가 흔한 검색어 입력 시 너무 많은 데이터를 가져올 수 있으므로 +// // 일단 최대 100개까지만 가져오도록 제한 +// Pageable limit = PageRequest.of(0, 100); +// +// // 에피소드 제외, 시리즈와 단일 콘텐츠만 검색 +// List contentsList = contentsRepository.searchLatest(searchWord, Status.ACTIVE, limit); +// List seriesList = seriesRepository.searchLatest(searchWord, Status.ACTIVE, limit); +// +// // 컨텐츠+시리즈 통합 정렬 +// List allResults = Stream.concat( +// contentsList.stream().map(c -> SearchItemResponse.builder() +// .type("CONTENTS") +// .id(c.getId()) +// .title(c.getTitle()) +// .posterUrl(c.getPosterUrl()) +// .createdAt(c.getCreatedDate()) +// .build()), +// seriesList.stream().map(s -> SearchItemResponse.builder() +// .type("SERIES") +// .id(s.getId()) +// .title(s.getTitle()) +// .posterUrl(s.getPosterUrl()) +// .createdAt(s.getCreatedDate()) +// .build())) +// .filter(item -> item.getCreatedAt() != null) +// .sorted(Comparator.comparing(SearchItemResponse::getCreatedAt).reversed()) // 통합 최신순 정렬 +// .toList(); +// +// // 페이징 계산 (직접 자르기) +// int totalElements = allResults.size(); +// int totalPages = (int) Math.ceil((double) totalElements / size); +// +// int start = Math.min(page * size, totalElements); +// int end = Math.min(start + size, totalElements); +// +// List pagedResult = allResults.subList(start, end); +// +// PageInfo pageInfo = PageInfo.builder() +// .currentPage(page) +// .totalPage(totalPages) +// .pageSize(size) +// .build(); +// +// return PageResponse.toPageResponse(pageInfo, pagedResult); +// } +//} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 6c0d3fb..9fd59b6 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -1,24 +1,24 @@ -package com.ott.domain.contents.repository; - -import java.util.List; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import com.ott.domain.common.Status; -import com.ott.domain.contents.domain.Contents; - -public interface ContentsRepository extends JpaRepository { - - // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) - @Query("SELECT c FROM Contents c " + - "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND c.status = :status " + - "AND c.series IS NULL " + - "ORDER BY c.createdDate DESC") - List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, - Pageable pageable); - -} \ No newline at end of file +//package com.ott.domain.contents.repository; +// +//import java.util.List; +// +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.jpa.repository.JpaRepository; +//import org.springframework.data.jpa.repository.Query; +//import org.springframework.data.repository.query.Param; +// +//import com.ott.domain.common.Status; +//import com.ott.domain.contents.domain.Contents; +// +//public interface ContentsRepository extends JpaRepository { +// +// // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) +// @Query("SELECT c FROM Contents c " + +// "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + +// "AND c.status = :status " + +// "AND c.series IS NULL " + +// "ORDER BY c.createdDate DESC") +// List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, +// Pageable pageable); +// +//} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index e04b10f..1cea6a4 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -1,23 +1,23 @@ -package com.ott.domain.series.repository; - -import java.util.List; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import com.ott.domain.common.Status; -import com.ott.domain.series.domain.Series; - -public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { - - // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) - @Query("SELECT s FROM Series s " + - "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND s.status = :status " + - "ORDER BY s.createdDate DESC") - List searchLatest(@Param("keyword") String keyword, - @Param("status") Status status, - Pageable pageable); -} +//package com.ott.domain.series.repository; +// +//import java.util.List; +// +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.jpa.repository.JpaRepository; +//import org.springframework.data.jpa.repository.Query; +//import org.springframework.data.repository.query.Param; +// +//import com.ott.domain.common.Status; +//import com.ott.domain.series.domain.Series; +// +//public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { +// +// // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) +// @Query("SELECT s FROM Series s " + +// "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + +// "AND s.status = :status " + +// "ORDER BY s.createdDate DESC") +// List searchLatest(@Param("keyword") String keyword, +// @Param("status") Status status, +// Pageable pageable); +//} diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java index 6d38069..4dc78ec 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -1,45 +1,45 @@ -package com.ott.domain.series.repository; - -import com.ott.domain.series.domain.Series; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.util.StringUtils; - -import java.util.List; - -import static com.ott.domain.series.domain.QSeries.series; - -@RequiredArgsConstructor -public class SeriesRepositoryImpl implements SeriesRepositoryCustom { - - private final JPAQueryFactory queryFactory; - - @Override - public Page findSeriesList(Pageable pageable, String searchWord) { - List seriesList = queryFactory - .selectFrom(series) - .where(titleContains(searchWord)) - .orderBy(series.createdDate.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - JPAQuery countQuery = queryFactory - .select(series.count()) - .from(series) - .where(titleContains(searchWord)); - - return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); - } - - private BooleanExpression titleContains(String searchWord) { - if (StringUtils.hasText(searchWord)) - return series.title.contains(searchWord); - return null; - } -} +//package com.ott.domain.series.repository; +// +//import com.ott.domain.series.domain.Series; +//import com.querydsl.core.types.dsl.BooleanExpression; +//import com.querydsl.jpa.impl.JPAQuery; +//import com.querydsl.jpa.impl.JPAQueryFactory; +//import lombok.RequiredArgsConstructor; +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.support.PageableExecutionUtils; +//import org.springframework.util.StringUtils; +// +//import java.util.List; +// +//import static com.ott.domain.series.domain.QSeries.series; +// +//@RequiredArgsConstructor +//public class SeriesRepositoryImpl implements SeriesRepositoryCustom { +// +// private final JPAQueryFactory queryFactory; +// +// @Override +// public Page findSeriesList(Pageable pageable, String searchWord) { +// List seriesList = queryFactory +// .selectFrom(series) +// .where(titleContains(searchWord)) +// .orderBy(series.createdDate.desc()) +// .offset(pageable.getOffset()) +// .limit(pageable.getPageSize()) +// .fetch(); +// +// JPAQuery countQuery = queryFactory +// .select(series.count()) +// .from(series) +// .where(titleContains(searchWord)); +// +// return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); +// } +// +// private BooleanExpression titleContains(String searchWord) { +// if (StringUtils.hasText(searchWord)) +// return series.title.contains(searchWord); +// return null; +// } +//} From 241b080882438641f34593aadda311c0f651df32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Fri, 20 Feb 2026 20:37:36 +0900 Subject: [PATCH 028/257] =?UTF-8?q?[REFACTOR]:=20media=20id=20FK=20NOT=20N?= =?UTF-8?q?ULL=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V2__media_table_inheritance.sql | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql b/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql index 5d65e54..86ab538 100644 --- a/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql +++ b/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql @@ -59,7 +59,7 @@ ALTER TABLE media_tag -- 3. media 상세 테이블에 media_id 컬럼 추가 + 제약조건 -- series ALTER TABLE series - ADD COLUMN media_id BIGINT NULL AFTER id; + ADD COLUMN media_id BIGINT NOT NULL AFTER id; ALTER TABLE series ADD CONSTRAINT uk_series_media UNIQUE (media_id); @@ -71,7 +71,7 @@ ALTER TABLE series -- contents ALTER TABLE contents - ADD COLUMN media_id BIGINT NULL AFTER id; + ADD COLUMN media_id BIGINT NOT NULL AFTER id; ALTER TABLE contents ADD CONSTRAINT uk_contents_media UNIQUE (media_id); @@ -83,7 +83,7 @@ ALTER TABLE contents -- short_form ALTER TABLE short_form - ADD COLUMN media_id BIGINT NULL AFTER id; + ADD COLUMN media_id BIGINT NOT NULL AFTER id; ALTER TABLE short_form ADD CONSTRAINT uk_short_form_media UNIQUE (media_id); @@ -96,7 +96,7 @@ ALTER TABLE short_form -- 4. bookmark: target_type + target_id → media_id ALTER TABLE bookmark - ADD COLUMN media_id BIGINT NULL; + ADD COLUMN media_id BIGINT NOT NULL; ALTER TABLE bookmark ADD CONSTRAINT fk_bookmark_to_media @@ -112,7 +112,7 @@ ALTER TABLE bookmark -- 5. likes: target_type + target_id → media_id ALTER TABLE likes - ADD COLUMN media_id BIGINT NULL; + ADD COLUMN media_id BIGINT NOT NULL; ALTER TABLE likes ADD CONSTRAINT fk_likes_to_media @@ -128,7 +128,7 @@ ALTER TABLE likes -- 6. ingest_job: contents_id + short_form_id → media_id ALTER TABLE ingest_job - ADD COLUMN media_id BIGINT NULL; + ADD COLUMN media_id BIGINT NOT NULL; ALTER TABLE ingest_job ADD CONSTRAINT fk_ingest_job_to_media From ac70f3e4b94b7b4c2bf275124161e1c88d6b0efd Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 20 Feb 2026 21:41:42 +0900 Subject: [PATCH 029/257] =?UTF-8?q?[FEAT]:=20Member=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20Repository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/domain/member/domain/Member.java | 23 +++++++++++++++++++ .../member/repository/MemberRepository.java | 15 ++++++++++++ .../repository/PreferredTagRepository.java | 8 +++++++ 3 files changed, 46 insertions(+) create mode 100644 modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 0c617d7..3925eb8 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -49,4 +49,27 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + + public static Member createKakaoMember(String providerId, String email, String nickname) { + return Member.builder() + .provider(Provider.KAKAO) + .providerId(providerId) + .email(email) + .nickname(nickname) + .role(Role.MEMBER) + .build(); + } + + public void updateKakaoProfile(String email, String nickname) { + this.email = email; + this.nickname = nickname; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void clearRefreshToken() { + this.refreshToken = null; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..fac831e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -0,0 +1,15 @@ +package com.ott.domain.member.repository; + +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + // 기존 회원인지 신규 회원인지 DB 조회 + Optional findByProviderAndProviderId(Provider provider, String providerId); + + Optional findByEmailAndProvider(String email, Provider provider); +} diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java new file mode 100644 index 0000000..823bed6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -0,0 +1,8 @@ +package com.ott.domain.preferred_tag.repository; + +import com.ott.domain.preferred_tag.domain.PreferredTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PreferredTagRepository extends JpaRepository { + boolean existsByMemberId(Long memberId); +} From 9768e828a8940f478464f04a3ec389511051703a Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 20 Feb 2026 21:42:12 +0900 Subject: [PATCH 030/257] =?UTF-8?q?[DELETE]:=20gitKeep=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep | 0 apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep | 0 .../src/main/java/com/ott/common/security/jwt/.gitkeep | 0 .../src/main/java/com/ott/common/security/oauth/.gitkeep | 0 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep delete mode 100644 modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep delete mode 100644 modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep b/modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep b/modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep deleted file mode 100644 index e69de29..0000000 From 37ff80f6f7e674bc0fcd854178b095582c272910 Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 20 Feb 2026 21:44:14 +0900 Subject: [PATCH 031/257] =?UTF-8?q?[FEAT]:=20=EC=BF=A0=ED=82=A4=EB=A5=BC?= =?UTF-8?q?=20=ED=86=B5=ED=95=9C=20=ED=86=A0=ED=81=B0=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilter.java | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java index 54f738e..5c22967 100644 --- a/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java +++ b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java @@ -5,6 +5,7 @@ import com.ott.common.web.exception.ErrorCode; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -19,7 +20,9 @@ /** * 들어오는 요청을 가로채서 토큰을 꺼내서 provider에게 검증을 요청 - * provider에서 정상임을 반환하면 Authentication을 securityContext에 넣음 + * 토큰은 보통 Authorization 헤더 or accssToken 쿠키에서 꺼냄 // 현재는 쿠키에 httpOnly로 저장중 + * provider에서 토큰을 검증하고 검증이 성공하면 Authentication객체를 생성해서 SecurityContextHolder에 authentication 저장함 + * 이후 컨트롤러에서 authentication 받아서 사용함 */ @Component @RequiredArgsConstructor @@ -36,7 +39,7 @@ protected void doFilterInternal( // 토큰 꺼내옴 String token = resolveToken(request); - // 토큰을 검증하여 인증 없음, 만료됨, 유효x일 경우 에러코드를 저장 + // 토큰을 검증하여 인증 없음, 만료됨, 유효x일 경우 에러코드를 저장, 검증 통과 시 Authentication 생성 if(token != null) { ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(token); @@ -46,11 +49,11 @@ protected void doFilterInternal( // auth: ["ROLE_USER"] List authorities = jwtTokenProvider.getAuthorities(token); - // Authentication을 만듬 + // Authentication을 만듬 -> 민감한 정보 저장 x UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - memberId, // 추후 UserDetails?로 변경 예정 - null, - authorities.stream() + memberId, // principal // 추후 UserDetails로 변경할 예정 아마도 + null, // credentials + authorities.stream() // grantedAuthorities .map(SimpleGrantedAuthority::new) .toList() ); @@ -65,12 +68,22 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - // request에서 토큰 빼오기 + // 토큰 빼오기 private String resolveToken(HttpServletRequest request) { + //Authorization 헤더에서 토큰 빼오기 시도 String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { return bearer.substring(7); } + + // 쿠키에서 accessToken 빼오기 시도 + if(request.getCookies() != null) { + for(Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } return null; } From 8678ac8a914624895c5f868a91ab445aca93bfef Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 20 Feb 2026 21:54:58 +0900 Subject: [PATCH 032/257] =?UTF-8?q?[FEAT]:=20Kakao=20OAuth2=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 135 ++++++++++++++++++ .../ott/api_user/auth/dto/TokenResponse.java | 11 ++ .../auth/oauth2/CustomOAuth2UserService.java | 60 ++++++++ .../oauth2/handler/OAuth2FailureHandler.java | 35 +++++ .../oauth2/handler/OAuth2SuccessHandler.java | 99 +++++++++++++ .../auth/oauth2/userinfo/KakaoUserInfo.java | 24 ++++ .../api_user/auth/service/AuthService.java | 76 ++++++++++ .../auth/service/KakaoAuthService.java | 63 ++++++++ 8 files changed, 503 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java new file mode 100644 index 0000000..a314540 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -0,0 +1,135 @@ +package com.ott.api_user.auth.controller; + + +import com.ott.api_user.auth.dto.TokenResponse; +import com.ott.api_user.auth.service.AuthService; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController implements AuthApi { + + private final AuthService authService; + + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + + // Access Token 재발급 + @PostMapping("reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response) { + + String refreshToken = extractCookie(request, "refreshToken"); + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // access + refresh 재발급 -> 보안성 측면 + TokenResponse tokenResponse = authService.reissue(refreshToken); + + // 쿠키에 저장 + addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + + return ResponseEntity.noContent().build(); + } + + /** + * 로그아웃 + * DB는 refreshToken 삭제 + * 쿠키는 Controller에서 직접 삭제 + */ + @PostMapping("/logout") + public ResponseEntity logout( + Authentication authentication, + HttpServletResponse response) { + + Long memberId = (Long) authentication.getPrincipal(); + authService.logout(memberId); + + deleteCookie(response, "accessToken"); + deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + + + // 임시 테스트 코드 -> 추후 프론트 페이지로 변경 예정 + @GetMapping("logincheck") + public ResponseEntity> logincheck( + @RequestParam(value = "isNewMember") boolean isNewMember, + HttpServletRequest request + ) { + String accessToken = extractCookie(request, "accessToken"); + String refreshToken = extractCookie(request, "refreshToken"); + + + return ResponseEntity.ok(Map.of( + "isNewMember", isNewMember, + "accessToken", accessToken, + "refreshToken", refreshToken + )); + } + + + // 인가 테스트용 코드 -> 이렇게 @AuthenticationPrincipal로 쓰시면 됩니다. + // 추후 memberId -> UserDetails로 리팩토링 예정 + @GetMapping("/me") + public Long me(@AuthenticationPrincipal Long memberId) { + return memberId; + } + + + + // 쿠키에 대한 접근은 HTTP고 서비스로 내려가면 안되기 때문에 Controller에서 구현 + private String extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) { + return null; + } + + for (Cookie cookie: request.getCookies()) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true 변경 + cookie.setPath("/"); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + private void deleteCookie(HttpServletResponse response, String name) { + Cookie cookie = new Cookie(name, null); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true 변경 + cookie.setPath("/"); + cookie.setMaxAge(0); // 즉시 삭제 + response.addCookie(cookie); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java new file mode 100644 index 0000000..2bc7777 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package com.ott.api_user.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..5f4a890 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,60 @@ +package com.ott.api_user.auth.oauth2; + +import com.ott.api_user.auth.oauth2.userinfo.KakaoUserInfo; +import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.domain.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + /** + * 사용자가 카카오 로그인화면에서 로그인 + * 카카오 인증 서버에서 인가코드와 함께 리다이렉트 + * 스프링 시큐리티의 OAuth2LoginAuthenicationFilter가 인가코드를 token-url에 전달하여 Access token 교환 (자동 구현) + * DefaultOAuthUserService에서 기본적으로 loadUser를 호출하여 user-info-uri을 통하여 유저 객체인 oAuth2User를 생성 + */ + private final KakaoAuthService kakaoAuthService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2UserRequest -> 어떤 클라이이언트, provider가 저장됨 + + // 로그인된 객체는 로그인 정보가 없음 + // loadUser를 통해서 info-url를 통해 attributes를 채운 OAuth2User를 만듬 + OAuth2User oAuth2User = super.loadUser(userRequest); + + // 카카오 응답 객체 파싱 + KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); + + // DB 조회 + Member member = kakaoAuthService.findOrCreateMember(kakaoUserInfo); + + // 신규 회원 판별 + boolean isNewMember = kakaoAuthService.isNewMember(member.getId()); + + // attribute에 memberId(PK)와 신규 유저 유무를 적재 + // payload memberId, isNewMember만 들어감 -> 민감한 정보 적재 x + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + attributes.put("memberId", member.getId()); + attributes.put("isNewMember", isNewMember); + + // 스프링 시큐리티에 넘길 객체 반환 이때 권한은 ROLE_MEMBER임 + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority(member.getRole().getKey())), + attributes, + "id" + ); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java new file mode 100644 index 0000000..1d6b225 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java @@ -0,0 +1,35 @@ +package com.ott.api_user.auth.oauth2.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${app.frontend-url}") + private String frontedUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + log.info("OAuth2 로그인 실패: {}", exception.getMessage()); + + String targetUrl = frontedUrl + "/auth/login?error=" + + URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..a4817e7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -0,0 +1,99 @@ +package com.ott.api_user.auth.oauth2.handler; + +import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.common.security.jwt.JwtTokenProvider; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.List; + +/** + * Oauth2 성공시 해당 핸들러 자동 호출 + * 카카오 로그인 성공시 해당 핸들러에서 처리 + * JWT 생성(Access, Refresh) + * Refresh Token DB 저장 + * 콜백 URL로 리다이렉트 + 만든 토큰은 쿠키로 전달 + */ + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final KakaoAuthService kakaoAuthService; + + @Value("${app.frontend-url}") + private String frontedUrl; + + // 30분 + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + // 14일 + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + // Oauth2 로그인 성공 시 해당 메소드를 스프링 스큐리티가 자동 호출 + // 이 시점에서 authenication에 로그인된 사용자 정보가 저장 user-info + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + // memberId, authroies, isNewMember의 결과가 map으로 저장 + OAuth2User principal = (OAuth2User) authentication.getPrincipal(); + + long memberId = ((Number) principal.getAttributes().get("memberId")).longValue(); + boolean isNewMember = (boolean) principal.getAttributes().get("isNewMember"); + + // authorties: ["ROLE_MEMBER"] + List authorties = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + + // JWT 생성 + String accessToken = jwtTokenProvider.createAccessToken(memberId, authorties); + String refreshToken = jwtTokenProvider.createRefreshToken(memberId, authorties); + + kakaoAuthService.saveRefreshToken(memberId, refreshToken); + + // 쿠키로 저장 + addCookie(response, "accessToken", accessToken, accessTokenExpiry); + addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); + + + // 리다이렉트에는 쿼리 파라미터로 isNewMember만 전달 + String redirectUri = request.getParameter("redirect_uri"); + if (redirectUri == null || redirectUri.isBlank()) { + redirectUri = frontedUrl + "/auth/logincheck"; // 배포 후 변경 예정 + } + + String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("isNewMember", isNewMember) + .build() + .toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + + } + + private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 + cookie.setSecure(false); // 배포 서버에서는 true로 변경 + cookie.setPath("/"); // 모든 경로에서 전송 + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java new file mode 100644 index 0000000..c3566f1 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java @@ -0,0 +1,24 @@ +package com.ott.api_user.auth.oauth2.userinfo; + +import lombok.Getter; + +import java.util.Map; + +@Getter +public class KakaoUserInfo { + + private final String providerId; + private final String email; + private final String nickname; + + @SuppressWarnings("unchecked") + public KakaoUserInfo(Map attributes) { + this.providerId = String.valueOf(attributes.get("id")); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + this.nickname = (String) profile.get("nickname"); + this.email = (String) kakaoAccount.get("email"); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java new file mode 100644 index 0000000..a00ac40 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java @@ -0,0 +1,76 @@ +package com.ott.api_user.auth.service; + +import com.ott.api_user.auth.dto.TokenResponse; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * JWT 토큰 관리하는 클래스 -> 재발급과 로그아웃 구현 + * 모든 소셜 로그인 공통으로 사용 -> 현재는 카카오만 사용 + */ +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + + /** + * Access Token 재발급 + */ + public TokenResponse reissue(String refreshToken) { + // refresh 토큰 유효성 검증 + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); + + if(errorCode != null) { + throw new BusinessException(errorCode); + } + + // DB에 저장된 토큰과 일치 여부 확인 + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + Member member = findMemberById(memberId); + + + if(!refreshToken.equals(member.getRefreshToken())) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 권한 추출 + List authorities = jwtTokenProvider.getAuthorities(refreshToken); + + // access + refresh 재발급 + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, authorities); + String newRefreshToken = jwtTokenProvider.createRefreshToken(memberId, authorities); + + // refreshToken 갱신 및 이전 토큰 폐기 + member.clearRefreshToken(); + member.updateRefreshToken(newRefreshToken); + + return new TokenResponse(newAccessToken, newRefreshToken); + + } + + /** + * 로그아웃 - Refresh 토큰 삭제 + */ + public void logout(Long memberId) { + Member member = findMemberById(memberId); + member.clearRefreshToken(); + } + + + // Optipnal 처리를 위해 사용 + private Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java new file mode 100644 index 0000000..1565268 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -0,0 +1,63 @@ +package com.ott.api_user.auth.service; + +import com.ott.api_user.auth.oauth2.userinfo.KakaoUserInfo; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 카카오의 로그인 회원 로직으로 다음과 같은 비즈니스 로직을 수행함 + * 회원 조회/생성 + * 프로필 동기화 + * 신규 회원 판별 + * Refresh Token 저장 + */ +@Service +@RequiredArgsConstructor +@Transactional +public class KakaoAuthService { + + private final MemberRepository memberRepository; + private final PreferredTagRepository preferredTagRepository; + + // 카카오 사용자 정보로 회원 조회 or 신규 생성 + // 기존 회원일 경우 프로필 동기화 필요 + public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { + return memberRepository + .findByProviderAndProviderId(Provider.KAKAO, kakaoUserInfo.getProviderId()) + .map(existingMember -> { + existingMember.updateKakaoProfile( + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname() + ); + return existingMember; + }) + .orElseGet(() -> memberRepository.save( + Member.createKakaoMember( + kakaoUserInfo.getProviderId(), + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname() + ) + )); + } + + // 신규 회원 판별 -> 태그 소유 유무로 판단 + public boolean isNewMember(Long memberId) { + return !preferredTagRepository.existsByMemberId(memberId); + } + + // refresh token 저장 + public void saveRefreshToken(Long memberId, String refreshToken) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + member.updateRefreshToken(refreshToken); + } + + +} From fe8e430eb33e364921d3a3902d756af5457a4cc9 Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 20 Feb 2026 21:55:33 +0900 Subject: [PATCH 033/257] =?UTF-8?q?[FEAT]:=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java | 5 +++++ .../com/ott/api_user/auth/service/SocialAuthService.java | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java new file mode 100644 index 0000000..e9919c0 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java @@ -0,0 +1,5 @@ +package com.ott.api_user.auth.oauth2.userinfo; + +// 다중 소셜 로그인 도입 시 전략 패턴으로 구성 예정 +public interface UserInfo { +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java new file mode 100644 index 0000000..517d1b9 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java @@ -0,0 +1,5 @@ +package com.ott.api_user.auth.service; + +// 추후 다중 소셜 로그인 사용 시 전략 패턴으로 변경 +public interface SocialAuthService { +} From 59e5ab5c514dcb70a59c23fa66affb48067c5e07 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 21 Feb 2026 08:10:24 +0900 Subject: [PATCH 034/257] =?UTF-8?q?[FEAT]:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20JWT=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthController.java | 114 ++++++++++++++++++ .../auth/dto/request/AdminLoginRequest.java | 22 ++++ .../auth/dto/response/AdminLoginResponse.java | 24 ++++ .../auth/dto/response/AdminTokenResponse.java | 14 +++ .../auth/service/AdminAuthService.java | 105 ++++++++++++++++ .../src/main/resources/application.yml | 18 ++- .../src/main/resources/application.yml | 33 ++++- .../member/repository/MemberRepository.java | 1 + 8 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java new file mode 100644 index 0000000..20158d4 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -0,0 +1,114 @@ +package com.ott.api_admin.auth.controller; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.api_admin.auth.dto.response.AdminTokenResponse; +import com.ott.api_admin.auth.service.AdminAuthService; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.SuccessResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +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; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminAuthController implements AdminAuthApi { + + private final AdminAuthService adminAuthService; + + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + @Override + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response) { + + AdminLoginResponse loginResponse = adminAuthService.login(request); + + // 둘 다 쿠키로 + addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); + addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); + + // Body에는 memberId, role만 (토큰은 @JsonIgnore) + return SuccessResponse.of(loginResponse).asHttp(HttpStatus.OK); + } + + @Override + @PostMapping("/reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response) { + + String refreshToken = extractCookie(request, "refreshToken"); + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + AdminTokenResponse tokenResponse = adminAuthService.reissue(refreshToken); + + addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + + return ResponseEntity.noContent().build(); + } + + @Override + @PostMapping("/logout") + public ResponseEntity logout( + Authentication authentication, + HttpServletResponse response) { + + Long memberId = (Long) authentication.getPrincipal(); + adminAuthService.logout(memberId); + + deleteCookie(response, "accessToken"); + deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + private String extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true + cookie.setPath("/"); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + private void deleteCookie(HttpServletResponse response, String name) { + Cookie cookie = new Cookie(name, null); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java new file mode 100644 index 0000000..b840b35 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "관리자 로그인 요청") +public class AdminLoginRequest { + + @Email + @NotBlank + @Schema(description = "관리자 이메일", example = "admin@ott.com") + private String email; + + @NotBlank + @Schema(description = "비밀번호", example = "password123") + private String password; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java new file mode 100644 index 0000000..e0b6d70 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "관리자 로그인 응답") +public class AdminLoginResponse { + + @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 + private String accessToken; + + @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 + private String refreshToken; + + @Schema(description = "회원 ID", example = "1") + private Long memberId; + + @Schema(description = "회원 역할", example = "ADMIN") + private String role; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java new file mode 100644 index 0000000..4f20127 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * reissue 시 Service → Controller 토큰 전달용 + */ +@Getter +@AllArgsConstructor +public class AdminTokenResponse { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java new file mode 100644 index 0000000..028d96a --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java @@ -0,0 +1,105 @@ +package com.ott.api_admin.auth.service; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.api_admin.auth.dto.response.AdminTokenResponse; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.domain.Role; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminAuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + /** + * 관리자 로그인 + * 토큰은 Controller에서 쿠키로 세팅 + */ + public AdminLoginResponse login(AdminLoginRequest request) { + // 1. 이메일 + LOCAL provider로 회원 조회 + Member member = memberRepository.findByEmailAndProvider(request.getEmail(), Provider.LOCAL) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 2. 비밀번호 검증 +// String encodedPassword = member.getPassword(); +// if (encodedPassword == null || !passwordEncoder.matches(request.getPassword(), encodedPassword)) { +// throw new BusinessException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); +// } + + + // 3. 권한 확인 (ADMIN, EDITOR만 허용) + if (member.getRole() != Role.ADMIN && member.getRole() != Role.EDITOR) { + throw new BusinessException(ErrorCode.FORBIDDEN, "관리자 권한이 없습니다."); + } + + // 4. JWT 생성 + List authorities = List.of(member.getRole().getKey()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), authorities); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), authorities); + + // 5. refresh token DB 저장 + member.updateRefreshToken(refreshToken); + + return AdminLoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .role(member.getRole().name()) + .build(); + } + + /** + * Access 발급 시 + Refresh Token 재발급 + */ + public AdminTokenResponse reissue(String refreshToken) { + // 1. refresh token 검증 + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); + if (errorCode != null) { + throw new BusinessException(errorCode); + } + + // 2. DB 토큰 일치 확인 + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!refreshToken.equals(member.getRefreshToken())) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 3. 새 토큰 발급 + List authorities = jwtTokenProvider.getAuthorities(refreshToken); + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, authorities); + String newRefreshToken = jwtTokenProvider.createRefreshToken(memberId, authorities); + + // 4. refresh token 갱신 + member.clearRefreshToken(); + member.updateRefreshToken(newRefreshToken); + + return new AdminTokenResponse(newAccessToken, newRefreshToken); + } + + /** + * 로그아웃 — DB refresh token 삭제 + */ + public void logout(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + member.clearRefreshToken(); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/resources/application.yml b/apps/api-admin/src/main/resources/application.yml index 8fb8686..9953441 100644 --- a/apps/api-admin/src/main/resources/application.yml +++ b/apps/api-admin/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} @@ -25,3 +25,19 @@ spring: hibernate: show_sql: true format_sql: true + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + probes: + enabled: true + +# JWT 설정 +jwt: + secret: ${JWT_SECRET_BASE64} + access-token-expiry: 3200000 # 60분 -> 관리자의 경우 1시간으로 증가 + refresh-token-expiry: 1209600000 # 14일 diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index dd946ca..0190d09 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -1,13 +1,36 @@ +app: + frontend-url: ${FRONTEND_URL:http://localhost:8080} + server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} + # kakao + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} # REST API + client-secret: ${KAKAO_CLIENT_SECRET} #Client Secret + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-authentication-method: client_secret_post # body에 넣어서 토큰 교환 + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize # 로그인 화면 + token-uri: https://kauth.kakao.com/oauth/token # 받은 인가코드를 토큰으로 교환 + user-info-uri: https://kapi.kakao.com/v2/user/me # 유저 정보 가져오는 API + user-name-attribute: id # flyway 설정 flyway: enabled: true @@ -34,4 +57,10 @@ management: endpoint: health: probes: - enabled: true \ No newline at end of file + enabled: true + +# JWT 설정 +jwt: + secret: ${JWT_SECRET_BASE64} + access-token-expiry: 1800000 # 30분 + refresh-token-expiry: 1209600000 # 14일 diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java index fac831e..a583019 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -11,5 +11,6 @@ public interface MemberRepository extends JpaRepository { // 기존 회원인지 신규 회원인지 DB 조회 Optional findByProviderAndProviderId(Provider provider, String providerId); + // 관리자&에디터용 조회 Optional findByEmailAndProvider(String email, Provider provider); } From 198112cbe806260c02c151ec799944b233508bf1 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 21 Feb 2026 08:10:45 +0900 Subject: [PATCH 035/257] =?UTF-8?q?[FEAT]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthApi.java | 72 +++++++++++++++++++ .../ott/api_user/auth/controller/AuthApi.java | 45 ++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java new file mode 100644 index 0000000..686b105 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java @@ -0,0 +1,72 @@ +package com.ott.api_admin.auth.controller; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Admin Auth API", description = "관리자 인증/인가 API") +public interface AdminAuthApi { + + @Operation( + summary = "관리자 로그인", + description = "이메일/비밀번호로 로그인합니다. " + + "Access Token과 Refresh Token은 HttpOnly 쿠키로 세팅됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse( + responseCode = "401", + description = "이메일 또는 비밀번호 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "관리자 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response); + + @Operation( + summary = "토큰 재발급", + description = "refreshToken 쿠키를 사용해 Access Token과 Refresh Token을 재발급합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "재발급 성공"), + @ApiResponse( + responseCode = "401", + description = "refreshToken이 없거나 만료/유효하지 않음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + + @Operation( + summary = "로그아웃", + description = "DB의 refreshToken을 삭제하고 accessToken/refreshToken 쿠키를 제거합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "로그아웃 성공"), + @ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java new file mode 100644 index 0000000..7aef11d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java @@ -0,0 +1,45 @@ +package com.ott.api_user.auth.controller; + +import com.ott.common.web.exception.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + + +@Tag(name = "Auth API", description = "인증/인가 API") +public interface AuthApi { + + @Operation(summary = "Access Token 재발급", description = "access token + refresh token 재발급.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse( + responseCode = "401", + description = "refreshToken이 없거나 만료/유효하지 않음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + + + @Operation( + summary = "로그아웃", description = "DB refreshToken을 삭제, accessToken/refreshToken 쿠키 제거" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); +} \ No newline at end of file From 313dd03dd7a9a53225f331cfc061e510f9a9a44b Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 21 Feb 2026 08:13:42 +0900 Subject: [PATCH 036/257] =?UTF-8?q?[FEAT]:=EB=8F=84=EC=BB=A4=20JWT=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index e349c88..eec9594 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,10 @@ services: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} + KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + FRONTEND_URL: ${FRONTEND_URL} depends_on: mysql: condition: service_healthy @@ -56,6 +60,7 @@ services: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} depends_on: mysql: condition: service_healthy From 120cdd5aa303e50f6cff2d83c20044b37ee33c45 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 21 Feb 2026 08:17:36 +0900 Subject: [PATCH 037/257] =?UTF-8?q?[DELETE]:=20gitKeep=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep | 0 .../src/main/java/com/ott/api_user/auth/controller/.gitkeep | 0 apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 From 8a5f850e666c03b55adaa65dc2950c8d67369d33 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 21 Feb 2026 08:18:09 +0900 Subject: [PATCH 038/257] =?UTF-8?q?[FEAT]:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20Security=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_admin/config/SecurityConfig.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java new file mode 100644 index 0000000..c268785 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.ott.api_admin.config; + +import com.ott.common.security.filter.JwtAuthenticationFilter; +import com.ott.common.security.handler.JwtAccessDeniedHandler; +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> e + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/actuator/health/**", + "/actuator/info", + "/admin/login", + "/admin/reissue", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() + .anyRequest().hasAnyRole("ADMIN", "EDITOR") + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file From 9966170b5acdaa12fda0fac549c9a643ad8232f9 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 21 Feb 2026 08:54:02 +0900 Subject: [PATCH 039/257] =?UTF-8?q?[FEAT]:=20Security=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/config/SecurityConfig.java | 98 ++++++++++++++++++- .../ott/common/web/config/WebMvcConfig.java | 4 +- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java index a23941f..8bc5552 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -1,23 +1,111 @@ package com.ott.api_user.config; +import com.ott.api_user.auth.oauth2.CustomOAuth2UserService; +import com.ott.api_user.auth.oauth2.handler.OAuth2FailureHandler; +import com.ott.api_user.auth.oauth2.handler.OAuth2SuccessHandler; +import com.ott.common.security.filter.JwtAuthenticationFilter; +import com.ott.common.security.handler.JwtAccessDeniedHandler; +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration +@RequiredArgsConstructor +@EnableMethodSecurity public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + // api-user 전용 OAuth2 + private final CustomOAuth2UserService CustomOAuth2UserService; // 카카오에서 받은 사용자 프로필 조회 후 DB에 적재 + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + + @Value("${app.frontend-url:http://localhost:8080}") + private String frontedUrl; + @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) // csrf 비활성화, Authorization 헤더로 보냄 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + +// .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .cors(AbstractHttpConfigurer::disable) + + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증 + + .exceptionHandling(e -> e + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + .authorizeHttpRequests(auth -> auth - .requestMatchers("/actuator/health/**", "/actuator/info").permitAll() - .anyRequest().permitAll() + // 인증 불필요 + .requestMatchers( + "/actuator/health/**", + "/actuator/info", + "/auth/**", + "/oauth2/**", + "/login/oauth2/**", + "/auth/reissue", + "/auth/logout", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() + + // 나머지 url에 대해서는 인증 필요 + .anyRequest().authenticated() + ) + + // OAuth2 카카오 로그인 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + userInfo.userService(CustomOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) ) - .httpBasic(Customizer.withDefaults()) + + // UsernamePasswordAuthenticationFilter 보다 먼저 실행 + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) .build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // allowedOrigins -> 허용할 Origin 내역 + // allowCredentials -> 브라우저가 요청에 인증정보를 포함하는 것을 허용하겠냐 + // credentials가 true일 경우 Allow-origin의 경우 구체적인 경로를 명시해야됨 + + config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); +// config.setAllowedOrigins(List.of(frontedUrl)); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + } diff --git a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java index dc50428..f742032 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java +++ b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java @@ -10,14 +10,12 @@ public class WebMvcConfig implements WebMvcConfigurer { private final long MAX_AGE_SECS = 3600; - // @Value("${app.cors.allowed-origins}") - // private String[] allowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // .allowedOrigins(allowedOrigins) - .allowedOriginPatterns("*") + .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) From 76e8dbe7bc77614aefeee73d37174b4b863d4184 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Sat, 21 Feb 2026 14:00:23 +0900 Subject: [PATCH 040/257] =?UTF-8?q?[DEPLOY]:=20develop->main=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EC=88=98=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 146 ++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 .github/workflows/deploy-ec2-docker.yml diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml new file mode 100644 index 0000000..eabdca1 --- /dev/null +++ b/.github/workflows/deploy-ec2-docker.yml @@ -0,0 +1,146 @@ +name: Deploy Docker Apps To EC2 + +on: + workflow_dispatch: + inputs: + image_tag: + description: "Docker image tag to deploy (default: commit SHA)" + required: false + type: string + pull_request: + types: + - closed + +env: + AWS_REGION: ap-northeast-2 + +jobs: + build-and-push: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: api-user + ecr_repo: oplust-api-user + - service: api-admin + ecr_repo: oplust-api-admin + - service: transcoder + ecr_repo: oplust-transcoder + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Ensure ECR repository exists + run: | + aws ecr describe-repositories --repository-names "${{ matrix.ecr_repo }}" >/dev/null 2>&1 || \ + aws ecr create-repository --repository-name "${{ matrix.ecr_repo }}" >/dev/null + + - name: Build and push image + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + run: | + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + IMAGE_URI="${ECR_REGISTRY}/${{ matrix.ecr_repo }}:${IMAGE_TAG}" + IMAGE_URI_LATEST="${ECR_REGISTRY}/${{ matrix.ecr_repo }}:latest" + + docker build \ + -f "apps/${{ matrix.service }}/Dockerfile" \ + -t "${IMAGE_URI}" \ + -t "${IMAGE_URI_LATEST}" \ + . + + docker push "${IMAGE_URI}" + docker push "${IMAGE_URI_LATEST}" + + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to EC2 instances via SSM + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + PROJECT_NAME: oplust + DB_NAME: oplust + RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + API_USER_ENV: ${{ secrets.API_USER_ENV }} + API_ADMIN_ENV: ${{ secrets.API_ADMIN_ENV }} + TRANSCODER_ENV: ${{ secrets.TRANSCODER_ENV }} + run: | + set -euo pipefail + + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + + if [ -z "${RDS_ENDPOINT}" ] || [ -z "${DB_USERNAME}" ] || [ -z "${DB_PASSWORD}" ]; then + echo "RDS_ENDPOINT, DB_USERNAME, DB_PASSWORD secrets are required" >&2 + exit 1 + fi + + deploy_service() { + local target_tag="$1" + local image_uri="$2" + local container_name="$3" + local env_file="$4" + local port="$5" + local env_payload="$6" + + local full_env_payload + full_env_payload=$(printf 'SPRING_DATASOURCE_URL=jdbc:mysql://%s:3306/%s\nSPRING_DATASOURCE_USERNAME=%s\nSPRING_DATASOURCE_PASSWORD=%s\n%s' "${RDS_ENDPOINT}" "${DB_NAME}" "${DB_USERNAME}" "${DB_PASSWORD}" "${env_payload}") + + local env_payload_b64 + env_payload_b64="$(printf '%s' "$full_env_payload" | base64 -w0)" + + if [ -n "$port" ]; then + run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped -p ${port}:${port} --env-file ${env_file} ${image_uri}" + else + run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped --env-file ${env_file} ${image_uri}" + fi + + aws ssm send-command \ + --targets "Key=tag:Name,Values=${target_tag}" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy ${container_name}:${IMAGE_TAG}" \ + --parameters commands="[ + \"set -e\", + \"sudo mkdir -p /etc/oplust\", + \"echo '${env_payload_b64}' | base64 -d | sudo tee ${env_file} >/dev/null\", + \"sudo chmod 600 ${env_file}\", + \"aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY\", + \"sudo docker pull ${image_uri}\", + \"sudo docker rm -f ${container_name} || true\", + \"${run_cmd}\" + ]" \ + --max-concurrency "1" \ + --max-errors "1" \ + --query 'Command.CommandId' \ + --output text >/dev/null + } + + deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "${API_USER_ENV}" + deploy_service "${PROJECT_NAME}-admin-ec2" "${ECR_REGISTRY}/oplust-api-admin:${IMAGE_TAG}" "oplust-api-admin" "/etc/oplust/api-admin.env" "8081" "${API_ADMIN_ENV}" + deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "${TRANSCODER_ENV}" From ab52eb59a5356047967555b51f296ccd3a34b486 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Sat, 21 Feb 2026 14:13:38 +0900 Subject: [PATCH 041/257] =?UTF-8?q?[DEPLOY]:=20SSM=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=8F=99=EA=B8=B0=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=A4=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 58 ++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml index eabdca1..4a7193f 100644 --- a/.github/workflows/deploy-ec2-docker.yml +++ b/.github/workflows/deploy-ec2-docker.yml @@ -109,20 +109,35 @@ jobs: local port="$5" local env_payload="$6" + local instance_id + instance_id=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${target_tag}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$instance_id" ] || [ "$instance_id" = "None" ]; then + echo "No running instance found for tag: ${target_tag}" >&2 + exit 1 + fi + local full_env_payload full_env_payload=$(printf 'SPRING_DATASOURCE_URL=jdbc:mysql://%s:3306/%s\nSPRING_DATASOURCE_USERNAME=%s\nSPRING_DATASOURCE_PASSWORD=%s\n%s' "${RDS_ENDPOINT}" "${DB_NAME}" "${DB_USERNAME}" "${DB_PASSWORD}" "${env_payload}") local env_payload_b64 env_payload_b64="$(printf '%s' "$full_env_payload" | base64 -w0)" + local run_cmd if [ -n "$port" ]; then run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped -p ${port}:${port} --env-file ${env_file} ${image_uri}" else run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped --env-file ${env_file} ${image_uri}" fi - aws ssm send-command \ - --targets "Key=tag:Name,Values=${target_tag}" \ + local cmd_id + cmd_id=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$instance_id" \ --document-name "AWS-RunShellScript" \ --comment "Deploy ${container_name}:${IMAGE_TAG}" \ --parameters commands="[ @@ -135,10 +150,43 @@ jobs: \"sudo docker rm -f ${container_name} || true\", \"${run_cmd}\" ]" \ - --max-concurrency "1" \ - --max-errors "1" \ --query 'Command.CommandId' \ - --output text >/dev/null + --output text) + + echo "[$container_name] command id: $cmd_id (instance: $instance_id)" + + local status + for _ in $(seq 1 120); do + status=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$cmd_id" \ + --instance-id "$instance_id" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$status" in + Success) + echo "[$container_name] deployment success" + return 0 + ;; + Failed|Cancelled|TimedOut) + echo "[$container_name] deployment failed with status: $status" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$cmd_id" --instance-id "$instance_id" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + echo "[$container_name] unexpected status: $status" >&2 + sleep 5 + ;; + esac + done + + echo "[$container_name] deployment timed out waiting for SSM command completion" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$cmd_id" --instance-id "$instance_id" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 } deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "${API_USER_ENV}" From 71a0b36070510864e80df167d2498911049a162f Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Sat, 21 Feb 2026 14:15:56 +0900 Subject: [PATCH 042/257] =?UTF-8?q?[CHORE]:=20deploy=20workflow=20BOM=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml index 4a7193f..c6a5a0f 100644 --- a/.github/workflows/deploy-ec2-docker.yml +++ b/.github/workflows/deploy-ec2-docker.yml @@ -1,4 +1,4 @@ -name: Deploy Docker Apps To EC2 +name: Deploy Docker Apps To EC2 on: workflow_dispatch: From e88332a810a5dcdb9a2ba0be0860d093a8fa2b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 14:26:06 +0900 Subject: [PATCH 043/257] =?UTF-8?q?[FEAT]:=20Media=20Repository=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/domain/media/repository/MediaRepository.java | 7 +++++++ .../domain/media/repository/MediaRepositoryCustom.java | 4 ++++ .../domain/media/repository/MediaRepositoryImpl.java | 10 ++++++++++ 3 files changed, 21 insertions(+) create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java new file mode 100644 index 0000000..97ffc04 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.media.domain.Media; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MediaRepository extends JpaRepository, MediaRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java new file mode 100644 index 0000000..78e32b5 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -0,0 +1,4 @@ +package com.ott.domain.media.repository; + +public interface MediaRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java new file mode 100644 index 0000000..b82f81e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -0,0 +1,10 @@ +package com.ott.domain.media.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class MediaRepositoryImpl implements MediaRepositoryCustom { + + private final JPAQueryFactory queryFactory; +} From 182dd1d869231835765ef4f490680e13893c80e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 14:42:02 +0900 Subject: [PATCH 044/257] =?UTF-8?q?[FEAT]:=20MediaTagRepository=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media_tag/repository/MediaTagRepository.java | 7 +++++++ .../media_tag/repository/MediaTagRepositoryCustom.java | 4 ++++ .../media_tag/repository/MediaTagRepositoryImpl.java | 10 ++++++++++ 3 files changed, 21 insertions(+) create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java new file mode 100644 index 0000000..9fd6e01 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java new file mode 100644 index 0000000..b091ef2 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java @@ -0,0 +1,4 @@ +package com.ott.domain.media_tag.repository; + +public interface MediaTagRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java new file mode 100644 index 0000000..ca00983 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java @@ -0,0 +1,10 @@ +package com.ott.domain.media_tag.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class MediaTagRepositoryImpl implements MediaTagRepositoryCustom { + + private final JPAQueryFactory queryFactory; +} From 2cb461faf7d5e72d95cc916f71f4e889f5f529e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 15:23:41 +0900 Subject: [PATCH 045/257] =?UTF-8?q?[REFACTOR]:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B3=80=EA=B2=BD=EC=95=88=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../series/mapper/BackOfficeSeriesMapper.java | 83 ++++++----- .../service/BackOfficeSeriesService.java | 140 +++++++++--------- .../repository/MediaRepositoryCustom.java | 7 + .../media/repository/MediaRepositoryImpl.java | 42 ++++++ .../repository/MediaTagRepositoryCustom.java | 6 + .../repository/MediaTagRepositoryImpl.java | 17 +++ 6 files changed, 184 insertions(+), 111 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index d1a4b90..8815ffa 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -1,30 +1,31 @@ -//package com.ott.api_admin.series.mapper; -// -//import com.ott.api_admin.series.dto.response.SeriesDetailResponse; -//import com.ott.api_admin.series.dto.response.SeriesListResponse; -//import com.ott.domain.series.domain.Series; -//import com.ott.domain.series_tag.domain.SeriesTag; -//import org.springframework.stereotype.Component; -// -//import java.util.List; -// -//@Component -//public class BackOfficeSeriesMapper { -// -// public SeriesListResponse toSeriesListResponse(Series series, List seriesTagList) { -// String categoryName = extractCategoryName(seriesTagList); -// List tagNameList = extractTagNameList(seriesTagList); -// -// return new SeriesListResponse( -// series.getId(), -// series.getThumbnailUrl(), -// series.getTitle(), -// categoryName, -// tagNameList, -// series.getPublicStatus() -// ); -// } -// +package com.ott.api_admin.series.mapper; + +import com.ott.api_admin.series.dto.response.SeriesDetailResponse; +import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.series.domain.Series; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class BackOfficeSeriesMapper { + + public SeriesListResponse toSeriesListResponse(Media media, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); + + return new SeriesListResponse( + media.getId(), + media.getThumbnailUrl(), + media.getTitle(), + categoryName, + tagNameList, + media.getPublicStatus() + ); + } + // public SeriesDetailResponse toSeriesDetailResponse(Series series, List seriesTagList) { // String categoryName = extractCategoryName(seriesTagList); // List tagNameList = extractTagNameList(seriesTagList); @@ -43,17 +44,17 @@ // series.getThumbnailUrl() // ); // } -// -// private String extractCategoryName(List seriesTagList) { -// return seriesTagList.stream() -// .findFirst() -// .map(st -> st.getTag().getCategory().getName()) -// .orElse(null); -// } -// -// private List extractTagNameList(List seriesTagList) { -// return seriesTagList.stream() -// .map(st -> st.getTag().getName()) -// .toList(); -// } -//} + + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 0615ec6..3a61d7b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,72 +1,72 @@ -//package com.ott.api_admin.series.service; -// -//import com.ott.api_admin.series.dto.response.SeriesDetailResponse; -//import com.ott.api_admin.series.dto.response.SeriesListResponse; -//import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; -//import com.ott.common.web.exception.BusinessException; -//import com.ott.common.web.exception.ErrorCode; -//import com.ott.domain.series.repository.SeriesRepository; -//import com.ott.domain.series_tag.repository.SeriesTagRepository; -//import com.ott.common.web.response.PageInfo; -//import com.ott.common.web.response.PageResponse; -//import com.ott.domain.series.domain.Series; -//import com.ott.domain.series_tag.domain.SeriesTag; -//import lombok.RequiredArgsConstructor; -//import org.springframework.data.domain.Page; -//import org.springframework.data.domain.PageRequest; -//import org.springframework.data.domain.Pageable; -//import org.springframework.data.domain.Sort; -//import org.springframework.stereotype.Service; -//import org.springframework.transaction.annotation.Transactional; -//import org.springframework.util.StringUtils; -// -//import java.util.Collections; -//import java.util.List; -//import java.util.Map; -//import java.util.stream.Collectors; -// -//@RequiredArgsConstructor -//@Service -//public class BackOfficeSeriesService { -// -// private final BackOfficeSeriesMapper backOfficeSeriesMapper; -// -// private final SeriesRepository seriesRepository; -// private final SeriesTagRepository seriesTagRepository; -// -// @Transactional(readOnly = true) -// public PageResponse getSeries(int page, int size, String searchWord) { -// Pageable pageable = PageRequest.of(page, size); -// -// // 1. keyword 유무에 따라 분기 / 시리즈 대상 페이징 -// Page seriesPage = seriesRepository.findSeriesList(pageable, searchWord); -// -// // 2. 조회된 시리즈 ID 목록 추출 -// List seriesIdList = seriesPage.getContent().stream() -// .map(Series::getId) -// .toList(); -// -// // 3. IN절로 태그 일괄 조회 -// Map> tagListBySeriesId = seriesIdList.isEmpty() -// ? Collections.emptyMap() -// : seriesTagRepository.findWithTagAndCategoryBySeriesIds(seriesIdList).stream() -// .collect(Collectors.groupingBy(st -> st.getSeries().getId())); -// -// List responseList = seriesPage.getContent().stream() -// .map(series -> backOfficeSeriesMapper.toSeriesListResponse( -// series, -// tagListBySeriesId.getOrDefault(series.getId(), List.of()) -// )) -// .toList(); -// -// PageInfo pageInfo = PageInfo.toPageInfo( -// seriesPage.getNumber(), -// seriesPage.getTotalPages(), -// seriesPage.getSize() -// ); -// return PageResponse.toPageResponse(pageInfo, responseList); -// } -// +package com.ott.api_admin.series.service; + +import com.ott.api_admin.series.dto.response.SeriesDetailResponse; +import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.MediaType; +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 com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.series.domain.Series; +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.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class BackOfficeSeriesService { + + private final BackOfficeSeriesMapper backOfficeSeriesMapper; + + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + + @Transactional(readOnly = true) + public PageResponse getSeries(int page, int size, String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + // 1. 미디어 중 시리즈 대상 페이징 + Page mediaPage = mediaRepository.findMediaListByMediaType(pageable, MediaType.SERIES, searchWord); + + // 2. 조회된 미디어 ID 목록 추출 + List mediaIdList = mediaPage.getContent().stream() + .map(Media::getId) + .toList(); + + // 3. IN절로 태그 일괄 조회 + Map> tagListByMediaId = mediaIdList.isEmpty() + ? Collections.emptyMap() + : mediaTagRepository.findWithTagAndCategoryByMediaIds(mediaIdList).stream() + .collect(Collectors.groupingBy(mt -> mt.getMedia().getId())); + + List responseList = mediaPage.getContent().stream() + .map(media -> backOfficeSeriesMapper.toSeriesListResponse( + media, + tagListByMediaId.getOrDefault(media.getId(), List.of()) + )) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } + // @Transactional(readOnly = true) // public SeriesDetailResponse getSeriesDetail(Long seriesId) { // Series series = seriesRepository.findById(seriesId) @@ -77,4 +77,4 @@ // // return backOfficeSeriesMapper.toSeriesDetailResponse(series, seriesTagList); // } -//} +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index 78e32b5..7a7af15 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -1,4 +1,11 @@ package com.ott.domain.media.repository; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + public interface MediaRepositoryCustom { + + Page findMediaListByMediaType(Pageable pageable, MediaType mediaType, String searchWord); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index b82f81e..4c2b2f9 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -1,10 +1,52 @@ package com.ott.domain.media.repository; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ott.domain.media.domain.QMedia.media; @RequiredArgsConstructor public class MediaRepositoryImpl implements MediaRepositoryCustom { private final JPAQueryFactory queryFactory; + + @Override + public Page findMediaListByMediaType(Pageable pageable, MediaType mediaType, String searchWord) { + List mediaList = queryFactory + .selectFrom(media) + .where( + media.mediaType.eq(mediaType), + titleContains(searchWord) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + media.mediaType.eq(mediaType), + titleContains(searchWord) + ); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java index b091ef2..22875db 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java @@ -1,4 +1,10 @@ package com.ott.domain.media_tag.repository; +import com.ott.domain.media_tag.domain.MediaTag; + +import java.util.List; + public interface MediaTagRepositoryCustom { + + List findWithTagAndCategoryByMediaIds(List mediaIds); } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java index ca00983..9be5af0 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java @@ -1,10 +1,27 @@ package com.ott.domain.media_tag.repository; +import com.ott.domain.media_tag.domain.MediaTag; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import java.util.List; + +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; +import static com.ott.domain.tag.domain.QTag.tag; +import static com.ott.domain.category.domain.QCategory.category; + @RequiredArgsConstructor public class MediaTagRepositoryImpl implements MediaTagRepositoryCustom { private final JPAQueryFactory queryFactory; + + @Override + public List findWithTagAndCategoryByMediaIds(List mediaIds) { + return queryFactory + .selectFrom(mediaTag) + .join(mediaTag.tag, tag).fetchJoin() + .join(tag.category, category).fetchJoin() + .where(mediaTag.media.id.in(mediaIds)) + .fetch(); + } } From 0d9782779b37d1c24cdbd03d4ccb961290835ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 15:49:32 +0900 Subject: [PATCH 046/257] =?UTF-8?q?[REFACTOR]:=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EB=8A=94=20SeriesRepository=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../series/repository/SeriesRepository.java | 46 ++++++------ .../repository/SeriesRepositoryCustom.java | 6 +- .../repository/SeriesRepositoryImpl.java | 74 ++++++++----------- 3 files changed, 55 insertions(+), 71 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index 1cea6a4..e04b10f 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -1,23 +1,23 @@ -//package com.ott.domain.series.repository; -// -//import java.util.List; -// -//import org.springframework.data.domain.Pageable; -//import org.springframework.data.jpa.repository.JpaRepository; -//import org.springframework.data.jpa.repository.Query; -//import org.springframework.data.repository.query.Param; -// -//import com.ott.domain.common.Status; -//import com.ott.domain.series.domain.Series; -// -//public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { -// -// // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) -// @Query("SELECT s FROM Series s " + -// "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + -// "AND s.status = :status " + -// "ORDER BY s.createdDate DESC") -// List searchLatest(@Param("keyword") String keyword, -// @Param("status") Status status, -// Pageable pageable); -//} +package com.ott.domain.series.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ott.domain.common.Status; +import com.ott.domain.series.domain.Series; + +public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { + + // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) + @Query("SELECT s FROM Series s " + + "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "AND s.status = :status " + + "ORDER BY s.createdDate DESC") + List searchLatest(@Param("keyword") String keyword, + @Param("status") Status status, + Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java index f5fc2b6..aae6a81 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -1,10 +1,10 @@ package com.ott.domain.series.repository; import com.ott.domain.series.domain.Series; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; + +import java.util.Optional; public interface SeriesRepositoryCustom { - Page findSeriesList(Pageable pageable, String keyword); + Optional findWithMediaAndUploaderByMediaId(Long mediaId); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java index 4dc78ec..daf832c 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -1,45 +1,29 @@ -//package com.ott.domain.series.repository; -// -//import com.ott.domain.series.domain.Series; -//import com.querydsl.core.types.dsl.BooleanExpression; -//import com.querydsl.jpa.impl.JPAQuery; -//import com.querydsl.jpa.impl.JPAQueryFactory; -//import lombok.RequiredArgsConstructor; -//import org.springframework.data.domain.Page; -//import org.springframework.data.domain.Pageable; -//import org.springframework.data.support.PageableExecutionUtils; -//import org.springframework.util.StringUtils; -// -//import java.util.List; -// -//import static com.ott.domain.series.domain.QSeries.series; -// -//@RequiredArgsConstructor -//public class SeriesRepositoryImpl implements SeriesRepositoryCustom { -// -// private final JPAQueryFactory queryFactory; -// -// @Override -// public Page findSeriesList(Pageable pageable, String searchWord) { -// List seriesList = queryFactory -// .selectFrom(series) -// .where(titleContains(searchWord)) -// .orderBy(series.createdDate.desc()) -// .offset(pageable.getOffset()) -// .limit(pageable.getPageSize()) -// .fetch(); -// -// JPAQuery countQuery = queryFactory -// .select(series.count()) -// .from(series) -// .where(titleContains(searchWord)); -// -// return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); -// } -// -// private BooleanExpression titleContains(String searchWord) { -// if (StringUtils.hasText(searchWord)) -// return series.title.contains(searchWord); -// return null; -// } -//} +package com.ott.domain.series.repository; + +import com.ott.domain.series.domain.Series; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; + +@RequiredArgsConstructor +public class SeriesRepositoryImpl implements SeriesRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + Series result = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} From cf78bf53a032438844f6259b76f2a1b6ea72eb7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 16:02:15 +0900 Subject: [PATCH 047/257] =?UTF-8?q?[REFACTOR]:=20Media=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackOfficeSeriesController.java | 76 +++++++++---------- .../dto/response/SeriesListResponse.java | 4 +- .../series/mapper/BackOfficeSeriesMapper.java | 36 ++++----- .../service/BackOfficeSeriesService.java | 26 ++++--- .../repository/MediaTagRepositoryCustom.java | 2 + .../repository/MediaTagRepositoryImpl.java | 10 +++ 6 files changed, 86 insertions(+), 68 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index c2ac3a0..30fccf5 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -1,38 +1,38 @@ -//package com.ott.api_admin.series.controller; -// -//import com.ott.api_admin.series.dto.response.SeriesDetailResponse; -//import com.ott.api_admin.series.dto.response.SeriesListResponse; -//import com.ott.api_admin.series.service.BackOfficeSeriesService; -//import com.ott.common.web.response.PageResponse; -//import com.ott.common.web.response.SuccessResponse; -//import lombok.RequiredArgsConstructor; -//import org.springframework.http.ResponseEntity; -//import org.springframework.web.bind.annotation.*; -// -//@RestController -//@RequestMapping("/back-office") -//@RequiredArgsConstructor -//public class BackOfficeSeriesController implements BackOfficeSeriesApi { -// -// private final BackOfficeSeriesService backOfficeSeriesService; -// -// @Override -// @GetMapping("/admin/series") -// public ResponseEntity>> getSeries( -// @RequestParam(value = "page", defaultValue = "0") Integer page, -// @RequestParam(value = "size", defaultValue = "10") Integer size, -// @RequestParam(value = "searchWord", required = false) String searchWord -// ) { -// return ResponseEntity.ok( -// SuccessResponse.of(backOfficeSeriesService.getSeries(page, size, searchWord)) -// ); -// } -// -// @Override -// @GetMapping("/admin/series/{seriesId}") -// public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { -// return ResponseEntity.ok( -// SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) -// ); -// } -//} +package com.ott.api_admin.series.controller; + +import com.ott.api_admin.series.dto.response.SeriesDetailResponse; +import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.service.BackOfficeSeriesService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/back-office") +@RequiredArgsConstructor +public class BackOfficeSeriesController implements BackOfficeSeriesApi { + + private final BackOfficeSeriesService backOfficeSeriesService; + + @Override + @GetMapping("/admin/series") + public ResponseEntity>> getSeries( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeSeriesService.getSeries(page, size, searchWord)) + ); + } + + @Override + @GetMapping("/admin/series/{seriesId}") + public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java index 4b5b74a..444547a 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java @@ -8,8 +8,8 @@ @Schema(description = "시리즈 목록 조회 응답") public record SeriesListResponse( - @Schema(type = "Long", description = "시리즈 ID", example = "1") - Long seriesId, + @Schema(type = "Long", description = "미디어 ID (시리즈에서 참조)", example = "1") + Long mediaId, @Schema(type = "String", description = "썸네일 URL", example = "https://cdn.example.com/thumbnail.jpg") String thumbnailUrl, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 8815ffa..7580f09 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -26,24 +26,24 @@ public SeriesListResponse toSeriesListResponse(Media media, List media ); } -// public SeriesDetailResponse toSeriesDetailResponse(Series series, List seriesTagList) { -// String categoryName = extractCategoryName(seriesTagList); -// List tagNameList = extractTagNameList(seriesTagList); -// -// return new SeriesDetailResponse( -// series.getId(), -// series.getTitle(), -// series.getDescription(), -// categoryName, -// tagNameList, -// series.getPublicStatus(), -// series.getUploader().getNickname(), -// series.getBookmarkCount(), -// series.getActors(), -// series.getPosterUrl(), -// series.getThumbnailUrl() -// ); -// } + public SeriesDetailResponse toSeriesDetailResponse(Series series, Media media, String uploaderName, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); + + return new SeriesDetailResponse( + series.getId(), + media.getTitle(), + media.getDescription(), + categoryName, + tagNameList, + media.getPublicStatus(), + uploaderName, + media.getBookmarkCount(), + series.getActors(), + media.getPosterUrl(), + media.getThumbnailUrl() + ); + } private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 3a61d7b..5d11f8e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -13,6 +13,7 @@ import com.ott.common.web.response.PageInfo; import com.ott.common.web.response.PageResponse; import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -33,6 +34,7 @@ public class BackOfficeSeriesService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; + private final SeriesRepository seriesRepository; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { @@ -67,14 +69,18 @@ public PageResponse getSeries(int page, int size, String sea return PageResponse.toPageResponse(pageInfo, responseList); } -// @Transactional(readOnly = true) -// public SeriesDetailResponse getSeriesDetail(Long seriesId) { -// Series series = seriesRepository.findById(seriesId) -// .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); -// -// List seriesTagList = seriesTagRepository -// .findWithTagAndCategoryBySeriesIds(List.of(seriesId)); -// -// return backOfficeSeriesMapper.toSeriesDetailResponse(series, seriesTagList); -// } + @Transactional(readOnly = true) + public SeriesDetailResponse getSeriesDetail(Long mediaId) { + // 1. Series + Media + Uploader 한 번에 조회 + Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Media media = series.getMedia(); + String uploaderNickname = media.getUploader().getNickname(); + + // 2. 태그 조회 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); + + return backOfficeSeriesMapper.toSeriesDetailResponse(series, media, uploaderNickname, mediaTagList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java index 22875db..945d74b 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java @@ -7,4 +7,6 @@ public interface MediaTagRepositoryCustom { List findWithTagAndCategoryByMediaIds(List mediaIds); + + List findWithTagAndCategoryByMediaId(Long mediaId); } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java index 9be5af0..d3522e3 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java @@ -24,4 +24,14 @@ public List findWithTagAndCategoryByMediaIds(List mediaIds) { .where(mediaTag.media.id.in(mediaIds)) .fetch(); } + + @Override + public List findWithTagAndCategoryByMediaId(Long mediaId) { + return queryFactory + .selectFrom(mediaTag) + .join(mediaTag.tag, tag).fetchJoin() + .join(tag.category, category).fetchJoin() + .where(mediaTag.media.id.eq(mediaId)) + .fetch(); + } } From e948019d6ba52e51a7155f8f83dd1b165f2f66d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 16:23:44 +0900 Subject: [PATCH 048/257] =?UTF-8?q?[REFACTOR]:=20seriesId=20->=20mediaId?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../series/controller/BackOfficeSeriesApi.java | 2 +- .../controller/BackOfficeSeriesController.java | 6 +++--- .../series/repository/SeriesRepository.java | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 1c4b0e5..c535e77 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -61,6 +61,6 @@ ResponseEntity>> getSeries( ) }) ResponseEntity> getSeriesDetail( - @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable Long seriesId + @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable Long mediaId ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 30fccf5..528985d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -29,10 +29,10 @@ public ResponseEntity>> getSeri } @Override - @GetMapping("/admin/series/{seriesId}") - public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { + @GetMapping("/admin/series/{mediaId}") + public ResponseEntity> getSeriesDetail(@PathVariable("mediaId") Long mediaId) { return ResponseEntity.ok( - SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) + SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(mediaId)) ); } } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index e04b10f..cb671b4 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -12,12 +12,12 @@ public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { - // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) - @Query("SELECT s FROM Series s " + - "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND s.status = :status " + - "ORDER BY s.createdDate DESC") - List searchLatest(@Param("keyword") String keyword, - @Param("status") Status status, - Pageable pageable); +// // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) +// @Query("SELECT s FROM Series s " + +// "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + +// "AND s.status = :status " + +// "ORDER BY s.createdDate DESC") +// List searchLatest(@Param("keyword") String keyword, +// @Param("status") Status status, +// Pageable pageable); } From 2d6149b4bc41a78f7b847cf1eb4b6d7a7da38534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 18:43:59 +0900 Subject: [PATCH 049/257] =?UTF-8?q?[REFACTOR]:=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_admin/series/service/BackOfficeSeriesService.java | 2 +- .../com/ott/domain/media/repository/MediaRepositoryCustom.java | 2 +- .../com/ott/domain/media/repository/MediaRepositoryImpl.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 5d11f8e..05f7770 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -41,7 +41,7 @@ public PageResponse getSeries(int page, int size, String sea Pageable pageable = PageRequest.of(page, size); // 1. 미디어 중 시리즈 대상 페이징 - Page mediaPage = mediaRepository.findMediaListByMediaType(pageable, MediaType.SERIES, searchWord); + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWord(pageable, MediaType.SERIES, searchWord); // 2. 조회된 미디어 ID 목록 추출 List mediaIdList = mediaPage.getContent().stream() diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index 7a7af15..a0f886e 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -7,5 +7,5 @@ public interface MediaRepositoryCustom { - Page findMediaListByMediaType(Pageable pageable, MediaType mediaType, String searchWord); + Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 4c2b2f9..425e45c 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -21,7 +21,7 @@ public class MediaRepositoryImpl implements MediaRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page findMediaListByMediaType(Pageable pageable, MediaType mediaType, String searchWord) { + public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord) { List mediaList = queryFactory .selectFrom(media) .where( From ac67586866ef9c4c29019bb8497efd9a430f319a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 19:14:29 +0900 Subject: [PATCH 050/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 47 +++++++++++++++++ .../BackOfficeContentsController.java | 34 ++++++++++++ .../dto/response/ContentsListResponse.java | 27 ++++++++++ .../mapper/BackOfficeContentsMapper.java | 22 ++++++++ .../service/BackOfficeContentsService.java | 52 +++++++++++++++++++ .../repository/MediaRepositoryCustom.java | 2 + .../media/repository/MediaRepositoryImpl.java | 39 ++++++++++++++ 7 files changed, 223 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java new file mode 100644 index 0000000..ad7c630 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -0,0 +1,47 @@ +package com.ott.api_admin.content.controller; + +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.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>> 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 + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java new file mode 100644 index 0000000..1e20c5f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -0,0 +1,34 @@ +package com.ott.api_admin.content.controller; + +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/back-office") +@RequiredArgsConstructor +public class BackOfficeContentsController implements BackOfficeContentsApi { + + private final BackOfficeContentsService backOfficeContentsService; + + @Override + @GetMapping("/admin/contents") + public ResponseEntity>> 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)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java new file mode 100644 index 0000000..4a5addb --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java new file mode 100644 index 0000000..2838e5e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.content.mapper; + +import com.ott.api_admin.content.dto.response.ContentsListResponse; +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() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java new file mode 100644 index 0000000..5e7a8e0 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -0,0 +1,52 @@ +package com.ott.api_admin.content.service; + +import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; +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.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 com.ott.domain.member.domain.Role; +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.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class BackOfficeContentsService { + + private final BackOfficeContentsMapper backOfficeContentsMapper; + + private final MediaRepository mediaRepository; + + @Transactional(readOnly = true) + public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { + Pageable pageable = PageRequest.of(page, size); + + // 미디어 중 콘텐츠 대상 페이징 + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatus(pageable, MediaType.CONTENTS, searchWord, publicStatus); + + List responseList = mediaPage.getContent().stream() + .map(backOfficeContentsMapper::toContentsListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index a0f886e..395e008 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -1,6 +1,7 @@ package com.ott.domain.media.repository; import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; import com.ott.domain.media.domain.Media; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -8,4 +9,5 @@ public interface MediaRepositoryCustom { Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); + Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 425e45c..5ae5b98 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -1,6 +1,7 @@ package com.ott.domain.media.repository; import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; import com.ott.domain.media.domain.Media; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; @@ -44,9 +45,47 @@ public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, Medi return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); } + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus) + ); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) return media.title.contains(searchWord); return null; } + + private BooleanExpression mediaTypeEq(MediaType mediaType) { + if (mediaType != null) + return media.mediaType.eq(mediaType); + return null; + } + + private BooleanExpression publicStatusEq(PublicStatus publicStatus) { + if (publicStatus != null) + return media.publicStatus.eq(publicStatus); + return null; + } } From b2587f88ccfa348cb94b7a6c195cf17f9fb7763a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 21:50:35 +0900 Subject: [PATCH 051/257] =?UTF-8?q?[CHORE]:=20.gitkeep=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_admin/content/controller/.gitkeep | 0 .../src/main/java/com/ott/api_admin/content/dto/.gitkeep | 0 .../src/main/java/com/ott/api_admin/content/service/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep deleted file mode 100644 index e69de29..0000000 From b94915c4e78d4f32246f594b74d62e59006ce1cb Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:58:57 +0900 Subject: [PATCH 052/257] =?UTF-8?q?[FEAT]:=20=EC=8B=9C=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=20=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?(=EA=B8=B0=EC=A1=B4=20v1=20=EA=B8=B0=EB=B0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/search/controller/SearchApi.java | 5 +- .../api_user/series/controller/SeriesApi.java | 52 ++++++++ .../series/controller/SeriesController.java | 47 ++++++++ .../series/dto/SeriesContentsResponse.java | 31 +++++ .../series/service/SeriesService.java | 113 ++++++++++++++++++ .../common/web/response/SuccessResponse.java | 1 - .../repository/CategoryRepository.java | 13 +- .../repository/ContentsRepository.java | 6 + .../repository/PlaybackRepository.java | 12 ++ .../series/repository/SeriesRepository.java | 3 +- .../domain/tag/repository/TagRepository.java | 11 +- 11 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java index b1f059d..47e97b6 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchApi.java @@ -30,7 +30,8 @@ public interface SearchApi { ResponseEntity> search( @Parameter(description = "검색어를 입력해주세요.", required = true) @RequestParam(value = "searchWord") String searchWord, - @Parameter(description = "조회할 페이지 번호를 입력해주세요. page는 0부터 시작합니다") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(defaultValue = "0")) @RequestParam(value = "page", defaultValue = "0") Integer page, + + @Parameter(description = "한 페이지 당 최대 항목 개수", schema = @Schema(defaultValue = "24")) @RequestParam(value = "size", defaultValue = "24") Integer size); - @Parameter(description = "한 페이지 당 최대 항목 개수를 입력해주세요. 기본값은 24입니다.") @RequestParam(value = "size", defaultValue = "24") Integer size); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java new file mode 100644 index 0000000..52b47e7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -0,0 +1,52 @@ +package com.ott.api_user.series.controller; + +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.RequestParam; + +import com.ott.api_user.series.dto.SeriesDetailResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 jakarta.websocket.server.PathParam; + +@Tag(name = "Series API", description = "시리즈 관련 API입니다.") +public interface SeriesApi { + @Operation(summary = "시리즈 상세 조회", description = "특정 시리즈의 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "시리즈 상세 조회 성공", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = SeriesDetailResponse.class)) }), + @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{seriesId}") + ResponseEntity> getSeriesDetail( + @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("seriesId") Long seriesId, + @Parameter(hidden = true) Long memberId // 토큰에서 추출 (스웨거에서는 숨김) + ); + + @Operation(summary = "시리즈 콘텐츠 목록 조회", description = "특정 시리즈에 속한 콘텐츠(에피소드) 목록을 페이징하여 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "시리즈 콘텐츠 목록 조회 성공", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), + @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{seriesId}/contents") + ResponseEntity> getSeriesContents( + @Parameter(description = "시리즈 ID", required = true) @PathVariable("seriesId") Long seriesId, + @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(defaultValue = "0")) @RequestParam("page") Integer page, + @Parameter(description = "페이지 크기", schema = @Schema(defaultValue = "24")) @RequestParam("size") Integer size, + @Parameter(hidden = true) Long memberId // 토큰에서 추출 (스웨거에서는 숨김) + ); + // 이어보기 지점 추가 +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java new file mode 100644 index 0000000..f361870 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -0,0 +1,47 @@ +package com.ott.api_user.series.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.method.P; +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; + +import com.ott.api_user.series.dto.SeriesDetailResponse; +import com.ott.api_user.series.service.SeriesService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import jakarta.websocket.server.PathParam; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/series") +public class SeriesController implements SeriesApi { + private final SeriesService seriesService; + + @Override + public ResponseEntity> getSeriesDetail( + @PathVariable(value = "seriesId") Long seriesId, Long memberId) { + + Long currentMemberId = 1L; + SeriesDetailResponse response = seriesService.getSeriesDetail(seriesId, currentMemberId); + + return ResponseEntity.ok(SuccessResponse.of(response)); + } + + @Override + public ResponseEntity> getSeriesContents( + @PathVariable(value = "seriesId") Long seriesId, + @RequestParam(value = "page") Integer pageParam, + @RequestParam(value = "size") Integer sizeParam, + Long memberId) { + + Long currentMemberId = 1L; + PageResponse response = seriesService.getSeriesContents(seriesId, pageParam, sizeParam, currentMemberId); + + return ResponseEntity.ok(SuccessResponse.of(response)); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java new file mode 100644 index 0000000..8e401d3 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_user.series.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "시리즈 내 콘텐츠(에피소드) 목록 아이템 응답 DTO") +public class SeriesContentsResponse { + @Schema(type = "Long", example = "1", description = "콘텐츠 고유 ID") + private Long id; + + @Schema(type = "String", example = "더 글로리 시즌 1: 1화", description = "콘텐츠 제목") + private String title; + + @Schema(type = "String", example = "추락하는 자에겐 날개가 없다...", description = "콘텐츠 설명") + private String description; + + @Schema(type = "String", example = "https://cdn.ott.com/thumbnails/c101.jpg", description = "콘텐츠 썸네일") + private String thumbnailUrl; + + @Schema(type = "Integer", example = "3600", description = "재생 시간 (초)") + private Integer duration; + + // 이어보기 지점도 응답에 포함 + @Schema(type = "Integer", example = "1200", description = "사용자가 마지막으로 시청한 지점 (초)") + private Integer positionSec; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java new file mode 100644 index 0000000..96322c1 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -0,0 +1,113 @@ +package com.ott.api_user.series.service; + +import java.util.List; +import java.util.Locale.Category; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import com.ott.api_user.series.dto.SeriesContentsResponse; +import com.ott.api_user.series.dto.SeriesDetailResponse; +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.bookmark.domain.Bookmark; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.common.TargetType; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.likes.domain.Likes; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.playback.domain.Playback; +import com.ott.domain.playback.repository.PlaybackRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.domain.tag.repository.TagRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SeriesService { + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; + private final TagRepository tagRepository; + private final CategoryRepository categoryRepository; + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + private final PlaybackRepository playbackRepository; + + // 시리즈 상세 조회 + public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { + Series series = seriesRepository + .findByIdAndStatusAndPublicStatus(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + List tags = tagRepository.findTagNamesBySeriesId(seriesId); + List categories = categoryRepository.findCategoryNameBySeriesId(seriesId); + + Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndTargetIdAndTargetTypeAndStatus(memberId, + seriesId, TargetType.SERIES, Status.ACTIVE); + + Boolean isLiked = likesRepository.existsByMemberIdAndTargetIdAndTargetTypeAndStatus(memberId, seriesId, + TargetType.SERIES, Status.ACTIVE); + + return SeriesDetailResponse.builder() + .id(series.getId()) + .title(series.getTitle()) + .description(series.getDescription()) + .actors(series.getActors()) + .posterUrl(series.getPosterUrl()) + .thumbnailUrl(series.getThumbnailUrl()) + .category(categories.isEmpty() ? null : categories.get(0)) + .tags(tags) + .isBookmarked(isBookmarked) + .isLiked(isLiked) + .build(); + } + + // 시리즈 콘텐츠 목록 조회 (페이징) + public PageResponse getSeriesContents(Long seriesId, int page, int size, Long memberId) { + Series series = seriesRepository + .findByIdAndStatusAndPublicStatus(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Pageable pageable = PageRequest.of(page, size); + Page contentsPage = contentsRepository.findBySeriesIdAndStatusAndPublicStatusOrderByIdAsc( + seriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); + + List contentsList = contentsPage.getContent().stream().map(content -> { + Integer positionSec = 0; + if (memberId != null) { + positionSec = playbackRepository + .findByMemberIdAndContentsIdAndStatus(memberId, content.getId(), + Status.ACTIVE) + .map(Playback::getPositionSec) + .orElse(0); + } + + return SeriesContentsResponse.builder() + .id(content.getId()) + .title(content.getTitle()) + .description(content.getDescription()) + .duration(content.getDuration()) + .thumbnailUrl(content.getThumbnailUrl()) + .positionSec(positionSec) + .build(); + }).collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.builder() + .currentPage(contentsPage.getNumber()) + .totalPage(contentsPage.getTotalPages()) + .pageSize(contentsPage.getSize()) + .build(); + + return PageResponse.toPageResponse(pageInfo, contentsList); + } +} diff --git a/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java b/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java index 3b98f10..2448114 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java +++ b/modules/common-web/src/main/java/com/ott/common/web/response/SuccessResponse.java @@ -8,7 +8,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; - @NoArgsConstructor(access = AccessLevel.PRIVATE) @Getter @Schema(description = "성공 Response") diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index 574e5e5..a5e2df6 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -12,10 +12,13 @@ public interface CategoryRepository extends JpaRepository { // 하나의 시리즈는 하나의 카테고리를 갖지만 // 일단 List 형태로 처리 - @Query("SELECT DISTINCT c.name FROM Category c" + - "JOIN Tag t ON c.id = t.category.id" + - "JOIN SeriesTag st ON t.id = st.tagId" + - "WHERE st.seriesId = :seriesId" + - "AND c.status = 'ACTIVE'") + @Query(""" + SELECT DISTINCT c.name + FROM Category c + JOIN Tag t ON c.id = t.category.id + JOIN SeriesTag st ON t.id = st.tag.id + WHERE st.series.id = :seriesId + AND c.status = 'ACTIVE' + """) List findCategoryNameBySeriesId(@Param("seriesId") Long seriesId); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 6c0d3fb..17e4aad 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -2,11 +2,13 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.ott.domain.common.PublicStatus; import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; @@ -21,4 +23,8 @@ public interface ContentsRepository extends JpaRepository { List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, Pageable pageable); + // 특정 시리즈의 콘텐츠(에피소드) 들을 1화부터 오름차순으로 페이징해서 가져옴. + Page findBySeriesIdAndStatusAndPublicStatusOrderByIdAsc(Long seriesId, Status status, + PublicStatus publicStatus, Pageable pageable); + } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java new file mode 100644 index 0000000..e127a6d --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -0,0 +1,12 @@ +package com.ott.domain.playback.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ott.domain.common.Status; +import com.ott.domain.playback.domain.Playback; + +public interface PlaybackRepository extends JpaRepository { + Optional findByMemberIdAndContentsIdAndStatus(Long memberId, Long contentsId, Status status); +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index fc8f5b9..46a5140 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.ott.domain.common.PublicStatus; import com.ott.domain.common.Status; import com.ott.domain.series.domain.Series; @@ -23,5 +24,5 @@ List searchLatest(@Param("keyword") String keyword, Pageable pageable); // 시리즈 단건 조회 - Optional findByIdAndStatusAndPublicStatus(Long id, Status status, Boolean publicStatus); + Optional findByIdAndStatusAndPublicStatus(Long id, Status status, PublicStatus publicStatus); } diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index a97db07..9b16b3f 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -10,9 +10,12 @@ public interface TagRepository extends JpaRepository { - @Query("SELECT t.name FROM Tag t" + - "JOIN SeriesTag st ON t.id = st.tagId" + - "WHERE st.seriesId = :seriesId" + - "AND t.status = 'ACTIVE'") + @Query(""" + SELECT t.name + FROM Tag t + JOIN SeriesTag st ON t.id = st.tag.id + WHERE st.series.id = :seriesId + AND t.status = 'ACTIVE' + """) List findTagNamesBySeriesId(@Param("seriesId") Long seriesId); } \ No newline at end of file From 033e25fd5bd9c25cdfac5f22ffd95c307563822c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sat, 21 Feb 2026 22:52:41 +0900 Subject: [PATCH 053/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 21 +++++++ .../BackOfficeContentsController.java | 12 ++++ .../dto/response/ContentsDetailResponse.java | 57 +++++++++++++++++++ .../mapper/BackOfficeContentsMapper.java | 38 +++++++++++++ .../service/BackOfficeContentsService.java | 35 ++++++++++-- .../repository/ContentsRepository.java | 27 ++++----- .../repository/ContentsRepositoryCustom.java | 10 ++++ .../repository/ContentsRepositoryImpl.java | 32 +++++++++++ 8 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index ad7c630..2d946c3 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -1,5 +1,6 @@ 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; @@ -14,6 +15,7 @@ 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") @@ -44,4 +46,23 @@ ResponseEntity>> getContents( @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> getContentsDetail( + @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable Long mediaId + ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index 1e20c5f..8000bd4 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -1,5 +1,6 @@ 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; @@ -8,6 +9,7 @@ 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; @@ -31,4 +33,14 @@ public ResponseEntity>> getCo SuccessResponse.of(backOfficeContentsService.getContents(page, size, searchWord, publicStatus)) ); } + + @Override + @GetMapping("/admin/contents/{mediaId}") + public ResponseEntity> getContentsDetail( + @PathVariable Long mediaId + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) + ); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java new file mode 100644 index 0000000..6d97ee9 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java @@ -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, + + @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 = "영상 크기(바이트)", example = "1048576") + Integer videoSize, + + @Schema(type = "String", description = "카테고리명", example = "드라마") + String categoryName, + + @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") + List 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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java index 2838e5e..3897988 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -1,6 +1,8 @@ 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; @@ -19,4 +21,40 @@ public ContentsListResponse toContentsListResponse(Media media) { media.getCreatedDate().toLocalDate() ); } + + public ContentsDetailResponse toContentsDetailResponse(Contents contents, Media media, String uploaderNickname, String seriesTitle, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List 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 mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index 5e7a8e0..a5e3a46 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -1,16 +1,20 @@ 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 com.ott.domain.member.domain.Role; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -18,10 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @RequiredArgsConstructor @Service @@ -30,6 +31,8 @@ public class BackOfficeContentsService { private final BackOfficeContentsMapper backOfficeContentsMapper; private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + private final ContentsRepository contentsRepository; @Transactional(readOnly = true) public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { @@ -49,4 +52,28 @@ public PageResponse getContents(int page, int size, String ); 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 mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMediaId); + + return backOfficeContentsMapper.toContentsDetailResponse(contents, media, uploaderNickname, seriesTitle, mediaTagList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 9fd59b6..e5777a8 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -1,18 +1,11 @@ -//package com.ott.domain.contents.repository; -// -//import java.util.List; -// -//import org.springframework.data.domain.Pageable; -//import org.springframework.data.jpa.repository.JpaRepository; -//import org.springframework.data.jpa.repository.Query; -//import org.springframework.data.repository.query.Param; -// -//import com.ott.domain.common.Status; -//import com.ott.domain.contents.domain.Contents; -// -//public interface ContentsRepository extends JpaRepository { -// -// // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentsRepository extends JpaRepository, ContentsRepositoryCustom { + + // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) // @Query("SELECT c FROM Contents c " + // "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + // "AND c.status = :status " + @@ -20,5 +13,5 @@ // "ORDER BY c.createdDate DESC") // List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, // Pageable pageable); -// -//} \ No newline at end of file + +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java new file mode 100644 index 0000000..2c8564e --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; + +import java.util.Optional; + +public interface ContentsRepositoryCustom { + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java new file mode 100644 index 0000000..b6bcc70 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; + +@RequiredArgsConstructor +public class ContentsRepositoryImpl implements ContentsRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + Contents result = queryFactory + .selectFrom(contents) + .join(contents.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(contents.series, series).fetchJoin() + .leftJoin(series.media).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} From e15776187b06d12bc962ed334fd455052ef77701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 17:27:09 +0900 Subject: [PATCH 054/257] =?UTF-8?q?[FEAT]:=20=EC=8B=9C=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=20=EC=A0=9C=EB=AA=A9=20=EB=AA=A9=EB=A1=9D=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콘텐츠 업로드 시 모달에서 드롭다운으로 사용 --- .../controller/BackOfficeSeriesApi.java | 26 +++++++++++++++ .../BackOfficeSeriesController.java | 13 ++++++++ .../dto/response/SeriesTitleListResponse.java | 13 ++++++++ .../series/mapper/BackOfficeSeriesMapper.java | 8 +++++ .../service/BackOfficeSeriesService.java | 21 ++++++++++++ .../repository/SeriesRepositoryCustom.java | 4 +++ .../repository/SeriesRepositoryImpl.java | 33 +++++++++++++++++++ 7 files changed, 118 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index c535e77..7270949 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -2,6 +2,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -45,6 +46,31 @@ ResponseEntity>> getSeries( @Parameter(description = "제목 부분일치 검색어. 미입력 시 전체 목록을 조회합니다.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord ); + @Operation(summary = "시리즈 제목 목록 조회 (콘텐츠 업로드 페이지)", description = "시리즈 목록을 페이징으로 조회합니다. - ADMIN 권한 필요.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesTitleListResponse.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>> getSeriesTitle( + @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 + ); + @Operation(summary = "시리즈 상세 조회", description = "시리즈의 상세 정보를 조회합니다. - ADMIN 권한 필요.") @ApiResponses(value = { @ApiResponse( diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 528985d..cb7fcde 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -2,6 +2,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.api_admin.series.service.BackOfficeSeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -28,6 +29,18 @@ public ResponseEntity>> getSeri ); } + @Override + @GetMapping("/admin/series/titles") + public ResponseEntity>> getSeriesTitle( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeSeriesService.getSeriesTitle(page, size, searchWord)) + ); + } + @Override @GetMapping("/admin/series/{mediaId}") public ResponseEntity> getSeriesDetail(@PathVariable("mediaId") Long mediaId) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java new file mode 100644 index 0000000..e3a8055 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java @@ -0,0 +1,13 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SeriesTitleListResponse( + + @Schema(type = "Long", description = "시리즈 ID", example = "1") + Long seriesId, + + @Schema(type = "String", description = "시리즈 제목", example = "비밀의 숲") + String title +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 7580f09..7e6506d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -2,6 +2,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.series.domain.Series; @@ -26,6 +27,13 @@ public SeriesListResponse toSeriesListResponse(Media media, List media ); } + public SeriesTitleListResponse toSeriesTitleList(Series series) { + return new SeriesTitleListResponse( + series.getId(), + series.getMedia().getTitle() + ); + } + public SeriesDetailResponse toSeriesDetailResponse(Series series, Media media, String uploaderName, List mediaTagList) { String categoryName = extractCategoryName(mediaTagList); List tagNameList = extractTagNameList(mediaTagList); diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 05f7770..9f28a11 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,7 +1,9 @@ package com.ott.api_admin.series.service; +import com.ott.api_admin.content.dto.response.ContentsListResponse; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -69,6 +71,25 @@ public PageResponse getSeries(int page, int size, String sea return PageResponse.toPageResponse(pageInfo, responseList); } + @Transactional(readOnly = true) + public PageResponse getSeriesTitle(Integer page, Integer size, String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + // 시리즈 + 미디어 페이징 + Page seriesPage = seriesRepository.findSeriesListWithMediaBySearchWord(pageable, searchWord); + + List responseList = seriesPage.getContent().stream() + .map(backOfficeSeriesMapper::toSeriesTitleList) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + seriesPage.getNumber(), + seriesPage.getTotalPages(), + seriesPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } + @Transactional(readOnly = true) public SeriesDetailResponse getSeriesDetail(Long mediaId) { // 1. Series + Media + Uploader 한 번에 조회 diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java index aae6a81..d361b5c 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -1,10 +1,14 @@ package com.ott.domain.series.repository; import com.ott.domain.series.domain.Series; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.Optional; public interface SeriesRepositoryCustom { + Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord); + Optional findWithMediaAndUploaderByMediaId(Long mediaId); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java index daf832c..f7402d4 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -1,9 +1,16 @@ package com.ott.domain.series.repository; import com.ott.domain.series.domain.Series; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; +import java.util.List; import java.util.Optional; import static com.ott.domain.media.domain.QMedia.media; @@ -26,4 +33,30 @@ public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { return Optional.ofNullable(result); } + + @Override + public Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord) { + List seriesList = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .where(titleContains(searchWord)) + .orderBy(series.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(series.count()) + .from(series) + .join(series.media, media) + .where(titleContains(searchWord)); + + return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } } From dea9bca897e742de7177720e72e0b1e211f23510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 17:47:23 +0900 Subject: [PATCH 055/257] =?UTF-8?q?[CHORE]:=20Admin=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20API=20=EA=B2=BD=EB=A1=9C=20=EB=B2=A0=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/BackOfficeContentsController.java | 6 +++--- .../member/controller/BackOfficeMemberController.java | 6 +++--- .../series/controller/BackOfficeSeriesController.java | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index 8000bd4..093daec 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -15,14 +15,14 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/back-office") +@RequestMapping("/back-office/admin/contents") @RequiredArgsConstructor public class BackOfficeContentsController implements BackOfficeContentsApi { private final BackOfficeContentsService backOfficeContentsService; @Override - @GetMapping("/admin/contents") + @GetMapping public ResponseEntity>> getContents( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -35,7 +35,7 @@ public ResponseEntity>> getCo } @Override - @GetMapping("/admin/contents/{mediaId}") + @GetMapping("/{mediaId}") public ResponseEntity> getContentsDetail( @PathVariable Long mediaId ) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java index 4650d6d..c455d48 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -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>> getMemberList( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -32,7 +32,7 @@ public ResponseEntity>> getMemb } @Override - @PatchMapping("/admin/members/{memberId}/role") + @PatchMapping("/{memberId}/role") public ResponseEntity changeRole( @PathVariable("memberId") Long memberId, @Valid @RequestBody ChangeRoleRequest changeRoleRequest diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index cb7fcde..5786f8e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -11,14 +11,14 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/back-office") +@RequestMapping("/back-office/admin/series") @RequiredArgsConstructor public class BackOfficeSeriesController implements BackOfficeSeriesApi { private final BackOfficeSeriesService backOfficeSeriesService; @Override - @GetMapping("/admin/series") + @GetMapping public ResponseEntity>> getSeries( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -30,7 +30,7 @@ public ResponseEntity>> getSeri } @Override - @GetMapping("/admin/series/titles") + @GetMapping("/titles") public ResponseEntity>> getSeriesTitle( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -42,7 +42,7 @@ public ResponseEntity>> ge } @Override - @GetMapping("/admin/series/{mediaId}") + @GetMapping("/{mediaId}") public ResponseEntity> getSeriesDetail(@PathVariable("mediaId") Long mediaId) { return ResponseEntity.ok( SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(mediaId)) From baebafe03ccf9b86eaea22d594b152a0f579f0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 18:54:33 +0900 Subject: [PATCH 056/257] =?UTF-8?q?[CHORE]:=20swagger=20=EC=98=A4=ED=83=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20query=20alias=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/controller/BackOfficeContentsApi.java | 2 +- .../domain/contents/repository/ContentsRepositoryImpl.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index 2d946c3..e818a5d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -54,7 +54,7 @@ ResponseEntity>> getContents( content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsDetailResponse.class))} ), @ApiResponse( - responseCode = "400", description = "시리즈 상세 조회 실패", + responseCode = "400", description = "콘텐츠 상세 조회 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} ), @ApiResponse( diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java index b6bcc70..8d62958 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java @@ -1,6 +1,7 @@ package com.ott.domain.contents.repository; import com.ott.domain.contents.domain.Contents; +import com.ott.domain.media.domain.QMedia; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -18,12 +19,13 @@ public class ContentsRepositoryImpl implements ContentsRepositoryCustom { @Override public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + QMedia seriesMedia = new QMedia("seriesMedia"); Contents result = queryFactory .selectFrom(contents) .join(contents.media, media).fetchJoin() .join(media.uploader, member).fetchJoin() .leftJoin(contents.series, series).fetchJoin() - .leftJoin(series.media).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() .where(media.id.eq(mediaId)) .fetchOne(); From 1d079e732cf74c0ba50696cb4ef73e7d6d8edb66 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:08:05 +0900 Subject: [PATCH 057/257] =?UTF-8?q?[FIX]=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EB=90=9C=20=EB=B6=80=EB=B6=84=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20authenticationprincip?= =?UTF-8?q?al=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ .../series/controller/SeriesController.java | 12 ++++++------ .../api_user/series/service/SeriesService.java | 4 ++-- apps/api-user/src/main/resources/application.yml | 6 +++--- .../category/repository/CategoryRepository.java | 16 ++++++++-------- .../ott/domain/tag/repository/TagRepository.java | 12 +++++++----- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 3271517..4b4dae9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ modules/infra/src/main/resources/db/seed/ private_docs/ AGENTS.md CLAUDE.md + + + +**/application-local.yml \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java index f361870..c627173 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.method.P; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -24,10 +25,10 @@ public class SeriesController implements SeriesApi { @Override public ResponseEntity> getSeriesDetail( - @PathVariable(value = "seriesId") Long seriesId, Long memberId) { + @PathVariable(value = "seriesId") Long seriesId, + @AuthenticationPrincipal Long memberId) { - Long currentMemberId = 1L; - SeriesDetailResponse response = seriesService.getSeriesDetail(seriesId, currentMemberId); + SeriesDetailResponse response = seriesService.getSeriesDetail(seriesId, memberId); return ResponseEntity.ok(SuccessResponse.of(response)); } @@ -37,10 +38,9 @@ public ResponseEntity> getSeriesContents( @PathVariable(value = "seriesId") Long seriesId, @RequestParam(value = "page") Integer pageParam, @RequestParam(value = "size") Integer sizeParam, - Long memberId) { + @AuthenticationPrincipal Long memberId) { - Long currentMemberId = 1L; - PageResponse response = seriesService.getSeriesContents(seriesId, pageParam, sizeParam, currentMemberId); + PageResponse response = seriesService.getSeriesContents(seriesId, pageParam, sizeParam, memberId); return ResponseEntity.ok(SuccessResponse.of(response)); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index 1cfacdb..ba4720e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -50,8 +50,8 @@ public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { Long mediaId = series.getMedia().getId(); - List tags = tagRepository.findTagNamesBySeriesId(seriesId); - List categories = categoryRepository.findCategoryNameBySeriesId(seriesId); + List tags = tagRepository.findTagNamesByMediaId(mediaId); + List categories = categoryRepository.findCategoryNamesByMediaId(mediaId); Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index 0190d09..e27a268 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -17,8 +17,8 @@ spring: client: registration: kakao: - client-id: ${KAKAO_CLIENT_ID} # REST API - client-secret: ${KAKAO_CLIENT_SECRET} #Client Secret + client-id: ${KAKAO_CLIENT_ID:} # REST API + client-secret: ${KAKAO_CLIENT_SECRET:} #Client Secret authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-authentication-method: client_secret_post # body에 넣어서 토큰 교환 @@ -61,6 +61,6 @@ management: # JWT 설정 jwt: - secret: ${JWT_SECRET_BASE64} + secret: ${JWT_SECRET_BASE64:} access-token-expiry: 1800000 # 30분 refresh-token-expiry: 1209600000 # 14일 diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index a5e2df6..777ab4f 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -10,15 +10,15 @@ public interface CategoryRepository extends JpaRepository { - // 하나의 시리즈는 하나의 카테고리를 갖지만 - // 일단 List 형태로 처리 @Query(""" SELECT DISTINCT c.name - FROM Category c - JOIN Tag t ON c.id = t.category.id - JOIN SeriesTag st ON t.id = st.tag.id - WHERE st.series.id = :seriesId - AND c.status = 'ACTIVE' + FROM MediaTag mt + JOIN mt.tag t + JOIN t.category c + WHERE mt.media.id = :mediaId + AND mt.status = 'ACTIVE' + AND t.status = 'ACTIVE' + AND c.status = 'ACTIVE' """) - List findCategoryNameBySeriesId(@Param("seriesId") Long seriesId); + List findCategoryNamesByMediaId(@Param("mediaId") Long mediaId); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index 9b16b3f..d0ed567 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -10,12 +10,14 @@ public interface TagRepository extends JpaRepository { + // 시리즈/콘텐츠에 연결된 태그 조회 @Query(""" - SELECT t.name - FROM Tag t - JOIN SeriesTag st ON t.id = st.tag.id - WHERE st.series.id = :seriesId + SELECT DISTINCT t.name + FROM MediaTag mt + JOIN mt.tag t + WHERE mt.media.id = :mediaId AND t.status = 'ACTIVE' + AND mt.status = 'ACTIVE' """) - List findTagNamesBySeriesId(@Param("seriesId") Long seriesId); + List findTagNamesByMediaId(@Param("mediaId") Long mediaId); } \ No newline at end of file From 713941877f65dd201cf0dccfc7fe761257916f0d Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:18:55 +0900 Subject: [PATCH 058/257] =?UTF-8?q?[FIX]:=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20LazyIniti?= =?UTF-8?q?alizationException=20=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20Fetch=20Join=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_user/series/service/SeriesService.java | 5 ++--- .../ott/domain/series/repository/SeriesRepository.java | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index ba4720e..2eb240c 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -44,8 +44,7 @@ public class SeriesService { // 시리즈 상세 조회 public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { - Series series = seriesRepository - .findByIdAndStatusAndMedia_PublicStatus(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) + Series series = seriesRepository.findByIdWithMedia(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Long mediaId = series.getMedia().getId(); @@ -74,7 +73,7 @@ public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { // 시리즈 콘텐츠 목록 조회 (페이징) public PageResponse getSeriesContents(Long seriesId, int page, int size, Long memberId) { - seriesRepository.findByIdAndStatusAndMedia_PublicStatus(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) + seriesRepository.findByIdWithMedia(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Pageable pageable = PageRequest.of(page, size); diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index e067df8..20138c9 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -13,5 +13,12 @@ import com.ott.domain.series.domain.Series; public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { - Optional findByIdAndStatusAndMedia_PublicStatus(Long id, Status status, PublicStatus publicStatus); + + // Optional findByIdAndStatusAndMedia_PublicStatus(Long id, Status + // status, PublicStatus publicStatus); + @Query("SELECT s FROM Series s JOIN FETCH s.media m WHERE s.id = :id AND s.status = :status AND m.publicStatus = :publicStatus") + Optional findByIdWithMedia(@Param("id") Long id, + @Param("status") Status status, + @Param("publicStatus") PublicStatus publicStatus); + } From 799cee16fd987e2fc75359675e6a6b9650b0b27b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 19:26:56 +0900 Subject: [PATCH 059/257] =?UTF-8?q?[CHORE]:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/dto/response/ContentsDetailResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java index 6d97ee9..7c2e7ef 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java @@ -36,7 +36,7 @@ public record ContentsDetailResponse( @Schema(type = "Integer", description = "영상 길이(초)", example = "7200") Integer duration, - @Schema(type = "Integer", description = "영상 크기(바이트)", example = "1048576") + @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") Integer videoSize, @Schema(type = "String", description = "카테고리명", example = "드라마") From 31e64fc5a39d3653408881ac1817687763b6261c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 22:02:24 +0900 Subject: [PATCH 060/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=88=8F=ED=8F=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자/에디터 통합 API로, Service 내에서 구분 후 역할에 알맞게 응답 구성 --- .../controller/BackOfficeShortFormApi.java | 45 ++++++++++++++ .../BackOfficeShortFormController.java | 36 +++++++++++ .../shortform/dto/ShortFormListResponse.java | 26 ++++++++ .../mapper/BackOfficeShortFormMapper.java | 19 ++++++ .../service/BackOfficeShortFormService.java | 62 +++++++++++++++++++ .../repository/MediaRepositoryCustom.java | 1 + .../media/repository/MediaRepositoryImpl.java | 34 ++++++++++ 7 files changed, 223 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java new file mode 100644 index 0000000..b241b03 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -0,0 +1,45 @@ +package com.ott.api_admin.shortform.controller; + +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "BackOffice Short-Form API", description = "[백오피스] 숏폼 관리 API") +public interface BackOfficeShortFormApi { + + @Operation(summary = "숏폼 목록 조회", description = "숏폼 목록을 페이징으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ShortFormListResponse.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))} + ) + }) + ResponseEntity>> getShortFormList( + @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, + Authentication authentication + ); +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java new file mode 100644 index 0000000..efeece5 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -0,0 +1,36 @@ +package com.ott.api_admin.shortform.controller; + +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.service.BackOfficeShortFormService; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +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/short-forms") +@RequiredArgsConstructor +public class BackOfficeShortFormController implements BackOfficeShortFormApi { + + private final BackOfficeShortFormService backOfficeShortFormService; + + @Override + @GetMapping + public ResponseEntity>> getShortFormList( + @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, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getShortFormList(page, size, searchWord, publicStatus, authentication)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java new file mode 100644 index 0000000..4a0362e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.shortform.dto; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "숏폼 목록 조회 응답") +public record ShortFormListResponse( + + @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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java new file mode 100644 index 0000000..d1e151f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.shortform.mapper; + +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.domain.media.domain.Media; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeShortFormMapper { + + public ShortFormListResponse toShortFormListResponse(Media media) { + return new ShortFormListResponse( + media.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getPublicStatus(), + media.getCreatedDate().toLocalDate() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java new file mode 100644 index 0000000..adb4daf --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -0,0 +1,62 @@ +package com.ott.api_admin.shortform.service; + +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; +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.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackOfficeShortFormService { + + private final BackOfficeShortFormMapper backOfficeShortFormMapper; + + private final MediaRepository mediaRepository; + + @Transactional(readOnly = true) + public PageResponse getShortFormList( + Integer page, Integer size, String searchWord, PublicStatus publicStatus, Authentication authentication + ) { + Pageable pageable = PageRequest.of(page, size); + + // 1. 관리자/에디터 여부 확인 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + Long uploaderId = null; + + // 2. 에디터인 경우 본인이 업로드한 숏폼만 조회 가능 + if (isEditor) + uploaderId = memberId; + + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( + pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId + ); + + List responseList = mediaPage.getContent().stream() + .map(backOfficeShortFormMapper::toShortFormListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index 395e008..72eaf11 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -10,4 +10,5 @@ public interface MediaRepositoryCustom { Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus); + Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 5ae5b98..b03ad26 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -71,6 +71,34 @@ public Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); } + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId) + ); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) return media.title.contains(searchWord); @@ -88,4 +116,10 @@ private BooleanExpression publicStatusEq(PublicStatus publicStatus) { return media.publicStatus.eq(publicStatus); return null; } + + private BooleanExpression uploaderIdEq(Long uploaderId) { + if (uploaderId != null) + return media.uploader.id.eq(uploaderId); + return null; + } } From 03380eef650c0d1d2f9447944dcba824b55a06cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 22:03:56 +0900 Subject: [PATCH 061/257] =?UTF-8?q?[CHORE]:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20.gitkeep=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_admin/shortform/controller/.gitkeep | 0 .../src/main/java/com/ott/api_admin/shortform/dto/.gitkeep | 0 .../src/main/java/com/ott/api_admin/shortform/service/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep deleted file mode 100644 index e69de29..0000000 From ad9151c1ac6ff81cd3ba537abe335c54173b1e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 22 Feb 2026 23:04:22 +0900 Subject: [PATCH 062/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=88=8F=ED=8F=BC=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeShortFormApi.java | 19 +++++++ .../BackOfficeShortFormController.java | 17 +++++-- .../dto/ShortFormDetailResponse.java | 51 +++++++++++++++++++ .../mapper/BackOfficeShortFormMapper.java | 39 ++++++++++++++ .../service/BackOfficeShortFormService.java | 35 +++++++++++++ .../domain/short_form/domain/ShortForm.java | 4 ++ .../repository/ShortFormRepository.java | 7 +++ .../repository/ShortFormRepositoryCustom.java | 10 ++++ .../repository/ShortFormRepositoryImpl.java | 39 ++++++++++++++ 9 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index b241b03..ba95f9b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -1,5 +1,7 @@ package com.ott.api_admin.shortform.controller; +import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; @@ -15,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Short-Form API", description = "[백오피스] 숏폼 관리 API") @@ -42,4 +45,20 @@ ResponseEntity>> getShortFor @Parameter(description = "공개 여부. 공개/비공개로 나뉩니다.", required = false, example = "PUBLIC") @RequestParam(value = "publicStatus", required = false) PublicStatus publicStatus, Authentication authentication ); + + @Operation(summary = "숏폼 상세 조회", description = "숏폼 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "숏폼 상세 조회 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormDetailResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "숏폼 상세 조회 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + }) + ResponseEntity> getShortFormDetail( + @Parameter(description = "조회할 숏폼의 미디어 ID", required = true) @PathVariable Long mediaId, + Authentication authentication + ); } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index efeece5..7504fd7 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -1,5 +1,6 @@ package com.ott.api_admin.shortform.controller; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.api_admin.shortform.service.BackOfficeShortFormService; import com.ott.common.web.response.PageResponse; @@ -8,10 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/back-office/short-forms") @@ -33,4 +31,15 @@ public ResponseEntity>> getS SuccessResponse.of(backOfficeShortFormService.getShortFormList(page, size, searchWord, publicStatus, authentication)) ); } + + @Override + @GetMapping("/{mediaId}") + public ResponseEntity> getShortFormDetail( + @PathVariable Long mediaId, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getShortFormDetail(mediaId, authentication)) + ); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java new file mode 100644 index 0000000..1d4debc --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java @@ -0,0 +1,51 @@ +package com.ott.api_admin.shortform.dto; + +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 ShortFormDetailResponse( + + @Schema(type = "Long", description = "숏폼 ID", example = "1") + Long shortFormId, + + @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/poster.jpg") + String posterUrl, + + @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") + String title, + + @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") + String description, + + @Schema(type = "String", description = "원본 콘텐츠 제목 (시리즈 혹은 콘텐츠(단편 등))", example = "더글로리 시즌1, 태극기 휘날리며") + String originContentsTitle, + + @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", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") + List 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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java index d1e151f..9c37881 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -1,9 +1,14 @@ package com.ott.api_admin.shortform.mapper; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.short_form.domain.ShortForm; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class BackOfficeShortFormMapper { @@ -16,4 +21,38 @@ public ShortFormListResponse toShortFormListResponse(Media media) { media.getCreatedDate().toLocalDate() ); } + + public ShortFormDetailResponse toShortFormDetailResponse(ShortForm shortForm, Media media, String uploaderNickname, String originMediaTitle, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); + + return new ShortFormDetailResponse( + shortForm.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getDescription(), + originMediaTitle, + uploaderNickname, + shortForm.getDuration(), + shortForm.getVideoSize(), + categoryName, + tagNameList, + media.getPublicStatus(), + media.getBookmarkCount(), + media.getCreatedDate().toLocalDate() + ); + } + + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index adb4daf..861f4f7 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,14 +1,21 @@ package com.ott.api_admin.shortform.service; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; +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.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 com.ott.domain.member.domain.Role; +import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.short_form.repository.ShortFormRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -26,6 +33,8 @@ public class BackOfficeShortFormService { private final BackOfficeShortFormMapper backOfficeShortFormMapper; private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + private final ShortFormRepository shortFormRepository; @Transactional(readOnly = true) public PageResponse getShortFormList( @@ -59,4 +68,30 @@ public PageResponse getShortFormList( return PageResponse.toPageResponse(pageInfo, responseList); } + + @Transactional(readOnly = true) + public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { + // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 조회 + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // 2. 에디터 - 숏폼 업로더 권한 체크 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + + Media media = shortForm.getMedia(); + if (isEditor && !media.getUploader().getId().equals(memberId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + + String uploaderNickname = media.getUploader().getNickname(); + + // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 + Media originMedia = shortForm.getOriginMedia(); + + // 3. 태그 조회 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMedia.getId()); + + return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMedia.getTitle(), mediaTagList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 4358dcb..e71cbac 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -55,4 +55,8 @@ public class ShortForm extends BaseEntity { @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + + public Media getOriginMedia() { + return series == null ? contents.getMedia() : series.getMedia(); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java new file mode 100644 index 0000000..c4f1575 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.short_form.domain.ShortForm; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ShortFormRepository extends JpaRepository, ShortFormRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java new file mode 100644 index 0000000..092594a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.short_form.domain.ShortForm; + +import java.util.Optional; + +public interface ShortFormRepositoryCustom { + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java new file mode 100644 index 0000000..42a0ac2 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.media.domain.QMedia; +import com.ott.domain.short_form.domain.ShortForm; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; +import static com.ott.domain.short_form.domain.QShortForm.shortForm; + +@RequiredArgsConstructor +public class ShortFormRepositoryImpl implements ShortFormRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + QMedia contentsMedia = new QMedia("contentsMedia"); + QMedia seriesMedia = new QMedia("seriesMedia"); + + ShortForm result = queryFactory + .selectFrom(shortForm) + .join(shortForm.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(shortForm.contents, contents).fetchJoin() + .leftJoin(contents.media, contentsMedia).fetchJoin() + .leftJoin(shortForm.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} From 8b1804415620cc16b7b3cd54f2859c560a62a322 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:05:12 +0900 Subject: [PATCH 063/257] =?UTF-8?q?[FIX]:=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20LazyIniti?= =?UTF-8?q?alizationException=20=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20@EntityGraph=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/domain/contents/repository/ContentsRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 6183732..5696be4 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; // import java.util.List; // @@ -33,6 +34,8 @@ import com.ott.domain.contents.domain.Contents; public interface ContentsRepository extends JpaRepository { + + @EntityGraph(attributePaths = { "media" }) Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long seriesId, Status status, PublicStatus publicStatus, Pageable pageable); } \ No newline at end of file From 077e42010c0a9043f3a54ff7decb2ef546a679b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 00:16:28 +0900 Subject: [PATCH 064/257] =?UTF-8?q?[FEAT]:=20=EC=88=8F=ED=8F=BC=EC=9D=98?= =?UTF-8?q?=20=EC=9B=90=EB=B3=B8=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EA=B2=80=EC=83=89=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 숏폼 업로드 모달에서 사용하는 원본 콘텐츠 제목 검색에 사용 --- .../dto/response/SeriesTitleListResponse.java | 1 + .../controller/BackOfficeShortFormApi.java | 23 +++++++++ .../BackOfficeShortFormController.java | 13 +++++ .../dto/OriginMediaTitleListResponse.java | 18 +++++++ .../mapper/BackOfficeShortFormMapper.java | 17 +++++++ .../service/BackOfficeShortFormService.java | 50 +++++++++++++++++++ .../repository/ContentsRepositoryCustom.java | 3 ++ .../repository/ContentsRepositoryImpl.java | 9 ++++ .../repository/MediaRepositoryCustom.java | 4 ++ .../media/repository/MediaRepositoryImpl.java | 35 +++++++++++++ .../repository/SeriesRepositoryCustom.java | 3 ++ .../repository/SeriesRepositoryImpl.java | 8 +++ 12 files changed, 184 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java index e3a8055..217639d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; +@Schema(description = "시리즈 제목 목록 조회 응답") public record SeriesTitleListResponse( @Schema(type = "Long", description = "시리즈 ID", example = "1") diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index ba95f9b..b7bfa4b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -1,6 +1,8 @@ package com.ott.api_admin.shortform.controller; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.common.web.exception.ErrorResponse; @@ -46,6 +48,27 @@ ResponseEntity>> getShortFor Authentication authentication ); + @Operation(summary = "원본 콘텐츠 제목 목록 조회 (숏폼 업로드 페이지)", description = "원본 콘텐츠 목록을 페이징으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = OriginMediaTitleListResponse.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))} + ) + }) + ResponseEntity>> getOriginMediaTitle( + @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 + ); + @Operation(summary = "숏폼 상세 조회", description = "숏폼 상세 정보를 조회합니다.") @ApiResponses(value = { @ApiResponse( diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index 7504fd7..cf205c4 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -1,5 +1,6 @@ package com.ott.api_admin.shortform.controller; +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.api_admin.shortform.service.BackOfficeShortFormService; @@ -32,6 +33,18 @@ public ResponseEntity>> getS ); } + @Override + @GetMapping("/origin-media") + public ResponseEntity>> getOriginMediaTitle( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getOriginMediaTitle(page, size, searchWord)) + ); + } + @Override @GetMapping("/{mediaId}") public ResponseEntity> getShortFormDetail( diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java new file mode 100644 index 0000000..e01bb3c --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java @@ -0,0 +1,18 @@ +package com.ott.api_admin.shortform.dto; + +import com.ott.domain.common.MediaType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "원본 콘텐츠 제목 목록 조회 응답") +public record OriginMediaTitleListResponse( + + @Schema(type = "Long", description = "원본 콘텐츠 ID", example = "1") + Long originId, + + @Schema(type = "String", description = "원본 콘텐츠 제목", example = "비밀의 숲") + String title, + + @Schema(type = "String", description = "원본 콘텐츠 타입", example = "SERIES") + MediaType mediaType +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java index 9c37881..562f780 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -1,13 +1,16 @@ package com.ott.api_admin.shortform.mapper; +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.domain.common.MediaType; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.short_form.domain.ShortForm; import org.springframework.stereotype.Component; import java.util.List; +import java.util.Map; @Component public class BackOfficeShortFormMapper { @@ -43,6 +46,20 @@ public ShortFormDetailResponse toShortFormDetailResponse(ShortForm shortForm, Me ); } + public OriginMediaTitleListResponse toOriginMediaTitleListResponse( + Media media, Map seriesIdByMediaId, Map contentsIdByMediaId + ) { + Long originId = media.getMediaType() == MediaType.SERIES + ? seriesIdByMediaId.get(media.getId()) + : contentsIdByMediaId.get(media.getId()); + + return new OriginMediaTitleListResponse( + originId, + media.getTitle(), + media.getMediaType() + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 861f4f7..f2e5a40 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,5 +1,6 @@ package com.ott.api_admin.shortform.service; +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.ShortFormListResponse; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; @@ -14,6 +15,10 @@ import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.media_tag.repository.MediaTagRepository; import com.ott.domain.member.domain.Role; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; import com.ott.domain.short_form.domain.ShortForm; import com.ott.domain.short_form.repository.ShortFormRepository; import lombok.RequiredArgsConstructor; @@ -25,6 +30,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -34,6 +41,8 @@ public class BackOfficeShortFormService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; private final ShortFormRepository shortFormRepository; @Transactional(readOnly = true) @@ -69,6 +78,47 @@ public PageResponse getShortFormList( return PageResponse.toPageResponse(pageInfo, responseList); } + @Transactional(readOnly = true) + public PageResponse getOriginMediaTitle(Integer page, Integer size, String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + // 1. Media 페이징 조회 (Series + 단편 Contents / 에피소드 제외) + Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); + + List mediaList = mediaPage.getContent(); + + // 2. mediaId를 타입별로 분리 + List seriesMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.SERIES) + .map(Media::getId) + .toList(); + + List contentsMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.CONTENTS) + .map(Media::getId) + .toList(); + + // 3. 일괄 조회: mediaId → entityId 매핑 + Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() + .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); + + Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList).stream() + .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); + + // 4. 응답 매핑 + List responseList = mediaList.stream() + .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, contentsIdByMediaId)) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + @Transactional(readOnly = true) public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 조회 diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java index 2c8564e..ea196c6 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java @@ -2,9 +2,12 @@ import com.ott.domain.contents.domain.Contents; +import java.util.List; import java.util.Optional; public interface ContentsRepositoryCustom { Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); } diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java index 8d62958..29391ba 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java @@ -5,6 +5,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import java.util.List; import java.util.Optional; import static com.ott.domain.contents.domain.QContents.contents; @@ -17,6 +18,14 @@ public class ContentsRepositoryImpl implements ContentsRepositoryCustom { private final JPAQueryFactory queryFactory; + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(contents) + .where(contents.media.id.in(mediaIdList)) + .fetch(); + } + @Override public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { QMedia seriesMedia = new QMedia("seriesMedia"); diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index 72eaf11..87c0069 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -9,6 +9,10 @@ public interface MediaRepositoryCustom { Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); + Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus); + Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); + + Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index b03ad26..4e5d643 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -14,6 +14,9 @@ import java.util.List; +import com.querydsl.jpa.JPAExpressions; + +import static com.ott.domain.contents.domain.QContents.contents; import static com.ott.domain.media.domain.QMedia.media; @RequiredArgsConstructor @@ -99,6 +102,38 @@ public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUpload return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); } + @Override + public Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord) { + BooleanExpression condition = media.mediaType.in(List.of(MediaType.SERIES, MediaType.CONTENTS)) + .and( + JPAExpressions.selectOne() + .from(contents) + .where( + contents.media.id.eq(media.id), + contents.series.isNotNull() + ) + .notExists() + ); + + List mediaList = queryFactory + .selectFrom(media) + .where( + condition, + titleContains(searchWord) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where(condition, titleContains(searchWord)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) return media.title.contains(searchWord); diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java index d361b5c..496cdf1 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface SeriesRepositoryCustom { @@ -11,4 +12,6 @@ public interface SeriesRepositoryCustom { Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord); Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java index f7402d4..8ca2f06 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -54,6 +54,14 @@ public Page findSeriesListWithMediaBySearchWord(Pageable pageable, Strin return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); } + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(series) + .where(series.media.id.in(mediaIdList)) + .fetch(); + } + private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) return media.title.contains(searchWord); From cbc48127f6c06d5d5df6ed27b1b0c5eeeace87cd Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:40:04 +0900 Subject: [PATCH 065/257] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=B0=98=EC=98=81=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/series/controller/SeriesApi.java | 1 - .../series/controller/SeriesController.java | 4 ---- .../series/service/SeriesService.java | 5 ++-- .../repository/CategoryRepository.java | 24 +++++++++---------- .../repository/PlaybackRepository.java | 3 ++- .../domain/tag/repository/TagRepository.java | 7 +++--- 6 files changed, 20 insertions(+), 24 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index 52b47e7..e7dc2b4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -17,7 +17,6 @@ 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 jakarta.websocket.server.PathParam; @Tag(name = "Series API", description = "시리즈 관련 API입니다.") public interface SeriesApi { diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java index c627173..0f77fff 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -1,9 +1,7 @@ package com.ott.api_user.series.controller; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.method.P; import org.springframework.security.core.annotation.AuthenticationPrincipal; -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; @@ -13,8 +11,6 @@ import com.ott.api_user.series.service.SeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; - -import jakarta.websocket.server.PathParam; import lombok.RequiredArgsConstructor; @RestController diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index 2eb240c..226e1b2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -1,7 +1,6 @@ package com.ott.api_user.series.service; import java.util.List; -import java.util.Locale.Category; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -49,8 +48,8 @@ public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { Long mediaId = series.getMedia().getId(); - List tags = tagRepository.findTagNamesByMediaId(mediaId); - List categories = categoryRepository.findCategoryNamesByMediaId(mediaId); + List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); + List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index 777ab4f..0da6e23 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -5,20 +5,20 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; - import com.ott.domain.category.domain.Category; +import com.ott.domain.common.Status; public interface CategoryRepository extends JpaRepository { - @Query(""" - SELECT DISTINCT c.name - FROM MediaTag mt - JOIN mt.tag t - JOIN t.category c - WHERE mt.media.id = :mediaId - AND mt.status = 'ACTIVE' - AND t.status = 'ACTIVE' - AND c.status = 'ACTIVE' - """) - List findCategoryNamesByMediaId(@Param("mediaId") Long mediaId); + @Query(""" + SELECT DISTINCT c.name + FROM MediaTag mt + JOIN mt.tag t + JOIN t.category c + WHERE mt.media.id = :mediaId + AND mt.status = :status + + AND t.status = :status + + AND c.status = :status + """) + List findCategoryNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java index e127a6d..ec7d982 100644 --- a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -8,5 +8,6 @@ import com.ott.domain.playback.domain.Playback; public interface PlaybackRepository extends JpaRepository { - Optional findByMemberIdAndContentsIdAndStatus(Long memberId, Long contentsId, Status status); + // 가장 최신으로 하나만 가져오기 + Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, Long contentsId, Status status); } diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index d0ed567..4033293 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.ott.domain.common.Status; import com.ott.domain.tag.domain.Tag; public interface TagRepository extends JpaRepository { @@ -16,8 +17,8 @@ public interface TagRepository extends JpaRepository { FROM MediaTag mt JOIN mt.tag t WHERE mt.media.id = :mediaId - AND t.status = 'ACTIVE' - AND mt.status = 'ACTIVE' + AND t.status = :status + AND mt.status = :status """) - List findTagNamesByMediaId(@Param("mediaId") Long mediaId); + List findTagNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); } \ No newline at end of file From 544c0ad964b1c58a8a43d74402907935cf3a2e7c Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:46:38 +0900 Subject: [PATCH 066/257] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=B0=98=EC=98=81=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=5Fv2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 4 ++-- .../repository/CategoryRepository.java | 18 +++++++++--------- .../repository/PlaybackRepository.java | 3 ++- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index e27a268..a274367 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -17,8 +17,8 @@ spring: client: registration: kakao: - client-id: ${KAKAO_CLIENT_ID:} # REST API - client-secret: ${KAKAO_CLIENT_SECRET:} #Client Secret + client-id: ${KAKAO_CLIENT_ID} # REST API + client-secret: ${KAKAO_CLIENT_SECRET} #Client Secret authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-authentication-method: client_secret_post # body에 넣어서 토큰 교환 diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index 0da6e23..aab2021 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -11,14 +11,14 @@ public interface CategoryRepository extends JpaRepository { @Query(""" - SELECT DISTINCT c.name - FROM MediaTag mt - JOIN mt.tag t - JOIN t.category c - WHERE mt.media.id = :mediaId - AND mt.status = :status - + AND t.status = :status - + AND c.status = :status - """) + SELECT DISTINCT c.name + FROM MediaTag mt + JOIN mt.tag t + JOIN t.category c + WHERE mt.media.id = :mediaId + AND mt.status = :status + AND t.status = :status + AND c.status = :status + """) List findCategoryNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java index ec7d982..d5395cf 100644 --- a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -9,5 +9,6 @@ public interface PlaybackRepository extends JpaRepository { // 가장 최신으로 하나만 가져오기 - Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, Long contentsId, Status status); + // Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, + // Long contentsId, Status status); } From 1250d4c7d276ae1dc796832ef509f64e64a1d6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 13:30:17 +0900 Subject: [PATCH 067/257] =?UTF-8?q?[FIX]:=20=EC=88=8F=ED=8F=BC=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EC=BD=98=ED=85=90=EC=B8=A0=20null=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20/=20snake=5Fcase=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/dto/response/ContentsListResponse.java | 2 +- .../api_admin/shortform/dto/ShortFormListResponse.java | 2 +- .../shortform/service/BackOfficeShortFormService.java | 10 +++++++--- .../com/ott/domain/short_form/domain/ShortForm.java | 8 ++++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java index 4a5addb..f10d219 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -13,7 +13,7 @@ public record ContentsListResponse( Long mediaId, @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") - String poster_url, + String posterUrl, @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") String title, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java index 4a0362e..15a4106 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java @@ -12,7 +12,7 @@ public record ShortFormListResponse( Long mediaId, @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") - String poster_url, + String posterUrl, @Schema(type = "String", description = "숏폼 제목", example = "비밀의 숲 명장면") String title, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index f2e5a40..32a8dc5 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -137,11 +138,14 @@ public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication a String uploaderNickname = media.getUploader().getNickname(); // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 - Media originMedia = shortForm.getOriginMedia(); + Optional originMedia = shortForm.findOriginMedia(); + String originMediaTitle = null; + if (originMedia.isPresent()) + originMediaTitle = originMedia.get().getTitle(); // 3. 태그 조회 - List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMedia.getId()); + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); // 숏폼은 원본 콘텐츠의 태그를 따라가지만, 자체 태그로 생성되어 있음을 상정 - return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMedia.getTitle(), mediaTagList); + return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMediaTitle, mediaTagList); } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index e71cbac..2663489 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -20,6 +20,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Optional; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -56,7 +58,9 @@ public class ShortForm extends BaseEntity { @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; - public Media getOriginMedia() { - return series == null ? contents.getMedia() : series.getMedia(); + public Optional findOriginMedia() { + if (series != null) return Optional.of(series.getMedia()); + if (contents != null) return Optional.of(contents.getMedia()); + return Optional.empty(); } } From d62cd15b7b7aa8779ee4167fe971511ebed9bfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 13:49:53 +0900 Subject: [PATCH 068/257] =?UTF-8?q?[CHORE]:=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +++++ .../src/main/resources/application.yml | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dd117ae..3271517 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ node_modules/ package-lock.json pnpm-lock.yaml +modules/infra/src/main/resources/db/seed/ + +private_docs/ +AGENTS.md +CLAUDE.md diff --git a/apps/api-admin/src/main/resources/application.yml b/apps/api-admin/src/main/resources/application.yml index 8fb8686..09a850a 100644 --- a/apps/api-admin/src/main/resources/application.yml +++ b/apps/api-admin/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} @@ -25,3 +25,23 @@ spring: hibernate: show_sql: true format_sql: true + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + probes: + enabled: true + +# JWT 설정 +jwt: + secret: ${JWT_SECRET_BASE64} + access-token-expiry: 3200000 # 60분 -> 관리자의 경우 1시간으로 증가 + refresh-token-expiry: 1209600000 # 14일 + +springdoc: + api-docs: + version: OPENAPI_3_0 From 680c145500ff4cbb2d29ccdc9e65dd4e8cccf64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 13:50:35 +0900 Subject: [PATCH 069/257] =?UTF-8?q?[CHORE]:=20PR=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 194 ++++++++ README.md | 441 +++++++++++++++++- .../auth/controller/AdminAuthApi.java | 72 +++ .../auth/controller/AdminAuthController.java | 114 +++++ .../auth/dto/request/AdminLoginRequest.java | 22 + .../auth/dto/response/AdminLoginResponse.java | 24 + .../auth/dto/response/AdminTokenResponse.java | 14 + .../auth/service/AdminAuthService.java | 105 +++++ .../java/com/ott/api_admin/config/.gitkeep | 0 .../ott/api_admin/config/SecurityConfig.java | 58 +++ .../ott/api_admin/content/controller/.gitkeep | 0 .../controller/BackOfficeContentsApi.java | 68 +++ .../BackOfficeContentsController.java | 46 ++ .../com/ott/api_admin/content/dto/.gitkeep | 0 .../dto/response/ContentsDetailResponse.java | 57 +++ .../dto/response/ContentsListResponse.java | 27 ++ .../mapper/BackOfficeContentsMapper.java | 60 +++ .../ott/api_admin/content/service/.gitkeep | 0 .../service/BackOfficeContentsService.java | 79 ++++ .../controller/BackOfficeIngestJobApi.java | 42 ++ .../dto/response/IngestJobListResponse.java | 27 ++ .../BackOfficeMemberController.java | 6 +- .../controller/BackOfficeSeriesApi.java | 28 +- .../BackOfficeSeriesController.java | 23 +- .../dto/response/SeriesListResponse.java | 4 +- .../dto/response/SeriesTitleListResponse.java | 14 + .../series/mapper/BackOfficeSeriesMapper.java | 57 ++- .../service/BackOfficeSeriesService.java | 73 ++- .../api_admin/shortform/controller/.gitkeep | 0 .../controller/BackOfficeShortFormApi.java | 87 ++++ .../BackOfficeShortFormController.java | 58 +++ .../com/ott/api_admin/shortform/dto/.gitkeep | 0 .../dto/OriginMediaTitleListResponse.java | 18 + .../dto/ShortFormDetailResponse.java | 51 ++ .../shortform/dto/ShortFormListResponse.java | 26 ++ .../mapper/BackOfficeShortFormMapper.java | 75 +++ .../ott/api_admin/shortform/service/.gitkeep | 0 .../service/BackOfficeShortFormService.java | 151 ++++++ apps/api-user/Dockerfile | 9 +- apps/api-user/build.gradle | 15 + .../com/ott/api_user/auth/controller/.gitkeep | 0 .../ott/api_user/auth/controller/AuthApi.java | 45 ++ .../auth/controller/AuthController.java | 135 ++++++ .../java/com/ott/api_user/auth/dto/.gitkeep | 0 .../ott/api_user/auth/dto/TokenResponse.java | 11 + .../auth/oauth2/CustomOAuth2UserService.java | 60 +++ .../oauth2/handler/OAuth2FailureHandler.java | 35 ++ .../oauth2/handler/OAuth2SuccessHandler.java | 99 ++++ .../auth/oauth2/userinfo/KakaoUserInfo.java | 24 + .../auth/oauth2/userinfo/UserInfo.java | 5 + .../com/ott/api_user/auth/service/.gitkeep | 0 .../api_user/auth/service/AuthService.java | 76 +++ .../auth/service/KakaoAuthService.java | 63 +++ .../auth/service/SocialAuthService.java | 5 + .../java/com/ott/api_user/config/.gitkeep | 0 .../ott/api_user/config/SecurityConfig.java | 111 +++++ .../search/controller/SearchController.java | 58 +-- .../search/service/SearchService.java | 170 +++---- .../src/main/resources/application.yml | 41 +- docker-compose.yml | 39 +- modules/common-security/build.gradle | 10 +- .../filter/JwtAuthenticationFilter.java | 91 ++++ .../handler/JwtAccessDeniedHandler.java | 41 ++ .../handler/JwtAuthenticationEntryPoint.java | 45 ++ .../java/com/ott/common/security/jwt/.gitkeep | 0 .../common/security/jwt/JwtTokenProvider.java | 101 ++++ .../com/ott/common/security/oauth/.gitkeep | 0 .../ott/common/web/config/WebMvcConfig.java | 4 +- .../ott/domain/bookmark/domain/Bookmark.java | 14 +- .../{TargetType.java => MediaType.java} | 8 +- .../ott/domain/contents/domain/Contents.java | 34 +- .../repository/ContentsRepository.java | 25 +- .../repository/ContentsRepositoryCustom.java | 13 + .../repository/ContentsRepositoryImpl.java | 43 ++ .../contents_tag/domain/ContentsTag.java | 39 -- .../domain/ingest_job/domain/IngestJob.java | 11 +- .../com/ott/domain/likes/domain/Likes.java | 14 +- .../com/ott/domain/media/domain/Media.java | 65 +++ .../media/repository/MediaRepository.java | 7 + .../repository/MediaRepositoryCustom.java | 18 + .../media/repository/MediaRepositoryImpl.java | 160 +++++++ .../domain/MediaTag.java} | 12 +- .../repository/MediaTagRepository.java | 7 + .../repository/MediaTagRepositoryCustom.java | 12 + .../repository/MediaTagRepositoryImpl.java | 37 ++ .../com/ott/domain/member/domain/Member.java | 23 + .../member/repository/MemberRepository.java | 9 + .../repository/PreferredTagRepository.java | 8 + .../com/ott/domain/series/domain/Series.java | 35 +- .../series/repository/SeriesRepository.java | 16 +- .../repository/SeriesRepositoryCustom.java | 9 +- .../repository/SeriesRepositoryImpl.java | 29 +- .../repository/SeriesTagRepository.java | 17 - .../domain/short_form/domain/ShortForm.java | 36 +- .../repository/ShortFormRepository.java | 7 + .../repository/ShortFormRepositoryCustom.java | 10 + .../repository/ShortFormRepositoryImpl.java | 39 ++ .../migration/V2__media_table_inheritance.sql | 195 ++++++++ 98 files changed, 3972 insertions(+), 394 deletions(-) create mode 100644 .github/workflows/deploy-ec2-docker.yml create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java create mode 100644 modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java create mode 100644 modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java create mode 100644 modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java delete mode 100644 modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep create mode 100644 modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java delete mode 100644 modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep rename modules/domain/src/main/java/com/ott/domain/common/{TargetType.java => MediaType.java} (65%) create mode 100644 modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java delete mode 100644 modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/domain/Media.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java rename modules/domain/src/main/java/com/ott/domain/{series_tag/domain/SeriesTag.java => media_tag/domain/MediaTag.java} (78%) create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java create mode 100644 modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java delete mode 100644 modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java create mode 100644 modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml new file mode 100644 index 0000000..c6a5a0f --- /dev/null +++ b/.github/workflows/deploy-ec2-docker.yml @@ -0,0 +1,194 @@ +name: Deploy Docker Apps To EC2 + +on: + workflow_dispatch: + inputs: + image_tag: + description: "Docker image tag to deploy (default: commit SHA)" + required: false + type: string + pull_request: + types: + - closed + +env: + AWS_REGION: ap-northeast-2 + +jobs: + build-and-push: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: api-user + ecr_repo: oplust-api-user + - service: api-admin + ecr_repo: oplust-api-admin + - service: transcoder + ecr_repo: oplust-transcoder + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Ensure ECR repository exists + run: | + aws ecr describe-repositories --repository-names "${{ matrix.ecr_repo }}" >/dev/null 2>&1 || \ + aws ecr create-repository --repository-name "${{ matrix.ecr_repo }}" >/dev/null + + - name: Build and push image + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + run: | + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + IMAGE_URI="${ECR_REGISTRY}/${{ matrix.ecr_repo }}:${IMAGE_TAG}" + IMAGE_URI_LATEST="${ECR_REGISTRY}/${{ matrix.ecr_repo }}:latest" + + docker build \ + -f "apps/${{ matrix.service }}/Dockerfile" \ + -t "${IMAGE_URI}" \ + -t "${IMAGE_URI_LATEST}" \ + . + + docker push "${IMAGE_URI}" + docker push "${IMAGE_URI_LATEST}" + + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop') }} + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to EC2 instances via SSM + env: + ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} + PROJECT_NAME: oplust + DB_NAME: oplust + RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + API_USER_ENV: ${{ secrets.API_USER_ENV }} + API_ADMIN_ENV: ${{ secrets.API_ADMIN_ENV }} + TRANSCODER_ENV: ${{ secrets.TRANSCODER_ENV }} + run: | + set -euo pipefail + + IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" + + if [ -z "${RDS_ENDPOINT}" ] || [ -z "${DB_USERNAME}" ] || [ -z "${DB_PASSWORD}" ]; then + echo "RDS_ENDPOINT, DB_USERNAME, DB_PASSWORD secrets are required" >&2 + exit 1 + fi + + deploy_service() { + local target_tag="$1" + local image_uri="$2" + local container_name="$3" + local env_file="$4" + local port="$5" + local env_payload="$6" + + local instance_id + instance_id=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${target_tag}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$instance_id" ] || [ "$instance_id" = "None" ]; then + echo "No running instance found for tag: ${target_tag}" >&2 + exit 1 + fi + + local full_env_payload + full_env_payload=$(printf 'SPRING_DATASOURCE_URL=jdbc:mysql://%s:3306/%s\nSPRING_DATASOURCE_USERNAME=%s\nSPRING_DATASOURCE_PASSWORD=%s\n%s' "${RDS_ENDPOINT}" "${DB_NAME}" "${DB_USERNAME}" "${DB_PASSWORD}" "${env_payload}") + + local env_payload_b64 + env_payload_b64="$(printf '%s' "$full_env_payload" | base64 -w0)" + + local run_cmd + if [ -n "$port" ]; then + run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped -p ${port}:${port} --env-file ${env_file} ${image_uri}" + else + run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped --env-file ${env_file} ${image_uri}" + fi + + local cmd_id + cmd_id=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$instance_id" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy ${container_name}:${IMAGE_TAG}" \ + --parameters commands="[ + \"set -e\", + \"sudo mkdir -p /etc/oplust\", + \"echo '${env_payload_b64}' | base64 -d | sudo tee ${env_file} >/dev/null\", + \"sudo chmod 600 ${env_file}\", + \"aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY\", + \"sudo docker pull ${image_uri}\", + \"sudo docker rm -f ${container_name} || true\", + \"${run_cmd}\" + ]" \ + --query 'Command.CommandId' \ + --output text) + + echo "[$container_name] command id: $cmd_id (instance: $instance_id)" + + local status + for _ in $(seq 1 120); do + status=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$cmd_id" \ + --instance-id "$instance_id" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$status" in + Success) + echo "[$container_name] deployment success" + return 0 + ;; + Failed|Cancelled|TimedOut) + echo "[$container_name] deployment failed with status: $status" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$cmd_id" --instance-id "$instance_id" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + echo "[$container_name] unexpected status: $status" >&2 + sleep 5 + ;; + esac + done + + echo "[$container_name] deployment timed out waiting for SSM command completion" >&2 + aws ssm get-command-invocation --region "$AWS_REGION" --command-id "$cmd_id" --instance-id "$instance_id" --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' --output json || true + exit 1 + } + + deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "${API_USER_ENV}" + deploy_service "${PROJECT_NAME}-admin-ec2" "${ECR_REGISTRY}/oplust-api-admin:${IMAGE_TAG}" "oplust-api-admin" "/etc/oplust/api-admin.env" "8081" "${API_ADMIN_ENV}" + deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "${TRANSCODER_ENV}" diff --git a/README.md b/README.md index 1650d7c..6a7c400 100644 --- a/README.md +++ b/README.md @@ -1 +1,440 @@ -# backend \ No newline at end of file +## 📌 1. Project Overview +**O+T(오쁠티)** 는 단순 알고리즘 추천의 한계를 보완하고 사용자의 콘텐츠 탐색 피로도를 낮추기 위해 기획된 숏폼/롱폼 연계 OTT 플랫폼입니다. +본 레포지토리는 서비스의 백엔드 API 서버 및 비동기 영상 트랜스코딩 시스템을 포함하고 있습니다. + +핵심 비즈니스 로직은 **에디터/관리자 기반의 숏폼 업로드**와 **숏폼에서 본편(롱폼)으로의 즉각적인 전환(CTA)** 을 지원하는데 맞춰져 있습니다. +기술적으로는 대용량 영상 처리로 인한 API 서버 부하를 방지하고 HLS 기반의 적응형 스트리밍(ABR) 을 안정적으로 제공하는 인프라 및 소프트웨어 아키텍처 설계에 집중했습니다. + +
+ +## 🛠️ 2. 기술 스택 (Tech Stack) +1. **언어 및 프레임워크:** Java, Spring Boot, Spring Data JPA, QueryDSL + +2. **데이터베이스:** MySQL 8.0, Flyway + +3. **로깅 및 모니터링:** Prometheus, Grafana, Loki + +4. **인프라:** AWS (EC2, RDS, S3, Lambda, ALB, VPC Endpoint) + +5. **메시지 큐:** AWS SQS (or RabbitMQ) + +6. **CI/CD 및 기타:** GitHub Actions, Docker, FFmpeg (Media Processing) + +
+ +## 3. 시스템 및 인프라 아키텍처 +### 3.1 🏗️ 전체 인프라 아키텍처 (System Architecture) + +```mermaid +flowchart LR + %% ================= Clients ================= + user["일반 사용자
(Client)"] + admin["관리자
(Client)"] + + %% ================= VPC ================= + subgraph vpc["VPC 10.0.0.0/20
Region: ap-northeast-2"] + direction LR + + %% Public + subgraph public["Public Subnets x2
(ALB 전용)"] + alb["ALB :80
- default → user-api (8080)
- /admin/* → admin-api (8081)"] + end + + %% Private App + subgraph private_app["Private App Subnet x1
(EC2 3대)"] + user_api["EC2 user-api
:8080
일반 기능 / 조회 API"] + admin_api["EC2 admin-api
:8081
Presigned URL 발급
(업로드 전용 관리)"] + worker["EC2 worker
SQS Consumer
Transcoding Server"] + end + + %% Private DB + subgraph private_db["Private DB Subnets x2
(RDS Subnet Group)"] + rds["RDS MySQL 8.0
db.t3.micro
Private / No Public Access"] + end + + %% VPC Endpoints + subgraph endpoints["VPC Endpoints (No NAT)"] + s3_ep["Gateway Endpoint
S3"] + sqs_ep["Interface Endpoint
SQS"] + ssm_ep["Interface Endpoint
SSM"] + ec2msg_ep["Interface Endpoint
EC2Messages"] + ssmm_ep["Interface Endpoint
SSMMessages"] + end + end + + %% ================= AWS Managed Services ================= + subgraph aws["AWS Managed Services
(Outside VPC)"] + s3_content["S3 Content Bucket
${project}-content-${random}
원본 & 트랜스코딩 저장"] + s3_deploy["S3 Deploy Bucket
${project}-deploy-${random}
배포 아티팩트"] + lambda["Lambda (python3.12)
s3_to_sqs
ObjectCreated Trigger"] + sqs["SQS transcode_queue
(Standard Queue)"] + dlq["SQS transcode_dlq
maxReceiveCount = 5"] + end + + %% ================= API Routing ================= + user -->|"일반 API 요청"| alb + admin -->|"관리자 API 요청"| alb + + alb -->|"default"| user_api + alb -->|"/admin/*"| admin_api + + %% ================= Database ================= + user_api -->|"조회/메타 데이터"| rds + admin_api -->|"업로드 메타 관리"| rds + worker -->|"상태 업데이트"| rds + + %% ================= Presigned Upload (핵심 구조) ================= + admin_api -. "Presigned PUT URL 발급
(S3 업로드용)" .-> admin + admin -. "직접 업로드 (PUT)
contents/{id}/origin/{file}.mp4" .-> s3_content + + %% ================= Event Driven Pipeline ================= + s3_content -->|"ObjectCreated (.mp4)"| lambda + lambda -->|"SendMessage
{bucket, key, videoId}"| sqs + sqs --> dlq + + %% ================= Worker Data Flow ================= + worker -->|"Poll 메시지"| sqs_ep + sqs_ep --> sqs + + worker -->|"원본 다운로드 / 결과 업로드"| s3_ep + s3_ep --> s3_content +``` + +위 다이어그램은 O+T 서비스의 핵심 인프라 구성도로, 네트워크 보안 강화, 비용 최적화, 미디어 처리의 비동기화에 초점을 맞추어 설계되었습니다. + +#### 1. 네트워크 격리 및 보안(VPC & Subnet) +- 외부의 모든 클라이언트 트래픽은 Public Subnet에 위치한 ALB(Application Load Balancer) 1곳을 통해서만 인입됩니다. + +- 실제 비즈니스 로직이 실행되는 3대의 EC2(User API, Admin API, Transcoder Worker)와 데이터가 저장되는 RDS MySQL은 모두 Private Subnet에 완벽히 격리하여 외부 인터넷으로부터의 직접적인 접근을 원천 차단했습니다. + +#### 2. 도메인별 트래픽 라우팅 분리 +- ALB의 경로 기반 라우팅(Path-based Routing) 규칙을 적용하여 물리적인 서버 인스턴스를 분리했습니다. + +- 에디터 전용 업로드 및 관리자 요청(/admin/*)은 Admin API 인스턴스(8081 포트)로, 검색/피드 조회/스트리밍 등 트래픽이 집중되는 일반 대고객 요청은 User API 인스턴스(8080 포트)로 전달하여 도메인 간 간섭을 최소화했습니다. + +#### 3. No-NAT 기반 프라이빗 통신(VPC Endpoints) +- Private Subnet 내부의 서버가 외부 AWS Managed Service(S3, SQS 등)와 통신하기 위해 필수적인 NAT Gateway를 과감히 제거했습니다. (월 고정 비용 절감) + +- 대신 AWS 내부망 전용선인 VPC Endpoints를 구축했습니다. 대용량 영상의 다운로드/업로드는 무료인 S3 Gateway Endpoint를 거치며, 작업 대기열 확인은 SQS Interface Endpoint를 통해 퍼블릭 인터넷망 노출 없이 안전하고 빠르게 처리됩니다. + +#### 4. 서버리스 이벤트 브릿지(Event-Driven Pipeline) +- Admin API가 S3 Presigned URL을 발급하면, 클라이언트는 서버를 거치지 않고 S3 버킷으로 원본 영상을 직행시킵니다. + +- 영상이 S3에 도착하면 발생하는 ObjectCreated 이벤트를 AWS Lambda가 즉시 낚아채어, 메타데이터와 함께 **SQS(Standard Queue)**로 트랜스코딩 작업 메시지를 밀어 넣습니다. + + +#### 5. 보안 접속 및 CI/CD 배포 자동화(AWS SSM) +- 보안 위협이 될 수 있는 외부 SSH 포트(22) 개방이나 별도의 Bastion Host(점프 서버) 구축을 배제했습니다. + +- SSM Interface Endpoint를 통해 AWS Systems Manager(Session Manager, Run Command)로 Private EC2에 안전하게 접속하며, GitHub Actions와 연동하여 무중단 자동 배포 파이프라인을 구동합니다. + + + +### 3.2 📁 소프트웨어 아키텍처 (Multi-Module Monorepo) +영상 트랜스코딩(FFmpeg)은 CPU 자원을 극도로 소모하는 작업입니다. 단일 모놀리식 구조에서 API 요청 처리와 인코딩 작업을 병행할 경우, 인코딩 부하가 일반 사용자 API의 응답 지연 및 장애로 전파될 위험이 있습니다. +이를 방지하고 개발 효율성을 높이기 위해 멀티 모듈 모노레포 및 레이어드 아키텍처를 채택했습니다. + +- **배포 단위 분리 (apps/):** + - api-user: 일반 사용자의 콘텐츠 검색, 재생, 통계 조회를 전담하는 API 서버. + + - api-admin: 관리자 및 에디터의 메타데이터 관리, 영상 업로드(Presigned URL 발급)를 전담하는 백오피스 서버. + + - transcoder: 외부 요청을 직접 받지 않고, SQS 메시지를 폴링하여 비동기로 영상을 변환하는 워커(Worker) 서버. + +- **공통 모듈 분리 (modules/):** + - 각 서버에서 공통으로 사용하는 도메인(Entity, Repository), 인프라 연동(S3, SQS 설정), 웹 공통(예외 처리, 응답 DTO), 보안(JWT, OAuth) 로직을 분리하여 코드 중복을 제거했습니다. + +``` +repo-root/ +├── apps/ ← 실제 배포 단위 (각각 독립 JAR) +│ ├── api-admin/ ← 관리자/에디터 API 서버 +│ ├── api-user/ ← 사용자 API 서버 +│ └── transcoder/ ← 트랜스코딩 워커 +│ +├── modules/ ← 공유 모듈 (단독 실행 불가, 앱에서 의존) +│ ├── domain/ ← 전체 Entity + Repository (JPA) +│ ├── infra/ ← JPA 설정 + S3 설정 +│ ├── common-web/ ← 예외처리, 응답 포맷 +│ └── common-security/ ← JWT, OAuth +│ +├── settings.gradle +└── docker-compose.yml + + +---------------------------------------- + + +repo-root/ +├── apps/ +│ ├── api-admin/ # 백오피스 서버 (JAR) +│ │ └── src/main/java/com/ott/admin/ +│ │ ├── content/ +│ │ │ ├── controller/ +│ │ │ ├── service/ +│ │ │ └── dto/ +│ ├── api-user/ # 사용자 API 서버 (JAR) +│ │ └── src/main/java/com/ott/user/ +│ │ ├── auth/ +│ │ │ ├── controller/ +│ │ │ ├── service/ +│ │ │ └── dto/ +│ │ ├── content/ +│ │ │ ├── controller/ +│ │ │ ├── service/ +│ │ │ └── dto/ +│ │ └── config/ +│ │ +│ └── transcoder/ # 트랜스코딩 워커 (JAR) +│ └── src/main/java/com/ott/transcode/ +│ ├── worker/ +│ ├── service/ +│ └── config/ +│ +├── modules/ +│ ├── domain/ # 전체 도메인 (Entity + Repository) +│ │ └── src/main/java/com/ott/domain/ +│ │ ├── content/ +│ │ │ ├── entity/ +│ │ │ └── repository/ +│ │ └── series/ +│ │ ├── entity/ +│ │ └── repository/ +│ │ +│ ├── infra/ # DB + S3 설정 +│ │ └── src/main/java/com/ott/infra/ +│ │ ├── db/ +│ │ │ ├── config/ +│ │ └── s3/ +│ │ ├── config/ +│ │ └── S3FileService.java +│ │ +│ ├── common-web/ # 웹 공통 +│ │ └── src/main/java/com/ott/common/web/ +│ │ ├── exception/ +│ │ └── response/ +│ │ +│ └── common-security/ # 인증/인가 공통 +│ └── src/main/java/com/ott/common/security/ +│ ├── jwt/ +│ └── oauth/ +│ +├── docker-compose.yml +├── settings.gradle +└── build.gradle +``` + +
+ +## 4. 핵심 기술 및 비즈니스 로직 +### 4.1 업로드 및 트랜스코딩 프로세스 (Event-Driven Ingest) +대용량 영상 파일 업로드 시 API 서버의 I/O 병목을 방지하기 위해 다이렉트 업로드 및 비동기 큐잉 방식을 적용했습니다. + +image + + +1. 업로드 URL 발급 요청: 에디터/관리자가 API 서버(api-admin)에 업로드용 Pre-signed URL을 요청합니다. + +2. Pre-signed URL 발급: API 서버가 S3용 Pre-signed URL을 생성 후 클라이언트에 반환합니다. + +3. 원본 영상 업로드: 클라이언트가 발급받은 Pre-signed URL을 사용하여 S3에 원본 영상을 직접 업로드합니다. + +4. 업로드 완료 이벤트 발행: S3 ObjectCreated 이벤트가 발생하면 EventBridge/Lambda를 거쳐 SQS 큐에 업로드 완료 이벤트(작업 메시지)가 적재됩니다. + +5. 트랜스코더 이벤트 소비: 격리된 트랜스코딩 서버(Worker)가 SQS 큐 메시지를 수신(폴링)합니다. + +6. 트랜스코딩 작업 수행: FFmpeg를 구동하여 원본 영상을 기반으로 해상도 및 비트레이트별(360p, 720p, 1080p) 인코딩을 동시 수행합니다. + +7. HLS 패키징: 스트리밍이 가능한 HLS 형식으로 패키징하여 .m3u8(Playlist) 및 .ts(Segment) 파일들을 생성합니다. + +8. 결과물 업로드: 패키징이 완료된 최종 HLS 결과물을 S3에 업로드하고 데이터베이스 상태를 업데이트합니다. + + +### 4.1 스트리밍(영상 재생) 파이프라인 (HLS & ABR) +사용자의 디바이스 및 실시간 네트워크 환경에 맞춰 최적의 화질을 끊김 없이 제공하는 ABR(Adaptive Bitrate) 재생 프로세스입니다. + +```mermaid +sequenceDiagram + autonumber + title HLS 스트리밍 재생 흐름 + + actor User as 사용자 + participant Player as 비디오 플레이어
(hls.js) + participant ABR as ABR 엔진 + participant Buffer as 버퍼 관리자 + participant CDN as CDN
(CloudFront) + participant S3 as Origin
(S3) + + %% 1. 초기화 및 Master Playlist 요청 + rect rgb(232, 245, 233) + Note over User, S3: 1. 초기화 및 Master Playlist 요청 + User->>Player: 영상 재생 클릭 + activate Player + Player->>CDN: GET /video/{id}/master.m3u8 + activate CDN + + alt 캐시 히트 + CDN-->>Player: master.m3u8 반환 + else 캐시 미스 + CDN->>S3: master.m3u8 요청 + activate S3 + S3-->>CDN: master.m3u8 + deactivate S3 + CDN->>CDN: 캐시 저장 + CDN-->>Player: master.m3u8 반환 + end + deactivate CDN + + Player->>Player: 화질 목록 파싱
(360p, 720p, 1080p) + end + + %% 2. 초기 화질 선택 + rect rgb(227, 242, 253) + Note over User, S3: 2. 초기 화질 선택 + Player->>ABR: 초기 화질 결정 요청 + activate ABR + ABR->>ABR: 네트워크 대역폭 측정
(3 Mbps) + ABR->>ABR: 안전 마진 적용
(3 × 0.8 = 2.4 Mbps) + ABR-->>Player: 720p 선택
(BANDWIDTH=2500000) + deactivate ABR + end + + %% 3. Media Playlist 요청 + rect rgb(255, 243, 224) + Note over User, S3: 3. Media Playlist 요청 + Player->>CDN: GET /video/{id}/720p/playlist.m3u8 + activate CDN + CDN-->>Player: 720p playlist.m3u8 + deactivate CDN + Player->>Player: 세그먼트 목록 파싱
(segment_000.ts ~ segment_00N.ts) + end + + %% 4. 세그먼트 순차 요청 및 재생 + rect rgb(232, 245, 233) + Note over User, S3: 4. 세그먼트 순차 요청 및 재생 + loop 세그먼트 다운로드 (정상 상태) + Player->>CDN: GET /video/{id}/720p/segment_000.ts + activate CDN + CDN-->>Player: segment_000.ts (10초 분량) + deactivate CDN + + Player->>Buffer: 세그먼트 추가 + activate Buffer + Buffer->>Buffer: 디코딩 & 버퍼링 + Buffer-->>Player: 버퍼 상태 (25초) + deactivate Buffer + + Player->>ABR: 다운로드 통계 전달
(속도, 시간) + ABR->>ABR: 대역폭 업데이트 + end + Player->>User: ▶️ 재생 시작 + end + + %% 5. 네트워크 상태 변화 감지 + rect rgb(255, 235, 238) + Note over User, S3: 5. 네트워크 상태 변화 감지 + Note over CDN: 네트워크 대역폭 저하
3 Mbps → 1 Mbps + + Player->>CDN: GET /video/{id}/720p/segment_003.ts + activate CDN + CDN-->>Player: segment_003.ts
(다운로드 지연 발생) + deactivate CDN + + Player->>ABR: 다운로드 통계 전달
(속도 저하 감지) + activate ABR + ABR->>ABR: 대역폭 재측정
(1 Mbps) + ABR->>Buffer: 버퍼 상태 확인 + Buffer-->>ABR: 현재 버퍼: 15초 + ABR->>ABR: 화질 전환 결정
(1 × 0.8 = 0.8 Mbps) + ABR-->>Player: 360p로 전환 지시
(BANDWIDTH=800000) + deactivate ABR + end + + %% 6. 화질 전환 (ABR) + rect rgb(252, 228, 236) + Note over User, S3: 6. 화질 전환 (ABR) + Player->>CDN: GET /video/{id}/360p/playlist.m3u8 + activate CDN + CDN-->>Player: 360p playlist.m3u8 + deactivate CDN + + Player->>Player: 현재 재생 위치 확인
(segment_004부터 필요) + + Player->>CDN: GET /video/{id}/360p/segment_004.ts + activate CDN + CDN-->>Player: segment_004.ts (360p) + deactivate CDN + + Player->>Buffer: 360p 세그먼트 추가 + Buffer->>Buffer: 끊김 없이 이어서 재생
(Seamless Switching) + + Note over Player, Buffer: 720p segment_003 → 360p segment_004
화질은 낮아지지만 버퍼링 없음 + end + + %% 7. 네트워크 복구 시 + rect rgb(232, 245, 233) + Note over User, S3: 7. 네트워크 복구 시 + Note over CDN: 네트워크 대역폭 복구
1 Mbps → 4 Mbps + + loop 세그먼트 다운로드 (복구 후) + Player->>CDN: GET /video/{id}/360p/segment_005.ts + CDN-->>Player: segment_005.ts (빠른 다운로드) + Player->>ABR: 다운로드 통계 전달 + ABR->>ABR: 대역폭 재측정 (4 Mbps) + ABR->>Buffer: 버퍼 상태 확인 + Buffer-->>ABR: 현재 버퍼: 30초 + end + + ABR->>ABR: 화질 상향 결정
(버퍼 충분 + 대역폭 여유) + ABR-->>Player: 720p로 복귀 지시 + + Player->>CDN: GET /video/{id}/720p/playlist.m3u8 + CDN-->>Player: 720p playlist.m3u8 + + Player->>CDN: GET /video/{id}/720p/segment_006.ts + CDN-->>Player: segment_006.ts (720p) + + Note over Player: 다시 720p로 화질 복귀 + deactivate Player + end + +``` + + +### 🎥 패키징 결과물 (디렉토리 구조) +FFmpeg를 통해 인코딩 및 HLS 패키징이 완료된 영상 데이터는 다음과 같은 구조로 S3 버킷에 적재됩니다. +``` +입력 (원본) 출력 (HLS) +─────────────────────────────────────────────────────────────── + +interview.mp4 → transcoded/{videoId}/ +├── H.264 또는 기타 코덱 ├── master.m3u8 +├── 1080p ├── 360p/ +├── 10 Mbps │ ├── playlist.m3u8 +└── 5분 단일 파일 │ ├── segment_000.ts (1MB) + │ ├── segment_001.ts + │ └── ... + ├── 720p/ + │ ├── playlist.m3u8 + │ ├── segment_000.ts (3MB) + │ └── ... + └── 1080p/ + ├── playlist.m3u8 + ├── segment_000.ts (6MB) + └── ... +``` + + +
+ +## [Next Step / 향후 계획] +1차 MVP 구현 이후, 운영 안정성을 극대화하기 위해 다음과 같은 고도화를 계획하고 있습니다. + +- **모니터링 강화:** Prometheus와 Grafana를 연동하여 트랜스코딩 워커의 CPU 임계치 초과 및 ABR 대역폭 전환 통계를 시각화. + +- **DR (재해 복구):** S3 Cross-Region Replication(교차 리전 복제)을 활용한 최소한의 영상 데이터 백업 아키텍처 구상. + +- **Redis 도입 (캐싱 및 DB 쓰기 부하 분산):** 10초 단위의 이어보기 위치 갱신 데이터를 인메모리로 처리 후 DB에 일괄 저장(Write-Behind)하여 쓰기 부하를 방지하고, 실시간 인기 차트 등 조회 빈도가 높은 피드를 캐싱하여 응답 속도를 극대화할 계획 + +- **Kafka 도입 :** 기존 SQS 기반의 단순 대기열을 넘어, 영상 업로드 시 트랜스코딩, 영상 분석, 썸네일 추출 등 다수의 독립적인 워커(Worker)들이 이벤트를 동시에 소비(Pub/Sub)하고 처리할 수 있는 확장성 높은 이벤트 스트리밍 아키텍처를 구축할 예정 + + diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java new file mode 100644 index 0000000..686b105 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java @@ -0,0 +1,72 @@ +package com.ott.api_admin.auth.controller; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Admin Auth API", description = "관리자 인증/인가 API") +public interface AdminAuthApi { + + @Operation( + summary = "관리자 로그인", + description = "이메일/비밀번호로 로그인합니다. " + + "Access Token과 Refresh Token은 HttpOnly 쿠키로 세팅됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse( + responseCode = "401", + description = "이메일 또는 비밀번호 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "관리자 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response); + + @Operation( + summary = "토큰 재발급", + description = "refreshToken 쿠키를 사용해 Access Token과 Refresh Token을 재발급합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "재발급 성공"), + @ApiResponse( + responseCode = "401", + description = "refreshToken이 없거나 만료/유효하지 않음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + + @Operation( + summary = "로그아웃", + description = "DB의 refreshToken을 삭제하고 accessToken/refreshToken 쿠키를 제거합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "로그아웃 성공"), + @ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java new file mode 100644 index 0000000..20158d4 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -0,0 +1,114 @@ +package com.ott.api_admin.auth.controller; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.api_admin.auth.dto.response.AdminTokenResponse; +import com.ott.api_admin.auth.service.AdminAuthService; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.response.SuccessResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +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; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminAuthController implements AdminAuthApi { + + private final AdminAuthService adminAuthService; + + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + @Override + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response) { + + AdminLoginResponse loginResponse = adminAuthService.login(request); + + // 둘 다 쿠키로 + addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); + addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); + + // Body에는 memberId, role만 (토큰은 @JsonIgnore) + return SuccessResponse.of(loginResponse).asHttp(HttpStatus.OK); + } + + @Override + @PostMapping("/reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response) { + + String refreshToken = extractCookie(request, "refreshToken"); + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + AdminTokenResponse tokenResponse = adminAuthService.reissue(refreshToken); + + addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + + return ResponseEntity.noContent().build(); + } + + @Override + @PostMapping("/logout") + public ResponseEntity logout( + Authentication authentication, + HttpServletResponse response) { + + Long memberId = (Long) authentication.getPrincipal(); + adminAuthService.logout(memberId); + + deleteCookie(response, "accessToken"); + deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + private String extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true + cookie.setPath("/"); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + private void deleteCookie(HttpServletResponse response, String name) { + Cookie cookie = new Cookie(name, null); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java new file mode 100644 index 0000000..b840b35 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "관리자 로그인 요청") +public class AdminLoginRequest { + + @Email + @NotBlank + @Schema(description = "관리자 이메일", example = "admin@ott.com") + private String email; + + @NotBlank + @Schema(description = "비밀번호", example = "password123") + private String password; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java new file mode 100644 index 0000000..e0b6d70 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "관리자 로그인 응답") +public class AdminLoginResponse { + + @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 + private String accessToken; + + @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 + private String refreshToken; + + @Schema(description = "회원 ID", example = "1") + private Long memberId; + + @Schema(description = "회원 역할", example = "ADMIN") + private String role; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java new file mode 100644 index 0000000..4f20127 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * reissue 시 Service → Controller 토큰 전달용 + */ +@Getter +@AllArgsConstructor +public class AdminTokenResponse { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java new file mode 100644 index 0000000..028d96a --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/service/AdminAuthService.java @@ -0,0 +1,105 @@ +package com.ott.api_admin.auth.service; + +import com.ott.api_admin.auth.dto.request.AdminLoginRequest; +import com.ott.api_admin.auth.dto.response.AdminLoginResponse; +import com.ott.api_admin.auth.dto.response.AdminTokenResponse; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.domain.Role; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminAuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + /** + * 관리자 로그인 + * 토큰은 Controller에서 쿠키로 세팅 + */ + public AdminLoginResponse login(AdminLoginRequest request) { + // 1. 이메일 + LOCAL provider로 회원 조회 + Member member = memberRepository.findByEmailAndProvider(request.getEmail(), Provider.LOCAL) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 2. 비밀번호 검증 +// String encodedPassword = member.getPassword(); +// if (encodedPassword == null || !passwordEncoder.matches(request.getPassword(), encodedPassword)) { +// throw new BusinessException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); +// } + + + // 3. 권한 확인 (ADMIN, EDITOR만 허용) + if (member.getRole() != Role.ADMIN && member.getRole() != Role.EDITOR) { + throw new BusinessException(ErrorCode.FORBIDDEN, "관리자 권한이 없습니다."); + } + + // 4. JWT 생성 + List authorities = List.of(member.getRole().getKey()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), authorities); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), authorities); + + // 5. refresh token DB 저장 + member.updateRefreshToken(refreshToken); + + return AdminLoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .role(member.getRole().name()) + .build(); + } + + /** + * Access 발급 시 + Refresh Token 재발급 + */ + public AdminTokenResponse reissue(String refreshToken) { + // 1. refresh token 검증 + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); + if (errorCode != null) { + throw new BusinessException(errorCode); + } + + // 2. DB 토큰 일치 확인 + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!refreshToken.equals(member.getRefreshToken())) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 3. 새 토큰 발급 + List authorities = jwtTokenProvider.getAuthorities(refreshToken); + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, authorities); + String newRefreshToken = jwtTokenProvider.createRefreshToken(memberId, authorities); + + // 4. refresh token 갱신 + member.clearRefreshToken(); + member.updateRefreshToken(newRefreshToken); + + return new AdminTokenResponse(newAccessToken, newRefreshToken); + } + + /** + * 로그아웃 — DB refresh token 삭제 + */ + public void logout(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + member.clearRefreshToken(); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java new file mode 100644 index 0000000..c268785 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.ott.api_admin.config; + +import com.ott.common.security.filter.JwtAuthenticationFilter; +import com.ott.common.security.handler.JwtAccessDeniedHandler; +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> e + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/actuator/health/**", + "/actuator/info", + "/admin/login", + "/admin/reissue", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() + .anyRequest().hasAnyRole("ADMIN", "EDITOR") + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java new file mode 100644 index 0000000..e818a5d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -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>> 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> getContentsDetail( + @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable Long mediaId + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java new file mode 100644 index 0000000..093daec --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -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>> 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> getContentsDetail( + @PathVariable Long mediaId + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java new file mode 100644 index 0000000..7c2e7ef --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java @@ -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, + + @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", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") + List 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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java new file mode 100644 index 0000000..f10d219 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -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 posterUrl, + + @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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java new file mode 100644 index 0000000..3897988 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -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 mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List 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 mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java new file mode 100644 index 0000000..a5e3a46 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -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 getContents(int page, int size, String searchWord, PublicStatus publicStatus) { + Pageable pageable = PageRequest.of(page, size); + + // 미디어 중 콘텐츠 대상 페이징 + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatus(pageable, MediaType.CONTENTS, searchWord, publicStatus); + + List 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 mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMediaId); + + return backOfficeContentsMapper.toContentsDetailResponse(contents, media, uploaderNickname, seriesTitle, mediaTagList); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java new file mode 100644 index 0000000..0ddecd9 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java @@ -0,0 +1,42 @@ +package com.ott.api_admin.ingest_job.controller; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +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.RequestParam; + +@Tag(name = "BackOffice IngestJob API", description = "[백오피스] 업로드 작업 관리 API") +public interface BackOfficeIngestJobApi { + + @Operation(summary = "업로드 작업 목록 조회", description = "업로드 작업 목록을 페이징으로 조회합니다. ADMIN은 전체, EDITOR는 본인 업로드만 조회됩니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = IngestJobListResponse.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))} + ) + }) + ResponseEntity>> getIngestJobList( + @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 = "업로더 ID. EDITOR인 경우 본인 ID를 전달합니다. 미입력 시 전체 조회 (ADMIN).", required = false) @RequestParam(value = "uploaderId", required = false) Long uploaderId + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java new file mode 100644 index 0000000..2067fd3 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_admin.ingest_job.dto.response; + +import com.ott.domain.ingest_job.domain.IngestStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "업로드 작업 목록 조회 응답") +public record IngestJobListResponse( + + @Schema(type = "Long", description = "작업 ID", example = "1") + Long ingestJobId, + + @Schema(type = "String", description = "콘텐츠 제목", example = "비밀의 숲 1화") + String title, + + @Schema(type = "Integer", description = "파일 크기 (bytes)", example = "104857600") + Integer videoSize, + + @Schema(type = "String", description = "업로더 닉네임", example = "홍길동") + String uploaderName, + + @Schema(type = "String", description = "작업 상태", example = "TRANSCODING") + IngestStatus ingestStatus, + + @Schema(type = "Integer", description = "진행률 (%)", example = "0") + Integer progress +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java index 4650d6d..c455d48 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/member/controller/BackOfficeMemberController.java @@ -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>> getMemberList( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -32,7 +32,7 @@ public ResponseEntity>> getMemb } @Override - @PatchMapping("/admin/members/{memberId}/role") + @PatchMapping("/{memberId}/role") public ResponseEntity changeRole( @PathVariable("memberId") Long memberId, @Valid @RequestBody ChangeRoleRequest changeRoleRequest diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 1c4b0e5..7270949 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -2,6 +2,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -45,6 +46,31 @@ ResponseEntity>> getSeries( @Parameter(description = "제목 부분일치 검색어. 미입력 시 전체 목록을 조회합니다.", required = false) @RequestParam(value = "searchWord", required = false) String searchWord ); + @Operation(summary = "시리즈 제목 목록 조회 (콘텐츠 업로드 페이지)", description = "시리즈 목록을 페이징으로 조회합니다. - ADMIN 권한 필요.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesTitleListResponse.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>> getSeriesTitle( + @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 + ); + @Operation(summary = "시리즈 상세 조회", description = "시리즈의 상세 정보를 조회합니다. - ADMIN 권한 필요.") @ApiResponses(value = { @ApiResponse( @@ -61,6 +87,6 @@ ResponseEntity>> getSeries( ) }) ResponseEntity> getSeriesDetail( - @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable Long seriesId + @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable Long mediaId ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 30fccf5..5786f8e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -2,6 +2,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.api_admin.series.service.BackOfficeSeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -10,14 +11,14 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/back-office") +@RequestMapping("/back-office/admin/series") @RequiredArgsConstructor public class BackOfficeSeriesController implements BackOfficeSeriesApi { private final BackOfficeSeriesService backOfficeSeriesService; @Override - @GetMapping("/admin/series") + @GetMapping public ResponseEntity>> getSeries( @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, @@ -29,10 +30,22 @@ public ResponseEntity>> getSeri } @Override - @GetMapping("/admin/series/{seriesId}") - public ResponseEntity> getSeriesDetail(@PathVariable("seriesId") Long seriesId) { + @GetMapping("/titles") + public ResponseEntity>> getSeriesTitle( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeSeriesService.getSeriesTitle(page, size, searchWord)) + ); + } + + @Override + @GetMapping("/{mediaId}") + public ResponseEntity> getSeriesDetail(@PathVariable("mediaId") Long mediaId) { return ResponseEntity.ok( - SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(seriesId)) + SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(mediaId)) ); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java index 4b5b74a..444547a 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java @@ -8,8 +8,8 @@ @Schema(description = "시리즈 목록 조회 응답") public record SeriesListResponse( - @Schema(type = "Long", description = "시리즈 ID", example = "1") - Long seriesId, + @Schema(type = "Long", description = "미디어 ID (시리즈에서 참조)", example = "1") + Long mediaId, @Schema(type = "String", description = "썸네일 URL", example = "https://cdn.example.com/thumbnail.jpg") String thumbnailUrl, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java new file mode 100644 index 0000000..217639d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesTitleListResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "시리즈 제목 목록 조회 응답") +public record SeriesTitleListResponse( + + @Schema(type = "Long", description = "시리즈 ID", example = "1") + Long seriesId, + + @Schema(type = "String", description = "시리즈 제목", example = "비밀의 숲") + String title +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 11191bc..7e6506d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -2,8 +2,10 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.series.domain.Series; -import com.ott.domain.series_tag.domain.SeriesTag; import org.springframework.stereotype.Component; import java.util.List; @@ -11,49 +13,56 @@ @Component public class BackOfficeSeriesMapper { - public SeriesListResponse toSeriesListResponse(Series series, List seriesTagList) { - String categoryName = extractCategoryName(seriesTagList); - List tagNameList = extractTagNameList(seriesTagList); + public SeriesListResponse toSeriesListResponse(Media media, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); return new SeriesListResponse( - series.getId(), - series.getThumbnailUrl(), - series.getTitle(), + media.getId(), + media.getThumbnailUrl(), + media.getTitle(), categoryName, tagNameList, - series.getPublicStatus() + media.getPublicStatus() + ); + } + + public SeriesTitleListResponse toSeriesTitleList(Series series) { + return new SeriesTitleListResponse( + series.getId(), + series.getMedia().getTitle() ); } - public SeriesDetailResponse toSeriesDetailResponse(Series series, List seriesTagList) { - String categoryName = extractCategoryName(seriesTagList); - List tagNameList = extractTagNameList(seriesTagList); + public SeriesDetailResponse toSeriesDetailResponse(Series series, Media media, String uploaderName, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); return new SeriesDetailResponse( series.getId(), - series.getTitle(), - series.getDescription(), + media.getTitle(), + media.getDescription(), categoryName, tagNameList, - series.getPublicStatus(), - series.getUploader().getNickname(), - series.getBookmarkCount(), + media.getPublicStatus(), + uploaderName, + media.getBookmarkCount(), series.getActors(), - series.getPosterUrl(), - series.getThumbnailUrl() + media.getPosterUrl(), + media.getThumbnailUrl() ); } - private String extractCategoryName(List seriesTagList) { - return seriesTagList.stream() + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() .findFirst() - .map(st -> st.getTag().getCategory().getName()) + .map(mt -> mt.getTag().getCategory().getName()) .orElse(null); } - private List extractTagNameList(List seriesTagList) { - return seriesTagList.stream() - .map(st -> st.getTag().getName()) + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) .toList(); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index b808ce7..9f28a11 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,24 +1,27 @@ package com.ott.api_admin.series.service; +import com.ott.api_admin.content.dto.response.ContentsListResponse; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; -import com.ott.domain.series.repository.SeriesRepository; -import com.ott.domain.series_tag.repository.SeriesTagRepository; +import com.ott.domain.common.MediaType; +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 com.ott.common.web.response.PageInfo; import com.ott.common.web.response.PageResponse; import com.ott.domain.series.domain.Series; -import com.ott.domain.series_tag.domain.SeriesTag; +import com.ott.domain.series.repository.SeriesRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.util.Collections; import java.util.List; @@ -31,34 +34,54 @@ public class BackOfficeSeriesService { private final BackOfficeSeriesMapper backOfficeSeriesMapper; + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; private final SeriesRepository seriesRepository; - private final SeriesTagRepository seriesTagRepository; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 1. keyword 유무에 따라 분기 / 시리즈 대상 페이징 - Page seriesPage = seriesRepository.findSeriesList(pageable, searchWord); + // 1. 미디어 중 시리즈 대상 페이징 + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWord(pageable, MediaType.SERIES, searchWord); - // 2. 조회된 시리즈 ID 목록 추출 - List seriesIdList = seriesPage.getContent().stream() - .map(Series::getId) + // 2. 조회된 미디어 ID 목록 추출 + List mediaIdList = mediaPage.getContent().stream() + .map(Media::getId) .toList(); // 3. IN절로 태그 일괄 조회 - Map> tagListBySeriesId = seriesIdList.isEmpty() + Map> tagListByMediaId = mediaIdList.isEmpty() ? Collections.emptyMap() - : seriesTagRepository.findWithTagAndCategoryBySeriesIds(seriesIdList).stream() - .collect(Collectors.groupingBy(st -> st.getSeries().getId())); + : mediaTagRepository.findWithTagAndCategoryByMediaIds(mediaIdList).stream() + .collect(Collectors.groupingBy(mt -> mt.getMedia().getId())); - List responseList = seriesPage.getContent().stream() - .map(series -> backOfficeSeriesMapper.toSeriesListResponse( - series, - tagListBySeriesId.getOrDefault(series.getId(), List.of()) + List responseList = mediaPage.getContent().stream() + .map(media -> backOfficeSeriesMapper.toSeriesListResponse( + media, + tagListByMediaId.getOrDefault(media.getId(), List.of()) )) .toList(); + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public PageResponse getSeriesTitle(Integer page, Integer size, String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + // 시리즈 + 미디어 페이징 + Page seriesPage = seriesRepository.findSeriesListWithMediaBySearchWord(pageable, searchWord); + + List responseList = seriesPage.getContent().stream() + .map(backOfficeSeriesMapper::toSeriesTitleList) + .toList(); + PageInfo pageInfo = PageInfo.toPageInfo( seriesPage.getNumber(), seriesPage.getTotalPages(), @@ -68,13 +91,17 @@ public PageResponse getSeries(int page, int size, String sea } @Transactional(readOnly = true) - public SeriesDetailResponse getSeriesDetail(Long seriesId) { - Series series = seriesRepository.findById(seriesId) + public SeriesDetailResponse getSeriesDetail(Long mediaId) { + // 1. Series + Media + Uploader 한 번에 조회 + Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - List seriesTagList = seriesTagRepository - .findWithTagAndCategoryBySeriesIds(List.of(seriesId)); + Media media = series.getMedia(); + String uploaderNickname = media.getUploader().getNickname(); + + // 2. 태그 조회 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); - return backOfficeSeriesMapper.toSeriesDetailResponse(series, seriesTagList); + return backOfficeSeriesMapper.toSeriesDetailResponse(series, media, uploaderNickname, mediaTagList); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java new file mode 100644 index 0000000..b7bfa4b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -0,0 +1,87 @@ +package com.ott.api_admin.shortform.controller; + +import com.ott.api_admin.content.dto.response.ContentsDetailResponse; +import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "BackOffice Short-Form API", description = "[백오피스] 숏폼 관리 API") +public interface BackOfficeShortFormApi { + + @Operation(summary = "숏폼 목록 조회", description = "숏폼 목록을 페이징으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ShortFormListResponse.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))} + ) + }) + ResponseEntity>> getShortFormList( + @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, + Authentication authentication + ); + + @Operation(summary = "원본 콘텐츠 제목 목록 조회 (숏폼 업로드 페이지)", description = "원본 콘텐츠 목록을 페이징으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "0", description = "조회 성공 - 페이징 dataList 구성", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = OriginMediaTitleListResponse.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))} + ) + }) + ResponseEntity>> getOriginMediaTitle( + @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 + ); + + @Operation(summary = "숏폼 상세 조회", description = "숏폼 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "숏폼 상세 조회 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormDetailResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "숏폼 상세 조회 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + }) + ResponseEntity> getShortFormDetail( + @Parameter(description = "조회할 숏폼의 미디어 ID", required = true) @PathVariable Long mediaId, + Authentication authentication + ); +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java new file mode 100644 index 0000000..cf205c4 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -0,0 +1,58 @@ +package com.ott.api_admin.shortform.controller; + +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.service.BackOfficeShortFormService; +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.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/back-office/short-forms") +@RequiredArgsConstructor +public class BackOfficeShortFormController implements BackOfficeShortFormApi { + + private final BackOfficeShortFormService backOfficeShortFormService; + + @Override + @GetMapping + public ResponseEntity>> getShortFormList( + @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, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getShortFormList(page, size, searchWord, publicStatus, authentication)) + ); + } + + @Override + @GetMapping("/origin-media") + public ResponseEntity>> getOriginMediaTitle( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getOriginMediaTitle(page, size, searchWord)) + ); + } + + @Override + @GetMapping("/{mediaId}") + public ResponseEntity> getShortFormDetail( + @PathVariable Long mediaId, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeShortFormService.getShortFormDetail(mediaId, authentication)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java new file mode 100644 index 0000000..e01bb3c --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java @@ -0,0 +1,18 @@ +package com.ott.api_admin.shortform.dto; + +import com.ott.domain.common.MediaType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "원본 콘텐츠 제목 목록 조회 응답") +public record OriginMediaTitleListResponse( + + @Schema(type = "Long", description = "원본 콘텐츠 ID", example = "1") + Long originId, + + @Schema(type = "String", description = "원본 콘텐츠 제목", example = "비밀의 숲") + String title, + + @Schema(type = "String", description = "원본 콘텐츠 타입", example = "SERIES") + MediaType mediaType +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java new file mode 100644 index 0000000..1d4debc --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java @@ -0,0 +1,51 @@ +package com.ott.api_admin.shortform.dto; + +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 ShortFormDetailResponse( + + @Schema(type = "Long", description = "숏폼 ID", example = "1") + Long shortFormId, + + @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/poster.jpg") + String posterUrl, + + @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") + String title, + + @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") + String description, + + @Schema(type = "String", description = "원본 콘텐츠 제목 (시리즈 혹은 콘텐츠(단편 등))", example = "더글로리 시즌1, 태극기 휘날리며") + String originContentsTitle, + + @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", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") + List 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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java new file mode 100644 index 0000000..15a4106 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.shortform.dto; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "숏폼 목록 조회 응답") +public record ShortFormListResponse( + + @Schema(type = "Long", description = "미디어 ID", example = "1") + Long mediaId, + + @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") + String posterUrl, + + @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 +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java new file mode 100644 index 0000000..562f780 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -0,0 +1,75 @@ +package com.ott.api_admin.shortform.mapper; + +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.short_form.domain.ShortForm; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +public class BackOfficeShortFormMapper { + + public ShortFormListResponse toShortFormListResponse(Media media) { + return new ShortFormListResponse( + media.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getPublicStatus(), + media.getCreatedDate().toLocalDate() + ); + } + + public ShortFormDetailResponse toShortFormDetailResponse(ShortForm shortForm, Media media, String uploaderNickname, String originMediaTitle, List mediaTagList) { + String categoryName = extractCategoryName(mediaTagList); + List tagNameList = extractTagNameList(mediaTagList); + + return new ShortFormDetailResponse( + shortForm.getId(), + media.getPosterUrl(), + media.getTitle(), + media.getDescription(), + originMediaTitle, + uploaderNickname, + shortForm.getDuration(), + shortForm.getVideoSize(), + categoryName, + tagNameList, + media.getPublicStatus(), + media.getBookmarkCount(), + media.getCreatedDate().toLocalDate() + ); + } + + public OriginMediaTitleListResponse toOriginMediaTitleListResponse( + Media media, Map seriesIdByMediaId, Map contentsIdByMediaId + ) { + Long originId = media.getMediaType() == MediaType.SERIES + ? seriesIdByMediaId.get(media.getId()) + : contentsIdByMediaId.get(media.getId()); + + return new OriginMediaTitleListResponse( + originId, + media.getTitle(), + media.getMediaType() + ); + } + + private String extractCategoryName(List mediaTagList) { + return mediaTagList.stream() + .findFirst() + .map(mt -> mt.getTag().getCategory().getName()) + .orElse(null); + } + + private List extractTagNameList(List mediaTagList) { + return mediaTagList.stream() + .map(mt -> mt.getTag().getName()) + .toList(); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java new file mode 100644 index 0000000..32a8dc5 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -0,0 +1,151 @@ +package com.ott.api_admin.shortform.service; + +import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; +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.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 com.ott.domain.member.domain.Role; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.short_form.repository.ShortFormRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BackOfficeShortFormService { + + private final BackOfficeShortFormMapper backOfficeShortFormMapper; + + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; + private final ShortFormRepository shortFormRepository; + + @Transactional(readOnly = true) + public PageResponse getShortFormList( + Integer page, Integer size, String searchWord, PublicStatus publicStatus, Authentication authentication + ) { + Pageable pageable = PageRequest.of(page, size); + + // 1. 관리자/에디터 여부 확인 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + Long uploaderId = null; + + // 2. 에디터인 경우 본인이 업로드한 숏폼만 조회 가능 + if (isEditor) + uploaderId = memberId; + + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( + pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId + ); + + List responseList = mediaPage.getContent().stream() + .map(backOfficeShortFormMapper::toShortFormListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public PageResponse getOriginMediaTitle(Integer page, Integer size, String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + // 1. Media 페이징 조회 (Series + 단편 Contents / 에피소드 제외) + Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); + + List mediaList = mediaPage.getContent(); + + // 2. mediaId를 타입별로 분리 + List seriesMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.SERIES) + .map(Media::getId) + .toList(); + + List contentsMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.CONTENTS) + .map(Media::getId) + .toList(); + + // 3. 일괄 조회: mediaId → entityId 매핑 + Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() + .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); + + Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList).stream() + .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); + + // 4. 응답 매핑 + List responseList = mediaList.stream() + .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, contentsIdByMediaId)) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { + // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 조회 + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // 2. 에디터 - 숏폼 업로더 권한 체크 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + + Media media = shortForm.getMedia(); + if (isEditor && !media.getUploader().getId().equals(memberId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + + String uploaderNickname = media.getUploader().getNickname(); + + // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 + Optional originMedia = shortForm.findOriginMedia(); + String originMediaTitle = null; + if (originMedia.isPresent()) + originMediaTitle = originMedia.get().getTitle(); + + // 3. 태그 조회 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); // 숏폼은 원본 콘텐츠의 태그를 따라가지만, 자체 태그로 생성되어 있음을 상정 + + return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMediaTitle, mediaTagList); + } +} diff --git a/apps/api-user/Dockerfile b/apps/api-user/Dockerfile index 692ca0f..42e97cf 100644 --- a/apps/api-user/Dockerfile +++ b/apps/api-user/Dockerfile @@ -3,8 +3,13 @@ WORKDIR /app COPY . . RUN gradle :apps:api-user:bootJar --no-daemon -FROM eclipse-temurin:21-jre +FROM eclipse-temurin:21-jre-jammy WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + COPY --from=build /app/apps/api-user/build/libs/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index faf3af7..800db85 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -16,4 +16,19 @@ dependencies { implementation 'org.flywaydb:flyway-mysql' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' +} + + +/** + * Flyway가 infra 모듈의 db/migration 리소스를 확실히 인식하도록 설정 + */ +tasks.named('processResources') { + from(project(':modules:infra').sourceSets.main.resources.srcDirs) { + include 'db/migration/**' + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java new file mode 100644 index 0000000..7aef11d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java @@ -0,0 +1,45 @@ +package com.ott.api_user.auth.controller; + +import com.ott.common.web.exception.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + + +@Tag(name = "Auth API", description = "인증/인가 API") +public interface AuthApi { + + @Operation(summary = "Access Token 재발급", description = "access token + refresh token 재발급.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse( + responseCode = "401", + description = "refreshToken이 없거나 만료/유효하지 않음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + + + @Operation( + summary = "로그아웃", description = "DB refreshToken을 삭제, accessToken/refreshToken 쿠키 제거" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java new file mode 100644 index 0000000..a314540 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -0,0 +1,135 @@ +package com.ott.api_user.auth.controller; + + +import com.ott.api_user.auth.dto.TokenResponse; +import com.ott.api_user.auth.service.AuthService; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController implements AuthApi { + + private final AuthService authService; + + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + + // Access Token 재발급 + @PostMapping("reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response) { + + String refreshToken = extractCookie(request, "refreshToken"); + if (refreshToken == null) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // access + refresh 재발급 -> 보안성 측면 + TokenResponse tokenResponse = authService.reissue(refreshToken); + + // 쿠키에 저장 + addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + + return ResponseEntity.noContent().build(); + } + + /** + * 로그아웃 + * DB는 refreshToken 삭제 + * 쿠키는 Controller에서 직접 삭제 + */ + @PostMapping("/logout") + public ResponseEntity logout( + Authentication authentication, + HttpServletResponse response) { + + Long memberId = (Long) authentication.getPrincipal(); + authService.logout(memberId); + + deleteCookie(response, "accessToken"); + deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + + + // 임시 테스트 코드 -> 추후 프론트 페이지로 변경 예정 + @GetMapping("logincheck") + public ResponseEntity> logincheck( + @RequestParam(value = "isNewMember") boolean isNewMember, + HttpServletRequest request + ) { + String accessToken = extractCookie(request, "accessToken"); + String refreshToken = extractCookie(request, "refreshToken"); + + + return ResponseEntity.ok(Map.of( + "isNewMember", isNewMember, + "accessToken", accessToken, + "refreshToken", refreshToken + )); + } + + + // 인가 테스트용 코드 -> 이렇게 @AuthenticationPrincipal로 쓰시면 됩니다. + // 추후 memberId -> UserDetails로 리팩토링 예정 + @GetMapping("/me") + public Long me(@AuthenticationPrincipal Long memberId) { + return memberId; + } + + + + // 쿠키에 대한 접근은 HTTP고 서비스로 내려가면 안되기 때문에 Controller에서 구현 + private String extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) { + return null; + } + + for (Cookie cookie: request.getCookies()) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true 변경 + cookie.setPath("/"); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + private void deleteCookie(HttpServletResponse response, String name) { + Cookie cookie = new Cookie(name, null); + cookie.setHttpOnly(true); + cookie.setSecure(false); // 배포 시 true 변경 + cookie.setPath("/"); + cookie.setMaxAge(0); // 즉시 삭제 + response.addCookie(cookie); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java new file mode 100644 index 0000000..2bc7777 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package com.ott.api_user.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..5f4a890 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,60 @@ +package com.ott.api_user.auth.oauth2; + +import com.ott.api_user.auth.oauth2.userinfo.KakaoUserInfo; +import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.domain.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + /** + * 사용자가 카카오 로그인화면에서 로그인 + * 카카오 인증 서버에서 인가코드와 함께 리다이렉트 + * 스프링 시큐리티의 OAuth2LoginAuthenicationFilter가 인가코드를 token-url에 전달하여 Access token 교환 (자동 구현) + * DefaultOAuthUserService에서 기본적으로 loadUser를 호출하여 user-info-uri을 통하여 유저 객체인 oAuth2User를 생성 + */ + private final KakaoAuthService kakaoAuthService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2UserRequest -> 어떤 클라이이언트, provider가 저장됨 + + // 로그인된 객체는 로그인 정보가 없음 + // loadUser를 통해서 info-url를 통해 attributes를 채운 OAuth2User를 만듬 + OAuth2User oAuth2User = super.loadUser(userRequest); + + // 카카오 응답 객체 파싱 + KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); + + // DB 조회 + Member member = kakaoAuthService.findOrCreateMember(kakaoUserInfo); + + // 신규 회원 판별 + boolean isNewMember = kakaoAuthService.isNewMember(member.getId()); + + // attribute에 memberId(PK)와 신규 유저 유무를 적재 + // payload memberId, isNewMember만 들어감 -> 민감한 정보 적재 x + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + attributes.put("memberId", member.getId()); + attributes.put("isNewMember", isNewMember); + + // 스프링 시큐리티에 넘길 객체 반환 이때 권한은 ROLE_MEMBER임 + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority(member.getRole().getKey())), + attributes, + "id" + ); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java new file mode 100644 index 0000000..1d6b225 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2FailureHandler.java @@ -0,0 +1,35 @@ +package com.ott.api_user.auth.oauth2.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${app.frontend-url}") + private String frontedUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + log.info("OAuth2 로그인 실패: {}", exception.getMessage()); + + String targetUrl = frontedUrl + "/auth/login?error=" + + URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..a4817e7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -0,0 +1,99 @@ +package com.ott.api_user.auth.oauth2.handler; + +import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.common.security.jwt.JwtTokenProvider; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.List; + +/** + * Oauth2 성공시 해당 핸들러 자동 호출 + * 카카오 로그인 성공시 해당 핸들러에서 처리 + * JWT 생성(Access, Refresh) + * Refresh Token DB 저장 + * 콜백 URL로 리다이렉트 + 만든 토큰은 쿠키로 전달 + */ + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final KakaoAuthService kakaoAuthService; + + @Value("${app.frontend-url}") + private String frontedUrl; + + // 30분 + @Value("${jwt.access-token-expiry}") + private int accessTokenExpiry; + + // 14일 + @Value("${jwt.refresh-token-expiry}") + private int refreshTokenExpiry; + + // Oauth2 로그인 성공 시 해당 메소드를 스프링 스큐리티가 자동 호출 + // 이 시점에서 authenication에 로그인된 사용자 정보가 저장 user-info + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + // memberId, authroies, isNewMember의 결과가 map으로 저장 + OAuth2User principal = (OAuth2User) authentication.getPrincipal(); + + long memberId = ((Number) principal.getAttributes().get("memberId")).longValue(); + boolean isNewMember = (boolean) principal.getAttributes().get("isNewMember"); + + // authorties: ["ROLE_MEMBER"] + List authorties = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + + // JWT 생성 + String accessToken = jwtTokenProvider.createAccessToken(memberId, authorties); + String refreshToken = jwtTokenProvider.createRefreshToken(memberId, authorties); + + kakaoAuthService.saveRefreshToken(memberId, refreshToken); + + // 쿠키로 저장 + addCookie(response, "accessToken", accessToken, accessTokenExpiry); + addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); + + + // 리다이렉트에는 쿼리 파라미터로 isNewMember만 전달 + String redirectUri = request.getParameter("redirect_uri"); + if (redirectUri == null || redirectUri.isBlank()) { + redirectUri = frontedUrl + "/auth/logincheck"; // 배포 후 변경 예정 + } + + String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("isNewMember", isNewMember) + .build() + .toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + + } + + private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 + cookie.setSecure(false); // 배포 서버에서는 true로 변경 + cookie.setPath("/"); // 모든 경로에서 전송 + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java new file mode 100644 index 0000000..c3566f1 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/KakaoUserInfo.java @@ -0,0 +1,24 @@ +package com.ott.api_user.auth.oauth2.userinfo; + +import lombok.Getter; + +import java.util.Map; + +@Getter +public class KakaoUserInfo { + + private final String providerId; + private final String email; + private final String nickname; + + @SuppressWarnings("unchecked") + public KakaoUserInfo(Map attributes) { + this.providerId = String.valueOf(attributes.get("id")); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + this.nickname = (String) profile.get("nickname"); + this.email = (String) kakaoAccount.get("email"); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java new file mode 100644 index 0000000..e9919c0 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/userinfo/UserInfo.java @@ -0,0 +1,5 @@ +package com.ott.api_user.auth.oauth2.userinfo; + +// 다중 소셜 로그인 도입 시 전략 패턴으로 구성 예정 +public interface UserInfo { +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/auth/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java new file mode 100644 index 0000000..a00ac40 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java @@ -0,0 +1,76 @@ +package com.ott.api_user.auth.service; + +import com.ott.api_user.auth.dto.TokenResponse; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * JWT 토큰 관리하는 클래스 -> 재발급과 로그아웃 구현 + * 모든 소셜 로그인 공통으로 사용 -> 현재는 카카오만 사용 + */ +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + + /** + * Access Token 재발급 + */ + public TokenResponse reissue(String refreshToken) { + // refresh 토큰 유효성 검증 + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); + + if(errorCode != null) { + throw new BusinessException(errorCode); + } + + // DB에 저장된 토큰과 일치 여부 확인 + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + Member member = findMemberById(memberId); + + + if(!refreshToken.equals(member.getRefreshToken())) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 권한 추출 + List authorities = jwtTokenProvider.getAuthorities(refreshToken); + + // access + refresh 재발급 + String newAccessToken = jwtTokenProvider.createAccessToken(memberId, authorities); + String newRefreshToken = jwtTokenProvider.createRefreshToken(memberId, authorities); + + // refreshToken 갱신 및 이전 토큰 폐기 + member.clearRefreshToken(); + member.updateRefreshToken(newRefreshToken); + + return new TokenResponse(newAccessToken, newRefreshToken); + + } + + /** + * 로그아웃 - Refresh 토큰 삭제 + */ + public void logout(Long memberId) { + Member member = findMemberById(memberId); + member.clearRefreshToken(); + } + + + // Optipnal 처리를 위해 사용 + private Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java new file mode 100644 index 0000000..1565268 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -0,0 +1,63 @@ +package com.ott.api_user.auth.service; + +import com.ott.api_user.auth.oauth2.userinfo.KakaoUserInfo; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 카카오의 로그인 회원 로직으로 다음과 같은 비즈니스 로직을 수행함 + * 회원 조회/생성 + * 프로필 동기화 + * 신규 회원 판별 + * Refresh Token 저장 + */ +@Service +@RequiredArgsConstructor +@Transactional +public class KakaoAuthService { + + private final MemberRepository memberRepository; + private final PreferredTagRepository preferredTagRepository; + + // 카카오 사용자 정보로 회원 조회 or 신규 생성 + // 기존 회원일 경우 프로필 동기화 필요 + public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { + return memberRepository + .findByProviderAndProviderId(Provider.KAKAO, kakaoUserInfo.getProviderId()) + .map(existingMember -> { + existingMember.updateKakaoProfile( + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname() + ); + return existingMember; + }) + .orElseGet(() -> memberRepository.save( + Member.createKakaoMember( + kakaoUserInfo.getProviderId(), + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname() + ) + )); + } + + // 신규 회원 판별 -> 태그 소유 유무로 판단 + public boolean isNewMember(Long memberId) { + return !preferredTagRepository.existsByMemberId(memberId); + } + + // refresh token 저장 + public void saveRefreshToken(Long memberId, String refreshToken) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + member.updateRefreshToken(refreshToken); + } + + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java new file mode 100644 index 0000000..517d1b9 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/SocialAuthService.java @@ -0,0 +1,5 @@ +package com.ott.api_user.auth.service; + +// 추후 다중 소셜 로그인 사용 시 전략 패턴으로 변경 +public interface SocialAuthService { +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java new file mode 100644 index 0000000..8bc5552 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -0,0 +1,111 @@ +package com.ott.api_user.config; + +import com.ott.api_user.auth.oauth2.CustomOAuth2UserService; +import com.ott.api_user.auth.oauth2.handler.OAuth2FailureHandler; +import com.ott.api_user.auth.oauth2.handler.OAuth2SuccessHandler; +import com.ott.common.security.filter.JwtAuthenticationFilter; +import com.ott.common.security.handler.JwtAccessDeniedHandler; +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + // api-user 전용 OAuth2 + private final CustomOAuth2UserService CustomOAuth2UserService; // 카카오에서 받은 사용자 프로필 조회 후 DB에 적재 + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + + @Value("${app.frontend-url:http://localhost:8080}") + private String frontedUrl; + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + return http + .csrf(AbstractHttpConfigurer::disable) // csrf 비활성화, Authorization 헤더로 보냄 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + +// .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .cors(AbstractHttpConfigurer::disable) + + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증 + + .exceptionHandling(e -> e + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + + .authorizeHttpRequests(auth -> auth + // 인증 불필요 + .requestMatchers( + "/actuator/health/**", + "/actuator/info", + "/auth/**", + "/oauth2/**", + "/login/oauth2/**", + "/auth/reissue", + "/auth/logout", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() + + // 나머지 url에 대해서는 인증 필요 + .anyRequest().authenticated() + ) + + // OAuth2 카카오 로그인 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + userInfo.userService(CustomOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + + // UsernamePasswordAuthenticationFilter 보다 먼저 실행 + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // allowedOrigins -> 허용할 Origin 내역 + // allowCredentials -> 브라우저가 요청에 인증정보를 포함하는 것을 허용하겠냐 + // credentials가 true일 경우 Allow-origin의 경우 구체적인 경로를 명시해야됨 + + config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); +// config.setAllowedOrigins(List.of(frontedUrl)); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java index 177213f..f6573ba 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/search/controller/SearchController.java @@ -1,29 +1,29 @@ -package com.ott.api_user.search.controller; - -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.ott.api_user.search.service.SearchService; -import com.ott.common.web.response.PageResponse; -import com.ott.common.web.response.SuccessResponse; -import lombok.RequiredArgsConstructor; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestParam; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/search") -public class SearchController implements SearchApi { - private final SearchService searchService; - - @Override - public ResponseEntity> search( - @RequestParam String searchWord, - @RequestParam Integer page, - @RequestParam Integer size) { - PageResponse response = searchService.search(searchWord, page, size); - return ResponseEntity.ok(SuccessResponse.of(response)); - } - -} +//package com.ott.api_user.search.controller; +// +//import org.springframework.web.bind.annotation.RequestMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import com.ott.api_user.search.service.SearchService; +//import com.ott.common.web.response.PageResponse; +//import com.ott.common.web.response.SuccessResponse; +//import lombok.RequiredArgsConstructor; +// +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.RequestParam; +// +//@RestController +//@RequiredArgsConstructor +//@RequestMapping("/search") +//public class SearchController implements SearchApi { +// private final SearchService searchService; +// +// @Override +// public ResponseEntity> search( +// @RequestParam String searchWord, +// @RequestParam Integer page, +// @RequestParam Integer size) { +// PageResponse response = searchService.search(searchWord, page, size); +// return ResponseEntity.ok(SuccessResponse.of(response)); +// } +// +//} diff --git a/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java index 8e559c7..f442da8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/search/service/SearchService.java @@ -1,85 +1,85 @@ -package com.ott.api_user.search.service; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Stream; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import com.ott.api_user.search.dto.SearchItemResponse; -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.Status; -import com.ott.domain.contents.repository.ContentsRepository; -import com.ott.domain.series.domain.Series; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.series.repository.SeriesRepository; -import lombok.RequiredArgsConstructor; - -// 최신순 정렬을 위해 DB 페이징 방식 대신, -// 검색 결과를 모두 가져와서 Java Stream으로 정렬 후, 페이지네이션 처리하는 방식으로 변경 -// 추후 검색 대상이 늘어나거나 데이터 양이 많아질 경우, Querydsl 으로 검색 쿼리 최적화 필요! -@Service -@RequiredArgsConstructor -public class SearchService { - private final ContentsRepository contentsRepository; - private final SeriesRepository seriesRepository; - - public PageResponse search(String searchWord, int page, int size) { - - if (searchWord == null || searchWord.length() < 2) { - throw new BusinessException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); - } - - // 사용자가 흔한 검색어 입력 시 너무 많은 데이터를 가져올 수 있으므로 - // 일단 최대 100개까지만 가져오도록 제한 - Pageable limit = PageRequest.of(0, 100); - - // 에피소드 제외, 시리즈와 단일 콘텐츠만 검색 - List contentsList = contentsRepository.searchLatest(searchWord, Status.ACTIVE, limit); - List seriesList = seriesRepository.searchLatest(searchWord, Status.ACTIVE, limit); - - // 컨텐츠+시리즈 통합 정렬 - List allResults = Stream.concat( - contentsList.stream().map(c -> SearchItemResponse.builder() - .type("CONTENTS") - .id(c.getId()) - .title(c.getTitle()) - .posterUrl(c.getPosterUrl()) - .createdAt(c.getCreatedDate()) - .build()), - seriesList.stream().map(s -> SearchItemResponse.builder() - .type("SERIES") - .id(s.getId()) - .title(s.getTitle()) - .posterUrl(s.getPosterUrl()) - .createdAt(s.getCreatedDate()) - .build())) - .filter(item -> item.getCreatedAt() != null) - .sorted(Comparator.comparing(SearchItemResponse::getCreatedAt).reversed()) // 통합 최신순 정렬 - .toList(); - - // 페이징 계산 (직접 자르기) - int totalElements = allResults.size(); - int totalPages = (int) Math.ceil((double) totalElements / size); - - int start = Math.min(page * size, totalElements); - int end = Math.min(start + size, totalElements); - - List pagedResult = allResults.subList(start, end); - - PageInfo pageInfo = PageInfo.builder() - .currentPage(page) - .totalPage(totalPages) - .pageSize(size) - .build(); - - return PageResponse.toPageResponse(pageInfo, pagedResult); - } -} +//package com.ott.api_user.search.service; +// +//import java.util.ArrayList; +//import java.util.Comparator; +//import java.util.List; +//import java.util.stream.Stream; +// +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.PageRequest; +//import org.springframework.data.domain.Pageable; +//import org.springframework.stereotype.Service; +// +//import com.ott.api_user.search.dto.SearchItemResponse; +//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.Status; +//import com.ott.domain.contents.repository.ContentsRepository; +//import com.ott.domain.series.domain.Series; +//import com.ott.domain.contents.domain.Contents; +//import com.ott.domain.series.repository.SeriesRepository; +//import lombok.RequiredArgsConstructor; +// +//// 최신순 정렬을 위해 DB 페이징 방식 대신, +//// 검색 결과를 모두 가져와서 Java Stream으로 정렬 후, 페이지네이션 처리하는 방식으로 변경 +//// 추후 검색 대상이 늘어나거나 데이터 양이 많아질 경우, Querydsl 으로 검색 쿼리 최적화 필요! +//@Service +//@RequiredArgsConstructor +//public class SearchService { +// private final ContentsRepository contentsRepository; +// private final SeriesRepository seriesRepository; +// +// public PageResponse search(String searchWord, int page, int size) { +// +// if (searchWord == null || searchWord.length() < 2) { +// throw new BusinessException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); +// } +// +// // 사용자가 흔한 검색어 입력 시 너무 많은 데이터를 가져올 수 있으므로 +// // 일단 최대 100개까지만 가져오도록 제한 +// Pageable limit = PageRequest.of(0, 100); +// +// // 에피소드 제외, 시리즈와 단일 콘텐츠만 검색 +// List contentsList = contentsRepository.searchLatest(searchWord, Status.ACTIVE, limit); +// List seriesList = seriesRepository.searchLatest(searchWord, Status.ACTIVE, limit); +// +// // 컨텐츠+시리즈 통합 정렬 +// List allResults = Stream.concat( +// contentsList.stream().map(c -> SearchItemResponse.builder() +// .type("CONTENTS") +// .id(c.getId()) +// .title(c.getTitle()) +// .posterUrl(c.getPosterUrl()) +// .createdAt(c.getCreatedDate()) +// .build()), +// seriesList.stream().map(s -> SearchItemResponse.builder() +// .type("SERIES") +// .id(s.getId()) +// .title(s.getTitle()) +// .posterUrl(s.getPosterUrl()) +// .createdAt(s.getCreatedDate()) +// .build())) +// .filter(item -> item.getCreatedAt() != null) +// .sorted(Comparator.comparing(SearchItemResponse::getCreatedAt).reversed()) // 통합 최신순 정렬 +// .toList(); +// +// // 페이징 계산 (직접 자르기) +// int totalElements = allResults.size(); +// int totalPages = (int) Math.ceil((double) totalElements / size); +// +// int start = Math.min(page * size, totalElements); +// int end = Math.min(start + size, totalElements); +// +// List pagedResult = allResults.subList(start, end); +// +// PageInfo pageInfo = PageInfo.builder() +// .currentPage(page) +// .totalPage(totalPages) +// .pageSize(size) +// .build(); +// +// return PageResponse.toPageResponse(pageInfo, pagedResult); +// } +//} diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index 9adde2b..0190d09 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -1,13 +1,36 @@ +app: + frontend-url: ${FRONTEND_URL:http://localhost:8080} + server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} + # kakao + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} # REST API + client-secret: ${KAKAO_CLIENT_SECRET} #Client Secret + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-authentication-method: client_secret_post # body에 넣어서 토큰 교환 + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize # 로그인 화면 + token-uri: https://kauth.kakao.com/oauth/token # 받은 인가코드를 토큰으로 교환 + user-info-uri: https://kapi.kakao.com/v2/user/me # 유저 정보 가져오는 API + user-name-attribute: id # flyway 설정 flyway: enabled: true @@ -25,3 +48,19 @@ spring: hibernate: show_sql: true format_sql: true + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + probes: + enabled: true + +# JWT 설정 +jwt: + secret: ${JWT_SECRET_BASE64} + access-token-expiry: 1800000 # 30분 + refresh-token-expiry: 1209600000 # 14일 diff --git a/docker-compose.yml b/docker-compose.yml index b21894b..0018ec2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,36 +21,51 @@ services: timeout: 5s retries: 20 - # ============ 앱 ============ - api-admin: - build: # 이미지를 다운받는게 아니라 해당 경로에서 빌드 + # ============ Flyway 마이그레이션 담당 (먼저 기동) ============ + api-user: + build: context: . - dockerfile: apps/api-admin/Dockerfile - container_name: ott-api-admin + dockerfile: apps/api-user/Dockerfile + container_name: ott-api-user ports: - - "8081:8081" + - "8080:8080" environment: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} + KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + FRONTEND_URL: ${FRONTEND_URL} depends_on: mysql: condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1" ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s - api-user: - build: + + # ============ Flyway 완류 이후 빌드하는 앱 ============ + api-admin: + build: # 이미지를 다운받는게 아니라 해당 경로에서 빌드 context: . - dockerfile: apps/api-user/Dockerfile - container_name: ott-api-user + dockerfile: apps/api-admin/Dockerfile + container_name: ott-api-admin ports: - - "8080:8080" + - "8081:8081" environment: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} depends_on: mysql: condition: service_healthy + api-user: + condition: service_healthy transcoder: build: @@ -66,6 +81,8 @@ services: depends_on: mysql: condition: service_healthy + api-user: + condition: service_healthy volumes: mysql-data: diff --git a/modules/common-security/build.gradle b/modules/common-security/build.gradle index 1d63f8f..292b489 100644 --- a/modules/common-security/build.gradle +++ b/modules/common-security/build.gradle @@ -1,7 +1,15 @@ dependencies { implementation project(':modules:domain') + implementation project(':modules:common-web') + + // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' -} \ No newline at end of file +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..5c22967 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.ott.common.security.filter; + +import com.ott.common.security.handler.JwtAuthenticationEntryPoint; +import com.ott.common.security.jwt.JwtTokenProvider; +import com.ott.common.web.exception.ErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * 들어오는 요청을 가로채서 토큰을 꺼내서 provider에게 검증을 요청 + * 토큰은 보통 Authorization 헤더 or accssToken 쿠키에서 꺼냄 // 현재는 쿠키에 httpOnly로 저장중 + * provider에서 토큰을 검증하고 검증이 성공하면 Authentication객체를 생성해서 SecurityContextHolder에 authentication 저장함 + * 이후 컨트롤러에서 authentication 받아서 사용함 + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 토큰 꺼내옴 + String token = resolveToken(request); + + // 토큰을 검증하여 인증 없음, 만료됨, 유효x일 경우 에러코드를 저장, 검증 통과 시 Authentication 생성 + if(token != null) { + ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(token); + + if(errorCode == null) { + Long memberId = jwtTokenProvider.getMemberId(token); + + // auth: ["ROLE_USER"] + List authorities = jwtTokenProvider.getAuthorities(token); + + // Authentication을 만듬 -> 민감한 정보 저장 x + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberId, // principal // 추후 UserDetails로 변경할 예정 아마도 + null, // credentials + authorities.stream() // grantedAuthorities + .map(SimpleGrantedAuthority::new) + .toList() + ); + // Authenication을 SecurityContext에 넣음 + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + // 실패할 경우 해당 에러코드를 reqeust에 넣음 + request.setAttribute(JwtAuthenticationEntryPoint.ERROR_CODE, errorCode); + } + } + + filterChain.doFilter(request, response); + } + + // 토큰 빼오기 + private String resolveToken(HttpServletRequest request) { + //Authorization 헤더에서 토큰 빼오기 시도 + String bearer = request.getHeader("Authorization"); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + + // 쿠키에서 accessToken 빼오기 시도 + if(request.getCookies() != null) { + for(Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..23f0246 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,41 @@ +package com.ott.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +// 403 권한 없음 +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.FORBIDDEN, accessDeniedException.getMessage()); + + response.setStatus(ErrorCode.FORBIDDEN.getStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..6ba704d --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,45 @@ +package com.ott.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.common.web.exception.ErrorCode; +import com.ott.common.web.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +// 401 인증 안됨, 유효하지않음, 만료됨 +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + public static final String ERROR_CODE = "AUTH_ERROR_CODE"; + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + // Filter에서 받아온 002, 003 에러일 경우 해당 에러 사용 + Object attribute = request.getAttribute(ERROR_CODE); + ErrorCode errorCode = (attribute instanceof ErrorCode) ? (ErrorCode) attribute : ErrorCode.UNAUTHORIZED; + ErrorResponse errorResponse = ErrorResponse.of(errorCode, authException.getMessage()); + + response.setStatus(errorCode.getStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep b/modules/common-security/src/main/java/com/ott/common/security/jwt/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java b/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..98421fa --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/jwt/JwtTokenProvider.java @@ -0,0 +1,101 @@ +package com.ott.common.security.jwt; + +import com.ott.common.web.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.List; + + +/** + * Access/Refresh JWT 생성 + * JWT 검증, JWT 파싱(claim 추출) + */ +@Component +public class JwtTokenProvider { + + private static final String CLAIM_AUTH = "auth"; + + private final SecretKey key; + private final long accessTokenExpiry; + private final long refreshTokenExpiry; + + + public JwtTokenProvider( + @Value("${jwt.secret}") String base64Secret, + @Value("${jwt.access-token-expiry}")long accessTokenExpiry, + @Value("${jwt.refresh-token-expiry}")long refreshTokenExpiry) { + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); + this.accessTokenExpiry = accessTokenExpiry; + this.refreshTokenExpiry = refreshTokenExpiry; + } + + // Access JWT 생성 + public String createAccessToken(Long memberId, List authorities) { + return createToken(memberId, authorities, accessTokenExpiry); + } + + // Refresh JWT 생성 + public String createRefreshToken(Long memberId, List authorities) { + return createToken(memberId, authorities, refreshTokenExpiry); + } + + // JWT 생성 + // header는 자동으로 생김 + // claim -> sub, auth, iat(issued at), exp(expiration)이 들어감 + // signature -> Ec(header+payload) + private String createToken(Long memberId, List authorities, long expiryMillis) { + Date now = new Date(); + return Jwts.builder() + .subject(String.valueOf(memberId)) + .claim(CLAIM_AUTH, authorities) // ["ROLE_MEMBER", "ROLE_ADMIN", "ROLE_EDITOR"] + .issuedAt(now) + .expiration(new Date(now.getTime() + expiryMillis)) + .signWith(key) // 서명 + .compact(); + } + + // Claim 파싱 및 검증 + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) //header, payload, signature 분리 후 디코딩 후 json 파싱 + // 검증 성공 시 Jwt 반환 + .getPayload(); // payload만 추출 + } + + public Long getMemberId(String token) { + return Long.parseLong(getClaims(token).getSubject()); + } + + + // claims중에서 auth를 꺼내와서 해당 토큰의 ROLE확인 -> ["ROLE_USER"] + @SuppressWarnings("unchecked") + public List getAuthorities(String token) { + Object auth = getClaims(token).get(CLAIM_AUTH); + if (auth == null) return List.of(); + return (List) auth; + } + + // validate 결과를 ErrorCode로 변환 002, 003에 대한 에러 코드를 알아야됨 + public ErrorCode validateAndGetErrorCode(String token) { + try { + getClaims(token); + return null; + } catch (ExpiredJwtException e) { + return ErrorCode.EXPIRED_TOKEN; // A003 + } catch (JwtException | IllegalArgumentException e) { + return ErrorCode.INVALID_TOKEN; // A002 + } + } + +} diff --git a/modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep b/modules/common-security/src/main/java/com/ott/common/security/oauth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java index dc50428..f742032 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java +++ b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java @@ -10,14 +10,12 @@ public class WebMvcConfig implements WebMvcConfigurer { private final long MAX_AGE_SECS = 3600; - // @Value("${app.cors.allowed-origins}") - // private String[] allowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // .allowedOrigins(allowedOrigins) - .allowedOriginPatterns("*") + .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java b/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java index 3877f8e..d7ec9b7 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/domain/Bookmark.java @@ -1,12 +1,9 @@ package com.ott.domain.bookmark.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.TargetType; +import com.ott.domain.media.domain.Media; import com.ott.domain.member.domain.Member; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -36,10 +33,7 @@ public class Bookmark extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "target_id", nullable = false) - private Long targetId; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false) - private TargetType targetType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/common/TargetType.java b/modules/domain/src/main/java/com/ott/domain/common/MediaType.java similarity index 65% rename from modules/domain/src/main/java/com/ott/domain/common/TargetType.java rename to modules/domain/src/main/java/com/ott/domain/common/MediaType.java index 5b8e572..2ca4d5e 100644 --- a/modules/domain/src/main/java/com/ott/domain/common/TargetType.java +++ b/modules/domain/src/main/java/com/ott/domain/common/MediaType.java @@ -5,11 +5,11 @@ @AllArgsConstructor @Getter -public enum TargetType { - SHORT_FORM("SHORT_FORM", "SHORT_FORM"), +public enum MediaType { + SERIES("SERIES", "SERIES"), CONTENTS("CONTENTS", "CONTENTS"), - SERIES("SERIES", "SERIES"); + SHORT_FORM("SHORT_FORM", "SHORT_FORM"); String key; String value; -} \ No newline at end of file +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index a68a151..105653b 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -1,19 +1,17 @@ package com.ott.domain.contents.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import com.ott.domain.series.domain.Series; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -33,45 +31,23 @@ public class Contents extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") private Series series; - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; - @Column(name = "actors", nullable = false) private String actors; - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - - @Column(name = "thumbnail_url", nullable = false, columnDefinition = "TEXT") - private String thumbnailUrl; - @Column(name = "duration") private Integer duration; @Column(name = "video_size") private Integer videoSize; - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Column(name = "likes_count", nullable = false) - private Long likesCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; - @Column(name = "origin_url", nullable = false, columnDefinition = "TEXT") private String originUrl; diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 6c0d3fb..e5777a8 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -1,24 +1,17 @@ package com.ott.domain.contents.repository; -import java.util.List; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; +import org.springframework.data.jpa.repository.JpaRepository; -public interface ContentsRepository extends JpaRepository { +public interface ContentsRepository extends JpaRepository, ContentsRepositoryCustom { // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) - @Query("SELECT c FROM Contents c " + - "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND c.status = :status " + - "AND c.series IS NULL " + - "ORDER BY c.createdDate DESC") - List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, - Pageable pageable); +// @Query("SELECT c FROM Contents c " + +// "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + +// "AND c.status = :status " + +// "AND c.series IS NULL " + +// "ORDER BY c.createdDate DESC") +// List searchLatest(@Param("keyword") String searchWord, @Param("status") Status status, +// Pageable pageable); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java new file mode 100644 index 0000000..ea196c6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java @@ -0,0 +1,13 @@ +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; + +import java.util.List; +import java.util.Optional; + +public interface ContentsRepositoryCustom { + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java new file mode 100644 index 0000000..29391ba --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.ott.domain.contents.repository; + +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.media.domain.QMedia; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; + +@RequiredArgsConstructor +public class ContentsRepositoryImpl implements ContentsRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(contents) + .where(contents.media.id.in(mediaIdList)) + .fetch(); + } + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + QMedia seriesMedia = new QMedia("seriesMedia"); + Contents result = queryFactory + .selectFrom(contents) + .join(contents.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(contents.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java b/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java deleted file mode 100644 index 85a96d7..0000000 --- a/modules/domain/src/main/java/com/ott/domain/contents_tag/domain/ContentsTag.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.ott.domain.contents_tag.domain; - -import com.ott.domain.common.BaseEntity; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.tag.domain.Tag; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Builder -@Getter -@Table(name = "contents_tag") -public class ContentsTag extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id", nullable = false) - private Tag tag; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "contents_id", nullable = false) - private Contents contents; -} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java index 1285548..4db15b3 100644 --- a/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/domain/IngestJob.java @@ -1,8 +1,7 @@ package com.ott.domain.ingest_job.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.media.domain.Media; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -33,12 +32,8 @@ public class IngestJob extends BaseEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "short_form_id") - private ShortForm shortForm; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "contents_id") - private Contents contents; + @JoinColumn(name = "media_id", nullable = false) + private Media media; @Enumerated(EnumType.STRING) @Column(name = "ingest_status", nullable = false) diff --git a/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java b/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java index eac7832..45dd194 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/domain/Likes.java @@ -1,12 +1,9 @@ package com.ott.domain.likes.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.TargetType; +import com.ott.domain.media.domain.Media; import com.ott.domain.member.domain.Member; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -36,10 +33,7 @@ public class Likes extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "target_id", nullable = false) - private Long targetId; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false) - private TargetType targetType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java new file mode 100644 index 0000000..4ebf89a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -0,0 +1,65 @@ +package com.ott.domain.media.domain; + +import com.ott.domain.common.BaseEntity; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.member.domain.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Getter +@Table(name = "media") +public class Media extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploader_id", nullable = false) + private Member uploader; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") + private String posterUrl; + + @Column(name = "thumbnail_url", columnDefinition = "TEXT") + private String thumbnailUrl; + + @Column(name = "bookmark_count", nullable = false) + private Long bookmarkCount; + + @Column(name = "likes_count", nullable = false) + private Long likesCount; + + @Enumerated(EnumType.STRING) + @Column(name = "media_type", nullable = false) + private MediaType mediaType; + + @Enumerated(EnumType.STRING) + @Column(name = "public_status", nullable = false) + private PublicStatus publicStatus; +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java new file mode 100644 index 0000000..97ffc04 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.media.domain.Media; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MediaRepository extends JpaRepository, MediaRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java new file mode 100644 index 0000000..87c0069 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -0,0 +1,18 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.media.domain.Media; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface MediaRepositoryCustom { + + Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); + + Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus); + + Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); + + Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); +} diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java new file mode 100644 index 0000000..4e5d643 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -0,0 +1,160 @@ +package com.ott.domain.media.repository; + +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.media.domain.Media; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import com.querydsl.jpa.JPAExpressions; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; + +@RequiredArgsConstructor +public class MediaRepositoryImpl implements MediaRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord) { + List mediaList = queryFactory + .selectFrom(media) + .where( + media.mediaType.eq(mediaType), + titleContains(searchWord) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + media.mediaType.eq(mediaType), + titleContains(searchWord) + ); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus) + ); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId) + ); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord) { + BooleanExpression condition = media.mediaType.in(List.of(MediaType.SERIES, MediaType.CONTENTS)) + .and( + JPAExpressions.selectOne() + .from(contents) + .where( + contents.media.id.eq(media.id), + contents.series.isNotNull() + ) + .notExists() + ); + + List mediaList = queryFactory + .selectFrom(media) + .where( + condition, + titleContains(searchWord) + ) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where(condition, titleContains(searchWord)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } + + private BooleanExpression mediaTypeEq(MediaType mediaType) { + if (mediaType != null) + return media.mediaType.eq(mediaType); + return null; + } + + private BooleanExpression publicStatusEq(PublicStatus publicStatus) { + if (publicStatus != null) + return media.publicStatus.eq(publicStatus); + return null; + } + + private BooleanExpression uploaderIdEq(Long uploaderId) { + if (uploaderId != null) + return media.uploader.id.eq(uploaderId); + return null; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java b/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java similarity index 78% rename from modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java rename to modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java index ed492da..f6e4e28 100644 --- a/modules/domain/src/main/java/com/ott/domain/series_tag/domain/SeriesTag.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/domain/MediaTag.java @@ -1,7 +1,7 @@ -package com.ott.domain.series_tag.domain; +package com.ott.domain.media_tag.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.series.domain.Series; +import com.ott.domain.media.domain.Media; import com.ott.domain.tag.domain.Tag; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -22,8 +22,8 @@ @Entity @Builder @Getter -@Table(name = "series_tag") -public class SeriesTag extends BaseEntity { +@Table(name = "media_tag") +public class MediaTag extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -34,6 +34,6 @@ public class SeriesTag extends BaseEntity { private Tag tag; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "series_id", nullable = false) - private Series series; + @JoinColumn(name = "media_id", nullable = false) + private Media media; } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java new file mode 100644 index 0000000..9fd6e01 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java new file mode 100644 index 0000000..945d74b --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; + +import java.util.List; + +public interface MediaTagRepositoryCustom { + + List findWithTagAndCategoryByMediaIds(List mediaIds); + + List findWithTagAndCategoryByMediaId(Long mediaId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java new file mode 100644 index 0000000..d3522e3 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.ott.domain.media_tag.repository; + +import com.ott.domain.media_tag.domain.MediaTag; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; +import static com.ott.domain.tag.domain.QTag.tag; +import static com.ott.domain.category.domain.QCategory.category; + +@RequiredArgsConstructor +public class MediaTagRepositoryImpl implements MediaTagRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findWithTagAndCategoryByMediaIds(List mediaIds) { + return queryFactory + .selectFrom(mediaTag) + .join(mediaTag.tag, tag).fetchJoin() + .join(tag.category, category).fetchJoin() + .where(mediaTag.media.id.in(mediaIds)) + .fetch(); + } + + @Override + public List findWithTagAndCategoryByMediaId(Long mediaId) { + return queryFactory + .selectFrom(mediaTag) + .join(mediaTag.tag, tag).fetchJoin() + .join(tag.category, category).fetchJoin() + .where(mediaTag.media.id.eq(mediaId)) + .fetch(); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 7e37f6e..c07e1f6 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -50,6 +50,29 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + public static Member createKakaoMember(String providerId, String email, String nickname) { + return Member.builder() + .provider(Provider.KAKAO) + .providerId(providerId) + .email(email) + .nickname(nickname) + .role(Role.MEMBER) + .build(); + } + + public void updateKakaoProfile(String email, String nickname) { + this.email = email; + this.nickname = nickname; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void clearRefreshToken() { + this.refreshToken = null; + } + public void changeRole(Role targetRole) { if (!this.role.canTransitionTo(targetRole)) throw new IllegalArgumentException("Invalid role transition: " + this.role + " -> " + targetRole); diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java index 233f787..478f208 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -1,7 +1,16 @@ package com.ott.domain.member.repository; import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { + + // 기존 회원인지 신규 회원인지 DB 조회 + Optional findByProviderAndProviderId(Provider provider, String providerId); + + // 관리자&에디터용 조회 + Optional findByEmailAndProvider(String email, Provider provider); } diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java new file mode 100644 index 0000000..823bed6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -0,0 +1,8 @@ +package com.ott.domain.preferred_tag.repository; + +import com.ott.domain.preferred_tag.domain.PreferredTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PreferredTagRepository extends JpaRepository { + boolean existsByMemberId(Long memberId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java index 51b3511..d2af923 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java +++ b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java @@ -1,18 +1,15 @@ package com.ott.domain.series.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -32,32 +29,10 @@ public class Series extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; - - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @Column(name = "actors", nullable = false) private String actors; - - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - - @Column(name = "thumbnail_url", nullable = false, columnDefinition = "TEXT") - private String thumbnailUrl; - - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Column(name = "likes_count", nullable = false) - private Long likesCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index e04b10f..cb671b4 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -12,12 +12,12 @@ public interface SeriesRepository extends JpaRepository, SeriesRepositoryCustom { - // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) - @Query("SELECT s FROM Series s " + - "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + - "AND s.status = :status " + - "ORDER BY s.createdDate DESC") - List searchLatest(@Param("keyword") String keyword, - @Param("status") Status status, - Pageable pageable); +// // 제목에 검색어 포함, 상태 ACTIVE인 시리즈 검색 (최신순 정렬) +// @Query("SELECT s FROM Series s " + +// "WHERE LOWER(s.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + +// "AND s.status = :status " + +// "ORDER BY s.createdDate DESC") +// List searchLatest(@Param("keyword") String keyword, +// @Param("status") Status status, +// Pageable pageable); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java index f5fc2b6..496cdf1 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -4,7 +4,14 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; +import java.util.Optional; + public interface SeriesRepositoryCustom { - Page findSeriesList(Pageable pageable, String keyword); + Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord); + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java index 6d38069..8ca2f06 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -11,7 +11,10 @@ import org.springframework.util.StringUtils; import java.util.List; +import java.util.Optional; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; import static com.ott.domain.series.domain.QSeries.series; @RequiredArgsConstructor @@ -20,9 +23,22 @@ public class SeriesRepositoryImpl implements SeriesRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page findSeriesList(Pageable pageable, String searchWord) { + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + Series result = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord) { List seriesList = queryFactory .selectFrom(series) + .join(series.media, media).fetchJoin() .where(titleContains(searchWord)) .orderBy(series.createdDate.desc()) .offset(pageable.getOffset()) @@ -32,14 +48,23 @@ public Page findSeriesList(Pageable pageable, String searchWord) { JPAQuery countQuery = queryFactory .select(series.count()) .from(series) + .join(series.media, media) .where(titleContains(searchWord)); return PageableExecutionUtils.getPage(seriesList, pageable, countQuery::fetchOne); } + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(series) + .where(series.media.id.in(mediaIdList)) + .fetch(); + } + private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) - return series.title.contains(searchWord); + return media.title.contains(searchWord); return null; } } diff --git a/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java b/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java deleted file mode 100644 index 3eb20ca..0000000 --- a/modules/domain/src/main/java/com/ott/domain/series_tag/repository/SeriesTagRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ott.domain.series_tag.repository; - -import com.ott.domain.series_tag.domain.SeriesTag; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface SeriesTagRepository extends JpaRepository { - - @Query("SELECT st FROM SeriesTag st " - + "JOIN FETCH st.tag t " - + "JOIN FETCH t.category " - + "WHERE st.series.id IN :seriesIds") - List findWithTagAndCategoryBySeriesIds(@Param("seriesIds") List seriesIds); -} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 1d18dba..2663489 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -1,20 +1,18 @@ package com.ott.domain.short_form.domain; import com.ott.domain.common.BaseEntity; -import com.ott.domain.common.PublicStatus; import com.ott.domain.contents.domain.Contents; -import com.ott.domain.member.domain.Member; +import com.ott.domain.media.domain.Media; import com.ott.domain.series.domain.Series; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -22,6 +20,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Optional; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -34,9 +34,9 @@ public class ShortForm extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploader_id", nullable = false) - private Member uploader; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "media_id", nullable = false, unique = true) + private Media media; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") @@ -46,31 +46,21 @@ public class ShortForm extends BaseEntity { @JoinColumn(name = "contents_id") private Contents contents; - @Column(name = "title", nullable = false) - private String title; - - @Column(name = "description", nullable = false) - private String description; - - @Column(name = "poster_url", nullable = false, columnDefinition = "TEXT") - private String posterUrl; - @Column(name = "duration") private Integer duration; @Column(name = "video_size") private Integer videoSize; - @Column(name = "bookmark_count", nullable = false) - private Long bookmarkCount; - - @Enumerated(EnumType.STRING) - @Column(name = "public_status", nullable = false) - private PublicStatus publicStatus; - @Column(name = "origin_url", nullable = false, columnDefinition = "TEXT") private String originUrl; @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + + public Optional findOriginMedia() { + if (series != null) return Optional.of(series.getMedia()); + if (contents != null) return Optional.of(contents.getMedia()); + return Optional.empty(); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java new file mode 100644 index 0000000..c4f1575 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.short_form.domain.ShortForm; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ShortFormRepository extends JpaRepository, ShortFormRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java new file mode 100644 index 0000000..092594a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.short_form.domain.ShortForm; + +import java.util.Optional; + +public interface ShortFormRepositoryCustom { + + Optional findWithMediaAndUploaderByMediaId(Long mediaId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java new file mode 100644 index 0000000..42a0ac2 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.ott.domain.short_form.repository; + +import com.ott.domain.media.domain.QMedia; +import com.ott.domain.short_form.domain.ShortForm; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; +import static com.ott.domain.series.domain.QSeries.series; +import static com.ott.domain.short_form.domain.QShortForm.shortForm; + +@RequiredArgsConstructor +public class ShortFormRepositoryImpl implements ShortFormRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { + QMedia contentsMedia = new QMedia("contentsMedia"); + QMedia seriesMedia = new QMedia("seriesMedia"); + + ShortForm result = queryFactory + .selectFrom(shortForm) + .join(shortForm.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(shortForm.contents, contents).fetchJoin() + .leftJoin(contents.media, contentsMedia).fetchJoin() + .leftJoin(shortForm.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(media.id.eq(mediaId)) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql b/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql new file mode 100644 index 0000000..86ab538 --- /dev/null +++ b/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql @@ -0,0 +1,195 @@ +-- ============================================================ +-- V2: 클래스 테이블 상속 마이그레이션 +-- series, contents, short_form → media 공통 부모 도입 +-- ============================================================ + +-- 1. media, media_tag 테이블 생성 +CREATE TABLE IF NOT EXISTS media +( + id BIGINT AUTO_INCREMENT NOT NULL, + uploader_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + poster_url TEXT NOT NULL, + thumbnail_url TEXT NULL, + + bookmark_count BIGINT NOT NULL DEFAULT 0, + likes_count BIGINT NOT NULL DEFAULT 0, + media_type ENUM ('SERIES','CONTENTS','SHORT_FORM') NOT NULL, + public_status ENUM ('PUBLIC','PRIVATE') NOT NULL, + + created_date DATETIME NOT NULL, + modified_date DATETIME NOT NULL, + status ENUM ('DELETE','ACTIVE') NOT NULL, + + CONSTRAINT pk_media PRIMARY KEY (id) +) engine = InnoDB; + +CREATE TABLE IF NOT EXISTS media_tag +( + id BIGINT AUTO_INCREMENT NOT NULL, + tag_id BIGINT NOT NULL, + media_id BIGINT NOT NULL, + + created_date DATETIME NOT NULL, + modified_date DATETIME NOT NULL, + status ENUM ('DELETE','ACTIVE') NOT NULL, + + CONSTRAINT pk_media_tag PRIMARY KEY (id) +) engine = InnoDB; + + +-- 2. 신규 테이블 FK 설정 +ALTER TABLE media + ADD CONSTRAINT fk_media_to_member + FOREIGN KEY (uploader_id) + REFERENCES member (id); + +ALTER TABLE media_tag + ADD CONSTRAINT fk_media_tag_to_tag + FOREIGN KEY (tag_id) + REFERENCES tag (id); + +ALTER TABLE media_tag + ADD CONSTRAINT fk_media_tag_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + + +-- 3. media 상세 테이블에 media_id 컬럼 추가 + 제약조건 +-- series +ALTER TABLE series + ADD COLUMN media_id BIGINT NOT NULL AFTER id; + +ALTER TABLE series + ADD CONSTRAINT uk_series_media UNIQUE (media_id); + +ALTER TABLE series + ADD CONSTRAINT fk_series_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +-- contents +ALTER TABLE contents + ADD COLUMN media_id BIGINT NOT NULL AFTER id; + +ALTER TABLE contents + ADD CONSTRAINT uk_contents_media UNIQUE (media_id); + +ALTER TABLE contents + ADD CONSTRAINT fk_contents_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +-- short_form +ALTER TABLE short_form + ADD COLUMN media_id BIGINT NOT NULL AFTER id; + +ALTER TABLE short_form + ADD CONSTRAINT uk_short_form_media UNIQUE (media_id); + +ALTER TABLE short_form + ADD CONSTRAINT fk_short_form_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + + +-- 4. bookmark: target_type + target_id → media_id +ALTER TABLE bookmark + ADD COLUMN media_id BIGINT NOT NULL; + +ALTER TABLE bookmark + ADD CONSTRAINT fk_bookmark_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE bookmark + DROP COLUMN target_id; + +ALTER TABLE bookmark + DROP COLUMN target_type; + + +-- 5. likes: target_type + target_id → media_id +ALTER TABLE likes + ADD COLUMN media_id BIGINT NOT NULL; + +ALTER TABLE likes + ADD CONSTRAINT fk_likes_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE likes + DROP COLUMN target_id; + +ALTER TABLE likes + DROP COLUMN target_type; + + +-- 6. ingest_job: contents_id + short_form_id → media_id +ALTER TABLE ingest_job + ADD COLUMN media_id BIGINT NOT NULL; + +ALTER TABLE ingest_job + ADD CONSTRAINT fk_ingest_job_to_media + FOREIGN KEY (media_id) + REFERENCES media (id); + +ALTER TABLE ingest_job + DROP FOREIGN KEY fk_ingest_job_to_contents; + +ALTER TABLE ingest_job + DROP FOREIGN KEY fk_ingest_job_to_short_form; + +ALTER TABLE ingest_job + DROP COLUMN contents_id; + +ALTER TABLE ingest_job + DROP COLUMN short_form_id; + + +-- 7. series_tag + contents_tag → media_tag 통합 (테이블 삭제) +DROP TABLE series_tag; +DROP TABLE contents_tag; + + +-- 8. 상세 테이블에서 media로 이동한 컬럼 제거 +-- series +ALTER TABLE series + DROP FOREIGN KEY fk_series_to_member_uploader; + +ALTER TABLE series + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN thumbnail_url, + DROP COLUMN bookmark_count, + DROP COLUMN likes_count, + DROP COLUMN public_status; + +-- contents +ALTER TABLE contents + DROP FOREIGN KEY fk_contents_to_member_uploader; + +ALTER TABLE contents + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN thumbnail_url, + DROP COLUMN bookmark_count, + DROP COLUMN likes_count, + DROP COLUMN public_status; + +-- short_form +ALTER TABLE short_form + DROP FOREIGN KEY fk_short_form_to_member_uploader; + +ALTER TABLE short_form + DROP COLUMN uploader_id, + DROP COLUMN title, + DROP COLUMN description, + DROP COLUMN poster_url, + DROP COLUMN bookmark_count, + DROP COLUMN public_status; From 601c884f4692ddc9bc082cc547f2bb3c5eca8eac Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:58:47 +0900 Subject: [PATCH 070/257] =?UTF-8?q?[FIX]:=20getSeriesContets=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=ED=83=80=EC=9E=85=20=EC=A0=9C=EB=84=A4=EB=A6=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/series/service/SeriesService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index 226e1b2..d1131c1 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -70,7 +70,9 @@ public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { } // 시리즈 콘텐츠 목록 조회 (페이징) - public PageResponse getSeriesContents(Long seriesId, int page, int size, Long memberId) { + // 반환 타입 제네릭으로 수정 + public PageResponse getSeriesContents(Long seriesId, int page, int size, + Long memberId) { seriesRepository.findByIdWithMedia(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); From 694f6de9687ee65839c171871e1f213c4c579d69 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:26:48 +0900 Subject: [PATCH 071/257] =?UTF-8?q?[REFACTOR]:=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=A1=B0=ED=9A=8C=20API=20=EC=BB=A4=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=98=EC=98=81=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SeriesSerivce 내부 DTO 빌더 로직을 정적 팩토리 메서드로 분리 - SeriesService 에 @Transactional(readOnly = true) 적용하여 성능 최적화 - PageResponse 의 반환 타입을 제너릭으로 명시 --- .../api_user/series/controller/SeriesApi.java | 4 ++- .../series/controller/SeriesController.java | 15 +++++----- .../series/dto/SeriesContentsResponse.java | 16 ++++++++++ .../series/dto/SeriesDetailResponse.java | 19 ++++++++++++ .../series/service/SeriesService.java | 29 ++++--------------- 5 files changed, 51 insertions(+), 32 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index e7dc2b4..f084a00 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -1,10 +1,12 @@ package com.ott.api_user.series.controller; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; +import com.ott.api_user.series.dto.SeriesContentsResponse; import com.ott.api_user.series.dto.SeriesDetailResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; @@ -41,7 +43,7 @@ ResponseEntity> getSeriesDetail( @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) @GetMapping("/{seriesId}/contents") - ResponseEntity> getSeriesContents( + ResponseEntity>> getSeriesContents( @Parameter(description = "시리즈 ID", required = true) @PathVariable("seriesId") Long seriesId, @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(defaultValue = "0")) @RequestParam("page") Integer page, @Parameter(description = "페이지 크기", schema = @Schema(defaultValue = "24")) @RequestParam("size") Integer size, diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java index 0f77fff..5db559a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -7,10 +7,13 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.ott.api_user.series.dto.SeriesContentsResponse; import com.ott.api_user.series.dto.SeriesDetailResponse; import com.ott.api_user.series.service.SeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; + +import io.micrometer.core.ipc.http.HttpSender.Response; import lombok.RequiredArgsConstructor; @RestController @@ -24,20 +27,18 @@ public ResponseEntity> getSeriesDetail( @PathVariable(value = "seriesId") Long seriesId, @AuthenticationPrincipal Long memberId) { - SeriesDetailResponse response = seriesService.getSeriesDetail(seriesId, memberId); - - return ResponseEntity.ok(SuccessResponse.of(response)); + return ResponseEntity.ok( + SuccessResponse.of(seriesService.getSeriesDetail(seriesId, memberId))); } @Override - public ResponseEntity> getSeriesContents( + public ResponseEntity>> getSeriesContents( @PathVariable(value = "seriesId") Long seriesId, @RequestParam(value = "page") Integer pageParam, @RequestParam(value = "size") Integer sizeParam, @AuthenticationPrincipal Long memberId) { - PageResponse response = seriesService.getSeriesContents(seriesId, pageParam, sizeParam, memberId); - - return ResponseEntity.ok(SuccessResponse.of(response)); + return ResponseEntity.ok( + SuccessResponse.of(seriesService.getSeriesContents(seriesId, pageParam, sizeParam, memberId))); } } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java index 8e401d3..748506e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java @@ -1,5 +1,7 @@ package com.ott.api_user.series.dto; +import com.ott.domain.contents.domain.Contents; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -28,4 +30,18 @@ public class SeriesContentsResponse { // 이어보기 지점도 응답에 포함 @Schema(type = "Integer", example = "1200", description = "사용자가 마지막으로 시청한 지점 (초)") private Integer positionSec; + + public static SeriesContentsResponse from(Contents content) { + // 임시 이어보기용 (나중에 로직 작성 후 파라미터로 받게 수정 예정) + Integer positionSec = 0; + + return SeriesContentsResponse.builder() + .id(content.getId()) + .duration(content.getDuration()) + .title(content.getMedia().getTitle()) + .description(content.getMedia().getDescription()) + .thumbnailUrl(content.getMedia().getThumbnailUrl()) + .positionSec(positionSec) + .build(); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java index a5764ad..32871e6 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java @@ -2,6 +2,8 @@ import java.util.List; +import com.ott.domain.series.domain.Series; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -43,4 +45,21 @@ public class SeriesDetailResponse { @Schema(description = "사용자 좋아요 여부", example = "true") private Boolean isLiked; + // 정적 팩토리 메서드 사용 + public static SeriesDetailResponse of(Series series, List tags, List categories, + Boolean isBookmarked, Boolean isLiked) { + return SeriesDetailResponse.builder() + .id(series.getId()) + .actors(series.getActors()) + .title(series.getMedia().getTitle()) + .description(series.getMedia().getDescription()) + .posterUrl(series.getMedia().getPosterUrl()) + .thumbnailUrl(series.getMedia().getThumbnailUrl()) + .category(categories.isEmpty() ? null : categories.get(0)) + .tags(tags) + .isBookmarked(isBookmarked) + .isLiked(isLiked) + .build(); + } + } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index d1131c1..6cdb0a1 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ott.api_user.series.dto.SeriesContentsResponse; import com.ott.api_user.series.dto.SeriesDetailResponse; import com.ott.common.web.exception.BusinessException; @@ -27,11 +28,11 @@ import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import com.ott.domain.tag.repository.TagRepository; - import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) // 더티 체킹 비활성화 public class SeriesService { private final SeriesRepository seriesRepository; private final ContentsRepository contentsRepository; @@ -55,18 +56,7 @@ public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { Status.ACTIVE); Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); - return SeriesDetailResponse.builder() - .id(series.getId()) - .actors(series.getActors()) - .title(series.getMedia().getTitle()) - .description(series.getMedia().getDescription()) - .posterUrl(series.getMedia().getPosterUrl()) - .thumbnailUrl(series.getMedia().getThumbnailUrl()) - .category(categories.isEmpty() ? null : categories.get(0)) - .tags(tags) - .isBookmarked(isBookmarked) - .isLiked(isLiked) - .build(); + return SeriesDetailResponse.of(series, tags, categories, isBookmarked, isLiked); } // 시리즈 콘텐츠 목록 조회 (페이징) @@ -83,17 +73,8 @@ public PageResponse getSeriesContents(Long seriesId, int .findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc( seriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); - List contentsList = contentsPage.getContent().stream().map(content -> { - Integer positionSec = 0;// 임시 이어보기용 (이어보기 API 구현 시 수정) - return SeriesContentsResponse.builder() - .id(content.getId()) - .duration(content.getDuration()) - .title(content.getMedia().getTitle()) - .description(content.getMedia().getDescription()) - .thumbnailUrl(content.getMedia().getThumbnailUrl()) - .positionSec(positionSec) - .build(); - }).collect(Collectors.toList()); + List contentsList = contentsPage.getContent().stream() + .map(SeriesContentsResponse::from).collect(Collectors.toList()); PageInfo pageInfo = PageInfo.builder() .currentPage(contentsPage.getNumber()) From 5b9663a185a9270254e2775dc6fc0ff3ede3d36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 14:51:02 +0900 Subject: [PATCH 072/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20-=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A4=91=EC=9D=B8=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeIngestJobApi.java | 3 +- .../BackOfficeIngestJobController.java | 34 +++++++ .../dto/response/IngestJobListResponse.java | 2 +- .../mapper/BackOfficeIngestJobMapper.java | 25 +++++ .../service/BackOfficeIngestJobService.java | 96 +++++++++++++++++++ .../repository/IngestJobRepository.java | 7 ++ .../repository/IngestJobRepositoryCustom.java | 10 ++ .../repository/IngestJobRepositoryImpl.java | 62 ++++++++++++ .../repository/ShortFormRepositoryCustom.java | 3 + .../repository/ShortFormRepositoryImpl.java | 9 ++ 10 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java index 0ddecd9..8d5b8de 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobApi.java @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice IngestJob API", description = "[백오피스] 업로드 작업 관리 API") @@ -37,6 +38,6 @@ ResponseEntity>> getIngestJo @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 = "업로더 ID. EDITOR인 경우 본인 ID를 전달합니다. 미입력 시 전체 조회 (ADMIN).", required = false) @RequestParam(value = "uploaderId", required = false) Long uploaderId + Authentication authentication ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java new file mode 100644 index 0000000..c31882f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/controller/BackOfficeIngestJobController.java @@ -0,0 +1,34 @@ +package com.ott.api_admin.ingest_job.controller; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.api_admin.ingest_job.service.BackOfficeIngestJobService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +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/ingest-jobs") +@RequiredArgsConstructor +public class BackOfficeIngestJobController implements BackOfficeIngestJobApi { + + private final BackOfficeIngestJobService backOfficeIngestJobService; + + @Override + @GetMapping + public ResponseEntity>> getIngestJobList( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @RequestParam(value = "searchWord", required = false) String searchWord, + Authentication authentication + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeIngestJobService.getIngestJobList(page, size, searchWord, authentication)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java index 2067fd3..1fb2944 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java @@ -9,7 +9,7 @@ public record IngestJobListResponse( @Schema(type = "Long", description = "작업 ID", example = "1") Long ingestJobId, - @Schema(type = "String", description = "콘텐츠 제목", example = "비밀의 숲 1화") + @Schema(type = "String", description = "미디어(시리즈/콘텐츠/숏폼) 제목", example = "비밀의 숲 1화") String title, @Schema(type = "Integer", description = "파일 크기 (bytes)", example = "104857600") diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java new file mode 100644 index 0000000..e87f15c --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/mapper/BackOfficeIngestJobMapper.java @@ -0,0 +1,25 @@ +package com.ott.api_admin.ingest_job.mapper; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.domain.ingest_job.domain.IngestJob; +import com.ott.domain.media.domain.Media; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class BackOfficeIngestJobMapper { + + public IngestJobListResponse toIngestJobListResponse(IngestJob ingestJob, Map videoSizeByMediaId) { + Media media = ingestJob.getMedia(); + + return new IngestJobListResponse( + ingestJob.getId(), + media.getTitle(), + videoSizeByMediaId.getOrDefault(media.getId(), null), + media.getUploader().getNickname(), + ingestJob.getIngestStatus(), + 0 + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java new file mode 100644 index 0000000..f9b86fe --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java @@ -0,0 +1,96 @@ +package com.ott.api_admin.ingest_job.service; + +import com.ott.api_admin.ingest_job.dto.response.IngestJobListResponse; +import com.ott.api_admin.ingest_job.mapper.BackOfficeIngestJobMapper; +import com.ott.common.web.response.PageInfo; +import com.ott.common.web.response.PageResponse; +import com.ott.domain.common.MediaType; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.ingest_job.domain.IngestJob; +import com.ott.domain.ingest_job.repository.IngestJobRepository; +import com.ott.domain.member.domain.Role; +import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.short_form.repository.ShortFormRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BackOfficeIngestJobService { + + private final BackOfficeIngestJobMapper backOfficeIngestJobMapper; + + private final IngestJobRepository ingestJobRepository; + private final ContentsRepository contentsRepository; + private final ShortFormRepository shortFormRepository; + + @Transactional(readOnly = true) + public PageResponse getIngestJobList( + Integer page, Integer size, String searchWord, Authentication authentication + ) { + Pageable pageable = PageRequest.of(page, size); + + // 1. 관리자/에디터 여부 확인 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + Long uploaderId = null; + + // 2. 에디터인 경우 본인이 업로드한 작업만 조회 가능 + if (isEditor) + uploaderId = memberId; + + // 2. IngestJob 페이징 조회 (Media + Uploader fetchJoin) + Page ingestJobPage = ingestJobRepository.findIngestJobListWithMediaBySearchWordAndUploaderId( + pageable, searchWord, uploaderId + ); + + List ingestJobList = ingestJobPage.getContent(); + + // 3. mediaId를 타입별 분리 + List contentsMediaIdList = ingestJobList.stream() + .filter(j -> j.getMedia().getMediaType() == MediaType.CONTENTS) + .map(j -> j.getMedia().getId()) + .toList(); + + List shortFormMediaIdList = ingestJobList.stream() + .filter(j -> j.getMedia().getMediaType() == MediaType.SHORT_FORM) + .map(j -> j.getMedia().getId()) + .toList(); + + // 4. 일괄 조회: mediaId → videoSize 매핑 + Map videoSizeByMediaId = new HashMap<>(); + + contentsRepository.findAllByMediaIdIn(contentsMediaIdList).forEach( + c -> videoSizeByMediaId.put(c.getMedia().getId(), c.getVideoSize()) + ); + + shortFormRepository.findAllByMediaIdIn(shortFormMediaIdList).forEach( + s -> videoSizeByMediaId.put(s.getMedia().getId(), s.getVideoSize()) + ); + + // 5. 응답 매핑 + List responseList = ingestJobList.stream() + .map(j -> backOfficeIngestJobMapper.toIngestJobListResponse(j, videoSizeByMediaId)) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + ingestJobPage.getNumber(), + ingestJobPage.getTotalPages(), + ingestJobPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java new file mode 100644 index 0000000..966c8e9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.ingest_job.repository; + +import com.ott.domain.ingest_job.domain.IngestJob; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IngestJobRepository extends JpaRepository, IngestJobRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java new file mode 100644 index 0000000..7357101 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.ingest_job.repository; + +import com.ott.domain.ingest_job.domain.IngestJob; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface IngestJobRepositoryCustom { + + Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pageable pageable, String searchWord, Long uploaderId); +} diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java new file mode 100644 index 0000000..6cb2419 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java @@ -0,0 +1,62 @@ +package com.ott.domain.ingest_job.repository; + +import com.ott.domain.ingest_job.domain.IngestJob; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ott.domain.ingest_job.domain.QIngestJob.ingestJob; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; + +@RequiredArgsConstructor +public class IngestJobRepositoryImpl implements IngestJobRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pageable pageable, String searchWord, Long uploaderId) { + List ingestJobList = queryFactory + .selectFrom(ingestJob) + .join(ingestJob.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .where( + titleContains(searchWord), + uploaderIdEq(uploaderId) + ) + .orderBy(ingestJob.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(ingestJob.count()) + .from(ingestJob) + .join(ingestJob.media, media) + .where( + titleContains(searchWord), + uploaderIdEq(uploaderId) + ); + + return PageableExecutionUtils.getPage(ingestJobList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } + + private BooleanExpression uploaderIdEq(Long uploaderId) { + if (uploaderId != null) + return media.uploader.id.eq(uploaderId); + return null; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java index 092594a..2739ecb 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java @@ -2,9 +2,12 @@ import com.ott.domain.short_form.domain.ShortForm; +import java.util.List; import java.util.Optional; public interface ShortFormRepositoryCustom { Optional findWithMediaAndUploaderByMediaId(Long mediaId); + + List findAllByMediaIdIn(List mediaIdList); } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java index 42a0ac2..3143e83 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java @@ -5,6 +5,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import java.util.List; import java.util.Optional; import static com.ott.domain.contents.domain.QContents.contents; @@ -36,4 +37,12 @@ public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { return Optional.ofNullable(result); } + + @Override + public List findAllByMediaIdIn(List mediaIdList) { + return queryFactory + .selectFrom(shortForm) + .where(shortForm.media.id.in(mediaIdList)) + .fetch(); + } } From 879b2675175a9b1a3f2364596772e300d4886270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 15:17:57 +0900 Subject: [PATCH 073/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeCategoryApi.java | 32 +++++++++++++++++++ .../BackOfficeCategoryController.java | 28 ++++++++++++++++ .../dto/response/CategoryListResponse.java | 14 ++++++++ .../mapper/BackOfficeCategoryMapper.java | 16 ++++++++++ .../service/BackOfficeCategoryService.java | 26 +++++++++++++++ .../repository/CategoryRepository.java | 7 ++++ 6 files changed, 123 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java new file mode 100644 index 0000000..fd37782 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryApi.java @@ -0,0 +1,32 @@ +package com.ott.api_admin.category.controller; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +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 java.util.List; + +@Tag(name = "BackOffice Category API", description = "[백오피스] 카테고리 관리 API") +public interface BackOfficeCategoryApi { + + @Operation(summary = "카테고리 목록 조회", description = "전체 카테고리 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "카테고리 목록 조회 성공", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = CategoryListResponse.class)))} + ), + @ApiResponse( + responseCode = "400", description = "카테고리 목록 조회 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getCategoryList(); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java new file mode 100644 index 0000000..035c27b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/controller/BackOfficeCategoryController.java @@ -0,0 +1,28 @@ +package com.ott.api_admin.category.controller; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.api_admin.category.service.BackOfficeCategoryService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/back-office/admin/categories") +@RequiredArgsConstructor +public class BackOfficeCategoryController implements BackOfficeCategoryApi { + + private final BackOfficeCategoryService backOfficeCategoryService; + + @Override + @GetMapping + public ResponseEntity>> getCategoryList() { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeCategoryService.getCategoryList()) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java new file mode 100644 index 0000000..364c0dd --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/dto/response/CategoryListResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.category.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카테고리 목록 조회 응답") +public record CategoryListResponse( + + @Schema(type = "Long", description = "카테고리 ID", example = "1") + Long categoryId, + + @Schema(type = "String", description = "카테고리명", example = "드라마") + String categoryName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java new file mode 100644 index 0000000..87d9625 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/mapper/BackOfficeCategoryMapper.java @@ -0,0 +1,16 @@ +package com.ott.api_admin.category.mapper; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.domain.category.domain.Category; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeCategoryMapper { + + public CategoryListResponse toCategoryListResponse(Category category) { + return new CategoryListResponse( + category.getId(), + category.getName() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java b/apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java new file mode 100644 index 0000000..7fd68d5 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/category/service/BackOfficeCategoryService.java @@ -0,0 +1,26 @@ +package com.ott.api_admin.category.service; + +import com.ott.api_admin.category.dto.response.CategoryListResponse; +import com.ott.api_admin.category.mapper.BackOfficeCategoryMapper; +import com.ott.domain.category.repository.CategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackOfficeCategoryService { + + private final BackOfficeCategoryMapper backOfficeCategoryMapper; + + private final CategoryRepository categoryRepository; + + @Transactional(readOnly = true) + public List getCategoryList() { + return categoryRepository.findAll().stream() + .map(backOfficeCategoryMapper::toCategoryListResponse) + .toList(); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java new file mode 100644 index 0000000..6f86733 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.category.repository; + +import com.ott.domain.category.domain.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { +} From 0b01b45d429db0691b0d832b3bce8ebba3038a99 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:21:22 +0900 Subject: [PATCH 074/257] git merge devleop --- .../com/ott/domain/contents/repository/ContentsRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 5696be4..a038aba 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -33,7 +33,7 @@ import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; -public interface ContentsRepository extends JpaRepository { +public interface ContentsRepository extends JpaRepository, ContentsRepositoryCustom { @EntityGraph(attributePaths = { "media" }) Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long seriesId, Status status, From 130bb2a2c9b47a24cc0f3ddf2aab195f123d60ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 16:14:44 +0900 Subject: [PATCH 075/257] =?UTF-8?q?[FEAT]:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=9B=94=EB=B3=84=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20#=ED=83=9C=EA=B7=B8=20=EC=8B=9C=EC=B2=AD?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tag/controller/BackOfficeTagApi.java | 37 ++++++++++++++++++ .../controller/BackOfficeTagController.java | 28 +++++++++++++ .../tag/dto/response/TagViewResponse.java | 14 +++++++ .../tag/mapper/BackOfficeTagMapper.java | 16 ++++++++ .../tag/service/BackOfficeTagService.java | 31 +++++++++++++++ .../repository/TagViewCountProjection.java | 7 ++++ .../repository/WatchHistoryRepository.java | 7 ++++ .../WatchHistoryRepositoryCustom.java | 9 +++++ .../WatchHistoryRepositoryImpl.java | 39 +++++++++++++++++++ 9 files changed, 188 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java create mode 100644 modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java create mode 100644 modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java new file mode 100644 index 0000000..dc26ae2 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagApi.java @@ -0,0 +1,37 @@ +package com.ott.api_admin.tag.controller; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +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; + +import java.util.List; + +@Tag(name = "BackOffice Tag API", description = "[백오피스] 태그 시청 통계 API") +public interface BackOfficeTagApi { + + @Operation(summary = "카테고리별 태그 당월 시청 통계 조회", description = "특정 카테고리에 속한 태그들의 당월 시청 수를 조회합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "태그 시청 통계 조회 성공", + content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = TagViewResponse.class)))} + ), + @ApiResponse( + responseCode = "400", description = "태그 시청 통계 조회 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + ResponseEntity>> getTagViewStats( + @Parameter(description = "카테고리 ID", required = true) @PathVariable(value = "categoryId") Long categoryId + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java new file mode 100644 index 0000000..13ecef2 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/controller/BackOfficeTagController.java @@ -0,0 +1,28 @@ +package com.ott.api_admin.tag.controller; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.api_admin.tag.service.BackOfficeTagService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/back-office/admin/tags") +@RequiredArgsConstructor +public class BackOfficeTagController implements BackOfficeTagApi { + + private final BackOfficeTagService backOfficeTagService; + + @Override + @GetMapping("/stats/{categoryId}") + public ResponseEntity>> getTagViewStats( + @PathVariable(value = "categoryId") Long categoryId + ) { + return ResponseEntity.ok( + SuccessResponse.of(backOfficeTagService.getTagViewStats(categoryId)) + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java new file mode 100644 index 0000000..3c6439d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/dto/response/TagViewResponse.java @@ -0,0 +1,14 @@ +package com.ott.api_admin.tag.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "태그별 시청 통계 응답") +public record TagViewResponse( + + @Schema(type = "String", description = "태그명", example = "스릴러") + String tagName, + + @Schema(type = "Long", description = "당월 시청 수", example = "120") + Long viewCount +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java new file mode 100644 index 0000000..fa0229e --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/mapper/BackOfficeTagMapper.java @@ -0,0 +1,16 @@ +package com.ott.api_admin.tag.mapper; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.domain.watch_history.repository.TagViewCountProjection; +import org.springframework.stereotype.Component; + +@Component +public class BackOfficeTagMapper { + + public TagViewResponse toTagViewResponse(TagViewCountProjection projection) { + return new TagViewResponse( + projection.tagName(), + projection.viewCount() + ); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java b/apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java new file mode 100644 index 0000000..e2b5e35 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/tag/service/BackOfficeTagService.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.tag.service; + +import com.ott.api_admin.tag.dto.response.TagViewResponse; +import com.ott.api_admin.tag.mapper.BackOfficeTagMapper; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackOfficeTagService { + + private final BackOfficeTagMapper backOfficeTagMapper; + private final WatchHistoryRepository watchHistoryRepository; + + @Transactional(readOnly = true) + public List getTagViewStats(Long categoryId) { + LocalDateTime startDate = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + LocalDateTime endDate = startDate.plusMonths(1); + + return watchHistoryRepository.countByTagAndCategoryIdAndWatchedBetween(categoryId, startDate, endDate) + .stream() + .map(backOfficeTagMapper::toTagViewResponse) + .toList(); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java new file mode 100644 index 0000000..7715e0f --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagViewCountProjection.java @@ -0,0 +1,7 @@ +package com.ott.domain.watch_history.repository; + +public record TagViewCountProjection( + String tagName, + Long viewCount +) { +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java new file mode 100644 index 0000000..efe3a9b --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java @@ -0,0 +1,7 @@ +package com.ott.domain.watch_history.repository; + +import com.ott.domain.watch_history.domain.WatchHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WatchHistoryRepository extends JpaRepository, WatchHistoryRepositoryCustom { +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java new file mode 100644 index 0000000..25a1749 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.ott.domain.watch_history.repository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface WatchHistoryRepositoryCustom { + + List countByTagAndCategoryIdAndWatchedBetween(Long categoryId, LocalDateTime startDate, LocalDateTime endDate); +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java new file mode 100644 index 0000000..4192abd --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.ott.domain.watch_history.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; +import static com.ott.domain.tag.domain.QTag.tag; +import static com.ott.domain.watch_history.domain.QWatchHistory.watchHistory; + +@RequiredArgsConstructor +public class WatchHistoryRepositoryImpl implements WatchHistoryRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List countByTagAndCategoryIdAndWatchedBetween(Long categoryId, LocalDateTime startDate, LocalDateTime endDate) { + return queryFactory + .select(Projections.constructor(TagViewCountProjection.class, + tag.name, + watchHistory.count() + )) + .from(tag) + .join(mediaTag).on(mediaTag.tag.id.eq(tag.id)) + .join(contents).on(contents.media.id.eq(mediaTag.media.id)) + .join(watchHistory).on(watchHistory.contents.id.eq(contents.id)) + .where( + tag.category.id.eq(categoryId), + watchHistory.lastWatchedAt.goe(startDate), + watchHistory.lastWatchedAt.lt(endDate) + ) + .groupBy(tag.id, tag.name) + .fetch(); + } +} From 45ac238f06fa8d75960694737b4d49d2ba6db55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 16:21:58 +0900 Subject: [PATCH 076/257] =?UTF-8?q?[DOCS]:=20CodeRabbit=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=9D=BC=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coderabbit/coderabbit-guidelines.md | 412 ++++++++++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 coderabbit/coderabbit-guidelines.md diff --git a/coderabbit/coderabbit-guidelines.md b/coderabbit/coderabbit-guidelines.md new file mode 100644 index 0000000..b2bc8e2 --- /dev/null +++ b/coderabbit/coderabbit-guidelines.md @@ -0,0 +1,412 @@ +# CodeRabbit Review Guidelines (Backend) + +이 문서는 PR 코드리뷰에서 CodeRabbit이 참고할 프로젝트 코딩 규칙이다. +CodeRabbit 사이트의 **Repository Settings > Review Instructions** 에 아래 내용을 붙여넣는다. + +--- + +## 1) 리뷰 우선순위 + +- **P0 (머지 차단)**: 컴파일 실패, 부팅 실패, 인가 누락, 데이터 무결성 훼손, SQL injection/XSS 등 보안 취약점 +- **P1 (머지 전 보완 권장)**: API 계약 불일치, 페이징/조회 품질, 상태 전이 누락, N+1 쿼리 +- **P2 (품질 개선)**: 중복 제거, 테스트 가독성, 불필요한 import + +--- + +## 2) 프로젝트 구조 및 모듈 경계 + +### 멀티모듈 구성 +``` +backend/ +├── apps/ +│ ├── api-user/ # 사용자 API (포트 8080, Flyway 실행 주체) +│ ├── api-admin/ # 관리자 API (포트 8081) +│ └── transcoder/ # 트랜스코더 (포트 8082) +├── modules/ +│ ├── domain/ # JPA 엔티티 + Repository + enum +│ ├── infra/ # Flyway 마이그레이션, S3, 외부 연동 +│ ├── common-web/ # 응답(SuccessResponse/PageResponse), 예외, Swagger 설정 +│ └── common-security/# JWT, Security 필터 +``` + +### 의존 규칙 +- `apps/*` → `common-web`, `common-security`, `infra`, `domain` 의존 가능. +- `common-web`에 DB(JPA) 의존성 추가 금지. +- `domain` 모듈은 웹 모듈에 의존하지 않는다. +- 앱 내부에 공통 DTO/유틸 중복 생성 금지 — `common-web`에 이미 있는 것을 사용한다. + +### 앱 내부 패키지 구조 +``` +com.ott.{app-name}/{도메인}/ +├── controller/ # @RestController (implements XxxApi) +├── service/ # 비즈니스 로직 +├── mapper/ # DTO 변환 (@Component) +└── dto/ + ├── request/ + └── response/ +``` + +### 도메인 모듈 패키지 구조 +``` +com.ott.domain.{도메인명}/ +├── domain/ # @Entity +└── repository/ # JpaRepository + Custom + Impl +``` + +--- + +## 3) Controller 규칙 + +### API 인터페이스 분리 패턴 +모든 컨트롤러는 **API 인터페이스 + 구현 컨트롤러**로 분리한다. +- `XxxApi.java` (interface): Swagger 어노테이션(`@Tag`, `@Operation`, `@ApiResponses`, `@Parameter`) 전담. +- `XxxController.java` (class): `implements XxxApi`, Spring MVC 어노테이션(`@GetMapping` 등)과 서비스 호출만 담당. + +```java +// API 인터페이스 +@Tag(name = "BackOffice Series API", description = "[백오피스] 시리즈 관리 API") +public interface BackOfficeSeriesApi { + @Operation(summary = "시리즈 목록 조회") + @ApiResponses(...) + ResponseEntity>> getSeries(...); +} + +// 컨트롤러 구현 +@RestController +@RequestMapping("/back-office") +@RequiredArgsConstructor +public class BackOfficeSeriesController implements BackOfficeSeriesApi { + @Override + @GetMapping("/admin/series") + public ResponseEntity>> getSeries(...) { + return ResponseEntity.ok(SuccessResponse.of(service.getSeries(...))); + } +} +``` + +### Controller 주의사항 +- Controller에서 직접 `BusinessException` throw 금지 — 검증 어노테이션(`@Valid`, `@NotBlank` 등) 사용. +- 엔티티를 Response로 직접 반환 금지 — 반드시 DTO 변환. +- `@RequestParam`에 `value` 명시: `@RequestParam(value = "page", defaultValue = "0")`. +- `@PathVariable`에 이름 명시: `@PathVariable("memberId")`. +- 페이지/사이즈 파라미터는 `Integer` (boxed type) 사용. + +### 응답 래핑 패턴 +| 상황 | 패턴 | +|------|------| +| 조회 (단건/일반) | `ResponseEntity.ok(SuccessResponse.of(data))` | +| 조회 (페이징) | `ResponseEntity.ok(SuccessResponse.of(PageResponse))` | +| 변경 (반환값 있음) | `SuccessResponse.of(data).asHttp(HttpStatus.OK)` | +| 변경 (반환값 없음) | `ResponseEntity.noContent().build()` (204) | + +--- + +## 4) Service 규칙 + +### 트랜잭션 어노테이션 +- 조회 메서드: `@Transactional(readOnly = true)` +- 변경 메서드: `@Transactional` +- 메서드 단위로 명시하는 것을 기본으로 한다. + +### 예외 처리 +- Service에서 `BusinessException(ErrorCode.XXX)` throw. +- 시스템 예외를 `BusinessException`으로 래핑하지 않는다 — `GlobalExceptionHandler`가 처리. +- `orElseThrow(() -> new BusinessException(ErrorCode.XXX))` 패턴 사용. + +### 페이징 패턴 (3단계) +```java +// 1. Pageable 생성 +Pageable pageable = PageRequest.of(page, size); + +// 2. 조회 +Page mediaPage = repository.findXxx(pageable, ...); + +// 3. DTO 변환 + PageResponse 생성 +List responseList = mediaPage.getContent().stream() + .map(entity -> mapper.toXxxResponse(entity, ...)) + .toList(); +PageInfo pageInfo = PageInfo.toPageInfo(mediaPage.getNumber(), mediaPage.getTotalPages(), mediaPage.getSize()); +return PageResponse.toPageResponse(pageInfo, responseList); +``` + +### Service ↔ Mapper 역할 분리 +- **Service**: 데이터 조회, 추출 (예: `media.getUploader().getNickname()`), 비즈니스 로직 처리. +- **Mapper**: 순수 DTO 변환만 담당. Service에서 추출한 값을 파라미터로 받는다. + +--- + +## 5) DTO 규칙 + +### Response DTO +- **`record`** 사용. +- 클래스 레벨 `@Schema(description = "...")` 필수. +- 모든 필드에 `@Schema(type = "...", description = "...", example = "...")` 필수. +- `type`은 문자열로 명시: `"Long"`, `"String"`, `"List"` 등. + +```java +@Schema(description = "시리즈 목록 조회 응답") +public record SeriesListResponse( + @Schema(type = "Long", description = "미디어 ID", example = "1") + Long mediaId, + @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\"]") + List tagNameList +) {} +``` + +### Request DTO +- 단순 요청: `record` + 필드에 검증 어노테이션. +- 복잡한 요청 (다수 필드, 중첩 검증): `class` + `@Getter @NoArgsConstructor` + 필드에 검증 어노테이션. +- `@Valid @RequestBody`로 바인딩. + +### 공통 +- 내부 전달용 DTO: `class` + `@Getter @AllArgsConstructor` (Swagger 어노테이션 불필요). + +--- + +## 6) Entity 규칙 + +### 어노테이션 순서 (고정) +```java +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Builder +@Getter +@Table(name = "snake_case_table_name") +public class MyEntity extends BaseEntity { ... } +``` + +### 필수 규칙 +- 모든 엔티티는 `BaseEntity` 상속 (제공: `createdDate`, `modifiedDate`, `status`). +- `@Setter` 사용 금지 — 변경은 명시적 비즈니스 메서드로만 (`updateXxx()`, `changeXxx()`, `clearXxx()`). +- 생성은 `static` 팩토리 메서드 또는 `@Builder`. +- 연관관계는 반드시 `FetchType.LAZY`. +- Enum 매핑은 반드시 `@Enumerated(EnumType.STRING)` (ORDINAL 금지). +- `@Column(name = "snake_case")` 명시. +- URL 필드: `columnDefinition = "TEXT"`. + +### 도메인 로직 위치 +- 상태 전이 검증, 불변조건 등은 엔티티 내부 메서드에서 처리. +```java +public void changeRole(Role targetRole) { + if (!this.role.canTransitionTo(targetRole)) + throw new IllegalArgumentException("Invalid role transition"); + this.role = targetRole; +} +``` + +--- + +## 7) Repository 규칙 + +### 3파일 구조 (Custom Repository) +동적 쿼리가 필요한 도메인은 반드시 아래 구조: +``` +XxxRepository.java → extends JpaRepository, XxxRepositoryCustom +XxxRepositoryCustom.java → interface (커스텀 메서드 시그니처) +XxxRepositoryImpl.java → implements XxxRepositoryCustom (QueryDSL 구현) +``` + +### QueryDSL 사용 원칙 +- **JPQL 사용 금지** — 모든 커스텀 쿼리는 QueryDSL로 작성. +- Q-class는 `static import`로 사용: `import static com.ott.domain.xxx.domain.QXxx.xxx;` +- `@RequiredArgsConstructor` + `JPAQueryFactory` 주입. + +### 페이징 쿼리 패턴 +```java +List resultList = queryFactory + .selectFrom(entity) + .where(조건들...) + .orderBy(entity.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + +JPAQuery countQuery = queryFactory + .select(entity.count()) + .from(entity) + .where(조건들...); + +return PageableExecutionUtils.getPage(resultList, pageable, countQuery::fetchOne); +``` + +### 동적 조건 헬퍼 (null-safe BooleanExpression) +필터가 없을 때 `null` 반환 — QueryDSL이 `null` 조건을 무시한다. +```java +private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; +} +``` + +### Fetch Join +- N+1 방지를 위해 연관 엔티티 조회 시 `fetchJoin()` 사용. +- 단건 조회: `.fetchOne()` + `Optional.ofNullable()` 래핑. +- 목록(IN절): 별도 메서드로 분리, `.in()` 사용. +- 단건 vs 목록은 별도 메서드 — 단건에 `List.of(id)` + IN절 사용 금지. + +### 메서드 네이밍 +- `findWith{조인대상}By{조건}`: `findWithMediaAndUploaderByMediaId` +- `findWith{조인대상}By{조건}s` (복수): `findWithTagAndCategoryByMediaIds` + +--- + +## 8) Mapper 규칙 + +### 구현 방식 +- MapStruct 미사용. 순수 `@Component` 클래스로 수동 매핑. +- 메서드명: `to{TargetDtoType}` (예: `toSeriesListResponse`, `toMemberListResponse`). +- 반복되는 추출 로직은 `private` 헬퍼 메서드로 분리 (예: `extractCategoryName`, `extractTagNameList`). +- 스트림에서 메서드 참조로 사용 가능: `mapper::toXxxResponse`. + +--- + +## 9) Enum 규칙 + +- 모든 enum은 `@AllArgsConstructor @Getter` + `key`/`value` 두 필드 구조. +```java +@AllArgsConstructor +@Getter +public enum MediaType { + SERIES("SERIES", "시리즈"), + CONTENTS("CONTENTS", "콘텐츠"), + SHORT_FORM("SHORT_FORM", "숏폼"); + String key; + String value; +} +``` +- 상태 등의 전이는 enum 내부 메서드도 적극 활용. + +--- + +## 10) 네이밍 규칙 + +### 컬렉션 변수 +- 반드시 `도메인의미 + List` 접미사: `mediaIdList`, `tagNameList`, `responseList`, `mediaTagList`. +- 단순 복수형(`tags`, `users`, `items`) 금지. +- DTO 필드, 서비스 로컬 변수, 응답 필드 모두 동일 기준. + +### 클래스 네이밍 +| 대상 | 패턴 | 예시 | +|------|------|------| +| 컨트롤러 (사용자) | `{Domain}Controller` | `SearchController` | +| 컨트롤러 (관리자) | `BackOffice{Domain}Controller` | `BackOfficeSeriesController` | +| API 인터페이스 | `{Domain}Api` / `BackOffice{Domain}Api` | `BackOfficeSeriesApi` | +| 서비스 | `{Domain}Service` / `BackOffice{Domain}Service` | `BackOfficeSeriesService` | +| 매퍼 | `BackOffice{Domain}Mapper` | `BackOfficeSeriesMapper` | +| Request DTO | `{동작}Request` | `AdminLoginRequest`, `ChangeRoleRequest` | +| Response DTO | `{Domain}{List/Detail}Response` | `SeriesListResponse`, `SeriesDetailResponse` | + +### 메서드 네이밍 +| 레이어 | 패턴 | 예시 | +|--------|--------------------------------------------------|------| +| Service (조회) | `get{Domain}List`, `get{Domain}Detail` | `getSeriesList`, `getSeriesDetail` | +| Service (변경) | 동사 + 명사 | `changeRole` | +| Mapper | `to{TargetDto}` | `toSeriesListResponse` | +| Entity (변경) | `update{Field}`, `change{Field}`, `clear{Field}` | `updateRefreshToken` | +| QueryDSL 헬퍼 | 필드명 + 조건 | `titleContains`, `roleEq` | + +[//]: # (| Repository | `findWith{Joins}By{Condition}` | `findWithMediaAndUploaderByMediaId` |) 임시 제거 +--- + +## 11) Lombok 사용 규칙 + +| 어노테이션 | 사용 위치 | 비고 | +|-----------|----------|------| +| `@Getter` | 엔티티, class 기반 DTO | record에는 불필요 | +| `@NoArgsConstructor(PROTECTED)` | 모든 엔티티 | JPA 프록시 용 | +| `@AllArgsConstructor(PRIVATE)` | 모든 엔티티 | Builder 강제 | +| `@Builder` | 엔티티 | 생성 전용 | +| `@RequiredArgsConstructor` | Controller, Service, Mapper, Impl | 생성자 주입 | +| `@Slf4j` | ExceptionHandler, Security 핸들러 | | +| `@Setter` | **사용 금지** | 어디에서든 금지 | + +- `@Autowired` 필드 주입 금지 — `@RequiredArgsConstructor` + `private final` 사용. + +--- + +## 12) 권한/보안 + +- Spring Security: `STATELESS` 세션 + JWT 필터. +- ADMIN 전용 백오피스 API에 `permitAll` 금지. +- `@Bean SecurityFilterChain`에서 URL별 인가 설정. +- `permitAll` 허용 대상: `/actuator/**`, `/swagger-ui/**`, `/v3/api-docs/**` 등 인프라 엔드포인트만. +- EDITOR 제한: 롱폼 업로드, 시리즈 관리, 사용자 관리, 대시보드 접근 불가. + +--- + +## 13) DB/Flyway 규칙 + +- 마이그레이션 경로: `modules/infra/src/main/resources/db/migration` +- 파일명: `V{version}__{description}.sql` +- 운영 모드: `ddl-auto: validate` (Hibernate가 스키마 자동 변경하지 않음) +- Flyway 실행 주체: `api-user` 앱만. +- 스키마 변경 PR은 반드시 SQL + 엔티티 + 영향 범위를 함께 리뷰. + +--- + +## 14) Media 테이블 계층 (V2 구조) + +- `media` 테이블이 공통 부모 (Class Table Inheritance, 비식별 1:1). +- `series`, `contents`, `short_form`은 각자 자체 PK + `media_id` UNIQUE FK. +- 공통 필드(`title`, `description`, `posterUrl`, `thumbnailUrl`, `publicStatus`, `bookmarkCount`, `likesCount`, `uploader`)는 반드시 `Media` 경유 접근. + - `series.getMedia().getTitle()` (O) + - `series.getTitle()` (X — 필드 없음) +- 태그: `media_tag` 테이블 중심. 구 `series_tag`/`contents_tag` 사용 금지. +- `bookmark`/`likes`: `media_id` FK로 통합. 구 `target_id + target_type` 패턴 사용 금지. +- `ingest_job`: `media_id` FK 사용. 구 `contents_id + short_form_id` 패턴 사용 금지. + +--- + +## 15) ErrorCode 체계 + +| 접두사 | 카테고리 | 예시 | +|--------|----------|------| +| C0XX | Common | `INVALID_INPUT`, `MISSING_PARAMETER` | +| A0XX | Auth | `UNAUTHORIZED`, `INVALID_TOKEN`, `EXPIRED_TOKEN` | +| U0XX | User | `USER_NOT_FOUND`, `DUPLICATE_EMAIL` | +| B0XX | Business | `CONTENT_NOT_FOUND`, `SERIES_NOT_FOUND` | + +- 새 에러 코드: `modules/common-web/.../exception/ErrorCode.java`에 추가. + +--- + +## 16) 리뷰 코멘트 형식 + +CodeRabbit이 코멘트를 남길 때 아래 3가지를 포함: +- **심각도**: P0 / P1 / P2 +- **근거**: 파일명:라인번호 +- **수정 제안**: 구체적인 코드 또는 구조 변경안 + +--- + +## 17) 최소 검증 기준 + +- `./gradlew clean build -x test` — 컴파일 성공 +- `./gradlew test` — 테스트 통과 +- DB 변경 포함 PR: Flyway 적용 후 부팅 검증 + +--- + +## 부록: 팀 내 확정 사항 + +아래 항목은 현재 코드베이스에서 일관되지 않거나, 명시적으로 확정되지 않은 부분이다. 팀 내 논의 후 결정하면 위 규칙에 반영한다. + +### A. Response 래핑 스타일 통일 +현재 두 가지 스타일이 혼용됨: +- `ResponseEntity.ok(SuccessResponse.of(service.method()))` — SeriesController, MemberController 선택 + +### B. @Transactional 선언 위치 +현재 두 가지 스타일 혼용: +- 메서드 단위 명시 (BackOfficeSeriesService, BackOfficeMemberService) 선택 + +### C. Request DTO — record vs class 기준 +- `record` (ChangeRoleRequest) 선택 + +### D. Mapper에서의 데이터 추출 범위 +Mapper 내에서 `extractCategoryName`, `extractTagNameList` 같은 가공 로직이 존재한다. +Service가 원시 데이터를 넘기고 Mapper가 가공하는 것. + +### E. 테스트 전략 +- 아직 미정. \ No newline at end of file From aba4efbb91446ad9f353dc8148b3e7a77984fa29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 23 Feb 2026 16:49:28 +0900 Subject: [PATCH 077/257] =?UTF-8?q?[FEAT]:=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=EC=9E=91=EC=97=85=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A9=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/IngestJobListResponse.java | 2 +- .../service/BackOfficeIngestJobService.java | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java index 1fb2944..ffef747 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/dto/response/IngestJobListResponse.java @@ -12,7 +12,7 @@ public record IngestJobListResponse( @Schema(type = "String", description = "미디어(시리즈/콘텐츠/숏폼) 제목", example = "비밀의 숲 1화") String title, - @Schema(type = "Integer", description = "파일 크기 (bytes)", example = "104857600") + @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") Integer videoSize, @Schema(type = "String", description = "업로더 닉네임", example = "홍길동") diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java index f9b86fe..34483ef 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/ingest_job/service/BackOfficeIngestJobService.java @@ -58,7 +58,7 @@ public PageResponse getIngestJobList( List ingestJobList = ingestJobPage.getContent(); - // 3. mediaId를 타입별 분리 + // 3. 타입별로 mediaId 분리 List contentsMediaIdList = ingestJobList.stream() .filter(j -> j.getMedia().getMediaType() == MediaType.CONTENTS) .map(j -> j.getMedia().getId()) @@ -72,15 +72,19 @@ public PageResponse getIngestJobList( // 4. 일괄 조회: mediaId → videoSize 매핑 Map videoSizeByMediaId = new HashMap<>(); - contentsRepository.findAllByMediaIdIn(contentsMediaIdList).forEach( - c -> videoSizeByMediaId.put(c.getMedia().getId(), c.getVideoSize()) - ); + // 5. 빈 리스트면 조회 x + if (!contentsMediaIdList.isEmpty()) { + contentsRepository.findAllByMediaIdIn(contentsMediaIdList).forEach( + c -> videoSizeByMediaId.put(c.getMedia().getId(), c.getVideoSize()) + ); + } - shortFormRepository.findAllByMediaIdIn(shortFormMediaIdList).forEach( - s -> videoSizeByMediaId.put(s.getMedia().getId(), s.getVideoSize()) - ); + if (!shortFormMediaIdList.isEmpty()) { + shortFormRepository.findAllByMediaIdIn(shortFormMediaIdList).forEach( + s -> videoSizeByMediaId.put(s.getMedia().getId(), s.getVideoSize()) + ); + } - // 5. 응답 매핑 List responseList = ingestJobList.stream() .map(j -> backOfficeIngestJobMapper.toIngestJobListResponse(j, videoSizeByMediaId)) .toList(); From d1209470f8d07acbb1f76812fc5aab9be2b340e0 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:30:36 +0900 Subject: [PATCH 078/257] =?UTF-8?q?[FEAT]:=20=EC=BD=98=ED=85=90=EC=B8=A0?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mvn/wrapper/maven-wrapper.jar | Bin 0 -> 50710 bytes .mvn/wrapper/maven-wrapper.properties | 2 + .../dto/response/ContentsListResponse.java | 16 ++-- .../ott/api_user/auth/controller/AuthApi.java | 38 +++------ .../auth/controller/AuthController.java | 22 ++---- .../api_user/auth/service/AuthService.java | 10 +-- .../auth/service/KakaoAuthService.java | 8 +- .../common/dto/ContentListElement.java | 5 ++ .../ott/api_user/content/controller/.gitkeep | 0 .../content/controller/ContentApi.java | 41 ++++++++++ .../content/controller/ContentController.java | 34 ++++++++ .../com/ott/api_user/content/dto/.gitkeep | 0 .../content/dto/ContentDetailResponse.java | 73 ++++++++++++++++++ .../com/ott/api_user/content/service/.gitkeep | 0 .../content/service/ContentService.java | 61 +++++++++++++++ .../api_user/series/controller/SeriesApi.java | 2 +- .../series/dto/SeriesDetailResponse.java | 4 - .../com/ott/domain/common/ContentSource.java | 22 ++++++ .../repository/ContentsRepository.java | 15 +++- .../repository/IngestJobRepositoryImpl.java | 9 +-- .../domain/tag/repository/TagRepository.java | 2 +- 21 files changed, 285 insertions(+), 79 deletions(-) create mode 100644 .mvn/wrapper/maven-wrapper.jar create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/controller/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/dto/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/service/.gitkeep create mode 100644 apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java create mode 100644 modules/domain/src/main/java/com/ott/domain/common/ContentSource.java diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2cc7d4a55c0cd0092912bf49ae38b3a9e3fd0054 GIT binary patch literal 50710 zcmbTd1CVCTmM+|7+wQV$+qP}n>auOywyU~q+qUhh+uxis_~*a##hm*_WW?9E7Pb7N%LRFiwbEGCJ0XP=%-6oeT$XZcYgtzC2~q zk(K08IQL8oTl}>>+hE5YRgXTB@fZ4TH9>7=79e`%%tw*SQUa9~$xKD5rS!;ZG@ocK zQdcH}JX?W|0_Afv?y`-NgLum62B&WSD$-w;O6G0Sm;SMX65z)l%m1e-g8Q$QTI;(Q z+x$xth4KFvH@Bs6(zn!iF#nenk^Y^ce;XIItAoCsow38eq?Y-Auh!1in#Rt-_D>H^ z=EjbclGGGa6VnaMGmMLj`x3NcwA43Jb(0gzl;RUIRAUDcR1~99l2SAPkVhoRMMtN} zXvC<tOmX83grD8GSo_Lo?%lNfhD#EBgPo z*nf@ppMC#B!T)Ae0RG$mlJWmGl7CkuU~B8-==5i;rS;8i6rJ=PoQxf446XDX9g|c> zU64ePyMlsI^V5Jq5A+BPe#e73+kpc_r1tv#B)~EZ;7^67F0*QiYfrk0uVW;Qb=NsG zN>gsuCwvb?s-KQIppEaeXtEMdc9dy6Dfduz-tMTms+i01{eD9JE&h?Kht*$eOl#&L zJdM_-vXs(V#$Ed;5wyNWJdPNh+Z$+;$|%qR(t`4W@kDhd*{(7-33BOS6L$UPDeE_53j${QfKN-0v-HG z(QfyvFNbwPK%^!eIo4ac1;b>c0vyf9}Xby@YY!lkz-UvNp zwj#Gg|4B~?n?G^{;(W;|{SNoJbHTMpQJ*Wq5b{l9c8(%?Kd^1?H1om1de0Da9M;Q=n zUfn{f87iVb^>Exl*nZ0hs(Yt>&V9$Pg`zX`AI%`+0SWQ4Zc(8lUDcTluS z5a_KerZWe}a-MF9#Cd^fi!y3%@RFmg&~YnYZ6<=L`UJ0v={zr)>$A;x#MCHZy1st7 ztT+N07NR+vOwSV2pvWuN1%lO!K#Pj0Fr>Q~R40{bwdL%u9i`DSM4RdtEH#cW)6}+I-eE< z&tZs+(Ogu(H_;$a$!7w`MH0r%h&@KM+<>gJL@O~2K2?VrSYUBbhCn#yy?P)uF3qWU z0o09mIik+kvzV6w>vEZy@&Mr)SgxPzUiDA&%07m17udz9usD82afQEps3$pe!7fUf z0eiidkJ)m3qhOjVHC_M(RYCBO%CZKZXFb8}s0-+}@CIn&EF(rRWUX2g^yZCvl0bI} zbP;1S)iXnRC&}5-Tl(hASKqdSnO?ASGJ*MIhOXIblmEudj(M|W!+I3eDc}7t`^mtg z)PKlaXe(OH+q-)qcQ8a@!llRrpGI8DsjhoKvw9T;TEH&?s=LH0w$EzI>%u;oD@x83 zJL7+ncjI9nn!TlS_KYu5vn%f*@qa5F;| zEFxY&B?g=IVlaF3XNm_03PA)=3|{n-UCgJoTr;|;1AU9|kPE_if8!Zvb}0q$5okF$ zHaJdmO&gg!9oN|M{!qGE=tb|3pVQ8PbL$}e;NgXz<6ZEggI}wO@aBP**2Wo=yN#ZC z4G$m^yaM9g=|&!^ft8jOLuzc3Psca*;7`;gnHm}tS0%f4{|VGEwu45KptfNmwxlE~ z^=r30gi@?cOm8kAz!EylA4G~7kbEiRlRIzwrb~{_2(x^$-?|#e6Bi_**(vyr_~9Of z!n>Gqf+Qwiu!xhi9f53=PM3`3tNF}pCOiPU|H4;pzjcsqbwg*{{kyrTxk<;mx~(;; z1NMrpaQ`57yn34>Jo3b|HROE(UNcQash!0p2-!Cz;{IRv#Vp5!3o$P8!%SgV~k&Hnqhp`5eLjTcy93cK!3Hm-$`@yGnaE=?;*2uSpiZTs_dDd51U%i z{|Zd9ou-;laGS_x=O}a+ zB||za<795A?_~Q=r=coQ+ZK@@ zId~hWQL<%)fI_WDIX#=(WNl!Dm$a&ROfLTd&B$vatq!M-2Jcs;N2vps$b6P1(N}=oI3<3luMTmC|0*{ zm1w8bt7vgX($!0@V0A}XIK)w!AzUn7vH=pZEp0RU0p?}ch2XC-7r#LK&vyc2=-#Q2 z^L%8)JbbcZ%g0Du;|8=q8B>X=mIQirpE=&Ox{TiuNDnOPd-FLI^KfEF729!!0x#Es z@>3ursjFSpu%C-8WL^Zw!7a0O-#cnf`HjI+AjVCFitK}GXO`ME&on|^=~Zc}^LBp9 zj=-vlN;Uc;IDjtK38l7}5xxQF&sRtfn4^TNtnzXv4M{r&ek*(eNbIu!u$>Ed%` z5x7+&)2P&4>0J`N&ZP8$vcR+@FS0126s6+Jx_{{`3ZrIMwaJo6jdrRwE$>IU_JTZ} z(||hyyQ)4Z1@wSlT94(-QKqkAatMmkT7pCycEB1U8KQbFX&?%|4$yyxCtm3=W`$4fiG0WU3yI@c zx{wfmkZAYE_5M%4{J-ygbpH|(|GD$2f$3o_Vti#&zfSGZMQ5_f3xt6~+{RX=$H8at z?GFG1Tmp}}lmm-R->ve*Iv+XJ@58p|1_jRvfEgz$XozU8#iJS})UM6VNI!3RUU!{5 zXB(+Eqd-E;cHQ>)`h0(HO_zLmzR3Tu-UGp;08YntWwMY-9i^w_u#wR?JxR2bky5j9 z3Sl-dQQU$xrO0xa&>vsiK`QN<$Yd%YXXM7*WOhnRdSFt5$aJux8QceC?lA0_if|s> ze{ad*opH_kb%M&~(~&UcX0nFGq^MqjxW?HJIP462v9XG>j(5Gat_)#SiNfahq2Mz2 zU`4uV8m$S~o9(W>mu*=h%Gs(Wz+%>h;R9Sg)jZ$q8vT1HxX3iQnh6&2rJ1u|j>^Qf`A76K%_ubL`Zu?h4`b=IyL>1!=*%!_K)=XC z6d}4R5L+sI50Q4P3upXQ3Z!~1ZXLlh!^UNcK6#QpYt-YC=^H=EPg3)z*wXo*024Q4b2sBCG4I# zlTFFY=kQ>xvR+LsuDUAk)q%5pEcqr(O_|^spjhtpb1#aC& zghXzGkGDC_XDa%t(X`E+kvKQ4zrQ*uuQoj>7@@ykWvF332)RO?%AA&Fsn&MNzmFa$ zWk&&^=NNjxLjrli_8ESU)}U|N{%j&TQmvY~lk!~Jh}*=^INA~&QB9em!in_X%Rl1&Kd~Z(u z9mra#<@vZQlOY+JYUwCrgoea4C8^(xv4ceCXcejq84TQ#sF~IU2V}LKc~Xlr_P=ry zl&Hh0exdCbVd^NPCqNNlxM3vA13EI8XvZ1H9#bT7y*U8Y{H8nwGpOR!e!!}*g;mJ#}T{ekSb}5zIPmye*If(}}_=PcuAW#yidAa^9-`<8Gr0 z)Fz=NiZ{)HAvw{Pl5uu)?)&i&Us$Cx4gE}cIJ}B4Xz~-q7)R_%owbP!z_V2=Aq%Rj z{V;7#kV1dNT9-6R+H}}(ED*_!F=~uz>&nR3gb^Ce%+0s#u|vWl<~JD3MvS0T9thdF zioIG3c#Sdsv;LdtRv3ml7%o$6LTVL>(H`^@TNg`2KPIk*8-IB}X!MT0`hN9Ddf7yN z?J=GxPL!uJ7lqwowsl?iRrh@#5C$%E&h~Z>XQcvFC*5%0RN-Opq|=IwX(dq(*sjs+ zqy99+v~m|6T#zR*e1AVxZ8djd5>eIeCi(b8sUk)OGjAsKSOg^-ugwl2WSL@d#?mdl zib0v*{u-?cq}dDGyZ%$XRY=UkQwt2oGu`zQneZh$=^! zj;!pCBWQNtvAcwcWIBM2y9!*W|8LmQy$H~5BEx)78J`4Z0(FJO2P^!YyQU{*Al+fs z){!4JvT1iLrJ8aU3k0t|P}{RN)_^v%$$r;+p0DY7N8CXzmS*HB*=?qaaF9D@#_$SN zSz{moAK<*RH->%r7xX~9gVW$l7?b|_SYI)gcjf0VAUJ%FcQP(TpBs; zg$25D!Ry_`8xpS_OJdeo$qh#7U+cepZ??TII7_%AXsT$B z=e)Bx#v%J0j``00Zk5hsvv6%T^*xGNx%KN-=pocSoqE5_R)OK%-Pbu^1MNzfds)mL zxz^F4lDKV9D&lEY;I+A)ui{TznB*CE$=9(wgE{m}`^<--OzV-5V4X2w9j(_!+jpTr zJvD*y6;39&T+==$F&tsRKM_lqa1HC}aGL0o`%c9mO=fts?36@8MGm7Vi{Y z^<7m$(EtdSr#22<(rm_(l_(`j!*Pu~Y>>xc>I9M#DJYDJNHO&4=HM%YLIp?;iR&$m z#_$ZWYLfGLt5FJZhr3jpYb`*%9S!zCG6ivNHYzNHcI%khtgHBliM^Ou}ZVD7ehU9 zS+W@AV=?Ro!=%AJ>Kcy9aU3%VX3|XM_K0A+ZaknKDyIS3S-Hw1C7&BSW5)sqj5Ye_ z4OSW7Yu-;bCyYKHFUk}<*<(@TH?YZPHr~~Iy%9@GR2Yd}J2!N9K&CN7Eq{Ka!jdu; zQNB*Y;i(7)OxZK%IHGt#Rt?z`I|A{q_BmoF!f^G}XVeTbe1Wnzh%1g>j}>DqFf;Rp zz7>xIs12@Ke0gr+4-!pmFP84vCIaTjqFNg{V`5}Rdt~xE^I;Bxp4)|cs8=f)1YwHz zqI`G~s2~qqDV+h02b`PQpUE#^^Aq8l%y2|ByQeXSADg5*qMprEAE3WFg0Q39`O+i1 z!J@iV!`Y~C$wJ!5Z+j5$i<1`+@)tBG$JL=!*uk=2k;T<@{|s1$YL079FvK%mPhyHV zP8^KGZnp`(hVMZ;s=n~3r2y;LTwcJwoBW-(ndU-$03{RD zh+Qn$ja_Z^OuMf3Ub|JTY74s&Am*(n{J3~@#OJNYuEVVJd9*H%)oFoRBkySGm`hx! zT3tG|+aAkXcx-2Apy)h^BkOyFTWQVeZ%e2@;*0DtlG9I3Et=PKaPt&K zw?WI7S;P)TWED7aSH$3hL@Qde?H#tzo^<(o_sv_2ci<7M?F$|oCFWc?7@KBj-;N$P zB;q!8@bW-WJY9do&y|6~mEruZAVe$!?{)N9rZZxD-|oltkhW9~nR8bLBGXw<632!l z*TYQn^NnUy%Ds}$f^=yQ+BM-a5X4^GHF=%PDrRfm_uqC zh{sKwIu|O0&jWb27;wzg4w5uA@TO_j(1X?8E>5Zfma|Ly7Bklq|s z9)H`zoAGY3n-+&JPrT!>u^qg9Evx4y@GI4$n-Uk_5wttU1_t?6><>}cZ-U+&+~JE) zPlDbO_j;MoxdLzMd~Ew|1o^a5q_1R*JZ=#XXMzg?6Zy!^hop}qoLQlJ{(%!KYt`MK z8umEN@Z4w!2=q_oe=;QttPCQy3Nm4F@x>@v4sz_jo{4m*0r%J(w1cSo;D_hQtJs7W z><$QrmG^+<$4{d2bgGo&3-FV}avg9zI|Rr(k{wTyl3!M1q+a zD9W{pCd%il*j&Ft z5H$nENf>>k$;SONGW`qo6`&qKs*T z2^RS)pXk9b@(_Fw1bkb)-oqK|v}r$L!W&aXA>IpcdNZ_vWE#XO8X`#Yp1+?RshVcd zknG%rPd*4ECEI0wD#@d+3NbHKxl}n^Sgkx==Iu%}HvNliOqVBqG?P2va zQ;kRJ$J6j;+wP9cS za#m;#GUT!qAV%+rdWolk+)6kkz4@Yh5LXP+LSvo9_T+MmiaP-eq6_k;)i6_@WSJ zlT@wK$zqHu<83U2V*yJ|XJU4farT#pAA&@qu)(PO^8PxEmPD4;Txpio+2)#!9 z>&=i7*#tc0`?!==vk>s7V+PL#S1;PwSY?NIXN2=Gu89x(cToFm))7L;< z+bhAbVD*bD=}iU`+PU+SBobTQ%S!=VL!>q$rfWsaaV}Smz>lO9JXT#`CcH_mRCSf4%YQAw`$^yY z3Y*^Nzk_g$xn7a_NO(2Eb*I=^;4f!Ra#Oo~LLjlcjke*k*o$~U#0ZXOQ5@HQ&T46l z7504MUgZkz2gNP1QFN8Y?nSEnEai^Rgyvl}xZfMUV6QrJcXp;jKGqB=D*tj{8(_pV zqyB*DK$2lgYGejmJUW)*s_Cv65sFf&pb(Yz8oWgDtQ0~k^0-wdF|tj}MOXaN@ydF8 zNr={U?=;&Z?wr^VC+`)S2xl}QFagy;$mG=TUs7Vi2wws5zEke4hTa2)>O0U?$WYsZ z<8bN2bB_N4AWd%+kncgknZ&}bM~eDtj#C5uRkp21hWW5gxWvc6b*4+dn<{c?w9Rmf zIVZKsPl{W2vQAlYO3yh}-{Os=YBnL8?uN5(RqfQ=-1cOiUnJu>KcLA*tQK3FU`_bM zM^T28w;nAj5EdAXFi&Kk1Nnl2)D!M{@+D-}bIEe+Lc4{s;YJc-{F#``iS2uk;2!Zp zF9#myUmO!wCeJIoi^A+T^e~20c+c2C}XltaR!|U-HfDA=^xF97ev}$l6#oY z&-&T{egB)&aV$3_aVA51XGiU07$s9vubh_kQG?F$FycvS6|IO!6q zq^>9|3U^*!X_C~SxX&pqUkUjz%!j=VlXDo$!2VLH!rKj@61mDpSr~7B2yy{>X~_nc zRI+7g2V&k zd**H++P9dg!-AOs3;GM`(g<+GRV$+&DdMVpUxY9I1@uK28$az=6oaa+PutlO9?6#? zf-OsgT>^@8KK>ggkUQRPPgC7zjKFR5spqQb3ojCHzj^(UH~v+!y*`Smv)VpVoPwa6 zWG18WJaPKMi*F6Zdk*kU^`i~NNTfn3BkJniC`yN98L-Awd)Z&mY? zprBW$!qL-OL7h@O#kvYnLsfff@kDIegt~?{-*5A7JrA;#TmTe?jICJqhub-G@e??D zqiV#g{)M!kW1-4SDel7TO{;@*h2=_76g3NUD@|c*WO#>MfYq6_YVUP+&8e4|%4T`w zXzhmVNziAHazWO2qXcaOu@R1MrPP{t)`N)}-1&~mq=ZH=w=;-E$IOk=y$dOls{6sRR`I5>|X zpq~XYW4sd;J^6OwOf**J>a7u$S>WTFPRkjY;BfVgQst)u4aMLR1|6%)CB^18XCz+r ztkYQ}G43j~Q&1em(_EkMv0|WEiKu;z2zhb(L%$F&xWwzOmk;VLBYAZ8lOCziNoPw1 zv2BOyXA`A8z^WH!nXhKXM`t0;6D*-uGds3TYGrm8SPnJJOQ^fJU#}@aIy@MYWz**H zvkp?7I5PE{$$|~{-ZaFxr6ZolP^nL##mHOErB^AqJqn^hFA=)HWj!m3WDaHW$C)i^ z9@6G$SzB=>jbe>4kqr#sF7#K}W*Cg-5y6kun3u&0L7BpXF9=#7IN8FOjWrWwUBZiU zT_se3ih-GBKx+Uw0N|CwP3D@-C=5(9T#BH@M`F2!Goiqx+Js5xC92|Sy0%WWWp={$(am!#l~f^W_oz78HX<0X#7 zp)p1u~M*o9W@O8P{0Qkg@Wa# z2{Heb&oX^CQSZWSFBXKOfE|tsAm#^U-WkDnU;IowZ`Ok4!mwHwH=s|AqZ^YD4!5!@ zPxJj+Bd-q6w_YG`z_+r;S86zwXb+EO&qogOq8h-Ect5(M2+>(O7n7)^dP*ws_3U6v zVsh)sk^@*c>)3EML|0<-YROho{lz@Nd4;R9gL{9|64xVL`n!m$-Jjrx?-Bacp!=^5 z1^T^eB{_)Y<9)y{-4Rz@9_>;_7h;5D+@QcbF4Wv7hu)s0&==&6u)33 zHRj+&Woq-vDvjwJCYES@$C4{$?f$Ibi4G()UeN11rgjF+^;YE^5nYprYoJNoudNj= zm1pXSeG64dcWHObUetodRn1Fw|1nI$D9z}dVEYT0lQnsf_E1x2vBLql7NrHH!n&Sq z6lc*mvU=WS6=v9Lrl}&zRiu_6u;6g%_DU{9b+R z#YHqX7`m9eydf?KlKu6Sb%j$%_jmydig`B*TN`cZL-g!R)iE?+Q5oOqBFKhx z%MW>BC^(F_JuG(ayE(MT{S3eI{cKiwOtPwLc0XO*{*|(JOx;uQOfq@lp_^cZo=FZj z4#}@e@dJ>Bn%2`2_WPeSN7si^{U#H=7N4o%Dq3NdGybrZgEU$oSm$hC)uNDC_M9xc zGzwh5Sg?mpBIE8lT2XsqTt3j3?We8}3bzLBTQd639vyg^$0#1epq8snlDJP2(BF)K zSx30RM+{f+b$g{9usIL8H!hCO117Xgv}ttPJm9wVRjPk;ePH@zxv%j9k5`TzdXLeT zFgFX`V7cYIcBls5WN0Pf6SMBN+;CrQ(|EsFd*xtwr#$R{Z9FP`OWtyNsq#mCgZ7+P z^Yn$haBJ)r96{ZJd8vlMl?IBxrgh=fdq_NF!1{jARCVz>jNdC)H^wfy?R94#MPdUjcYX>#wEx+LB#P-#4S-%YH>t-j+w zOFTI8gX$ard6fAh&g=u&56%3^-6E2tpk*wx3HSCQ+t7+*iOs zPk5ysqE}i*cQocFvA68xHfL|iX(C4h*67@3|5Qwle(8wT&!&{8*{f%0(5gH+m>$tq zp;AqrP7?XTEooYG1Dzfxc>W%*CyL16q|fQ0_jp%%Bk^k!i#Nbi(N9&T>#M{gez_Ws zYK=l}adalV(nH}I_!hNeb;tQFk3BHX7N}}R8%pek^E`X}%ou=cx8InPU1EE0|Hen- zyw8MoJqB5=)Z%JXlrdTXAE)eqLAdVE-=>wGHrkRet}>3Yu^lt$Kzu%$3#(ioY}@Gu zjk3BZuQH&~7H+C*uX^4}F*|P89JX;Hg2U!pt>rDi(n(Qe-c}tzb0#6_ItoR0->LSt zR~UT<-|@TO%O`M+_e_J4wx7^)5_%%u+J=yF_S#2Xd?C;Ss3N7KY^#-vx+|;bJX&8r zD?|MetfhdC;^2WG`7MCgs>TKKN=^=!x&Q~BzmQio_^l~LboTNT=I zC5pme^P@ER``p$2md9>4!K#vV-Fc1an7pl>_|&>aqP}+zqR?+~Z;f2^`a+-!Te%V? z;H2SbF>jP^GE(R1@%C==XQ@J=G9lKX+Z<@5}PO(EYkJh=GCv#)Nj{DkWJM2}F&oAZ6xu8&g7pn1ps2U5srwQ7CAK zN&*~@t{`31lUf`O;2w^)M3B@o)_mbRu{-`PrfNpF!R^q>yTR&ETS7^-b2*{-tZAZz zw@q5x9B5V8Qd7dZ!Ai$9hk%Q!wqbE1F1c96&zwBBaRW}(^axoPpN^4Aw}&a5dMe+*Gomky_l^54*rzXro$ z>LL)U5Ry>~FJi=*{JDc)_**c)-&faPz`6v`YU3HQa}pLtb5K)u%K+BOqXP0)rj5Au$zB zW1?vr?mDv7Fsxtsr+S6ucp2l#(4dnr9sD*v+@*>g#M4b|U?~s93>Pg{{a5|rm2xfI z`>E}?9S@|IoUX{Q1zjm5YJT|3S>&09D}|2~BiMo=z4YEjXlWh)V&qs;*C{`UMxp$9 zX)QB?G$fPD6z5_pNs>Jeh{^&U^)Wbr?2D6-q?)`*1k@!UvwQgl8eG$r+)NnFoT)L6 zg7lEh+E6J17krfYJCSjWzm67hEth24pomhz71|Qodn#oAILN)*Vwu2qpJirG)4Wnv}9GWOFrQg%Je+gNrPl8mw7ykE8{ z=|B4+uwC&bpp%eFcRU6{mxRV32VeH8XxX>v$du<$(DfinaaWxP<+Y97Z#n#U~V zVEu-GoPD=9$}P;xv+S~Ob#mmi$JQmE;Iz4(){y*9pFyW-jjgdk#oG$fl4o9E8bo|L zWjo4l%n51@Kz-n%zeSCD`uB?T%FVk+KBI}=ve zvlcS#wt`U6wrJo}6I6Rwb=1GzZfwE=I&Ne@p7*pH84XShXYJRgvK)UjQL%R9Zbm(m zxzTQsLTON$WO7vM)*vl%Pc0JH7WhP;$z@j=y#avW4X8iqy6mEYr@-}PW?H)xfP6fQ z&tI$F{NNct4rRMSHhaelo<5kTYq+(?pY)Ieh8*sa83EQfMrFupMM@nfEV@EmdHUv9 z35uzIrIuo4#WnF^_jcpC@uNNaYTQ~uZWOE6P@LFT^1@$o&q+9Qr8YR+ObBkpP9=F+$s5+B!mX2~T zAuQ6RenX?O{IlLMl1%)OK{S7oL}X%;!XUxU~xJN8xk z`xywS*naF(J#?vOpB(K=o~lE;m$zhgPWDB@=p#dQIW>xe_p1OLoWInJRKbEuoncf; zmS1!u-ycc1qWnDg5Nk2D)BY%jmOwCLC+Ny>`f&UxFowIsHnOXfR^S;&F(KXd{ODlm z$6#1ccqt-HIH9)|@fHnrKudu!6B$_R{fbCIkSIb#aUN|3RM>zuO>dpMbROZ`^hvS@ z$FU-;e4W}!ubzKrU@R*dW*($tFZ>}dd*4_mv)#O>X{U@zSzQt*83l9mI zI$8O<5AIDx`wo0}f2fsPC_l>ONx_`E7kdXu{YIZbp1$(^oBAH({T~&oQ&1{X951QW zmhHUxd)t%GQ9#ak5fTjk-cahWC;>^Rg7(`TVlvy0W@Y!Jc%QL3Ozu# zDPIqBCy&T2PWBj+d-JA-pxZlM=9ja2ce|3B(^VCF+a*MMp`(rH>Rt6W1$;r{n1(VK zLs>UtkT43LR2G$AOYHVailiqk7naz2yZGLo*xQs!T9VN5Q>eE(w zw$4&)&6xIV$IO^>1N-jrEUg>O8G4^@y+-hQv6@OmF@gy^nL_n1P1-Rtyy$Bl;|VcV zF=p*&41-qI5gG9UhKmmnjs932!6hceXa#-qfK;3d*a{)BrwNFeKU|ge?N!;zk+kB! zMD_uHJR#%b54c2tr~uGPLTRLg$`fupo}cRJeTwK;~}A>(Acy4k-Xk&Aa1&eWYS1ULWUj@fhBiWY$pdfy+F z@G{OG{*v*mYtH3OdUjwEr6%_ZPZ3P{@rfbNPQG!BZ7lRyC^xlMpWH`@YRar`tr}d> z#wz87t?#2FsH-jM6m{U=gp6WPrZ%*w0bFm(T#7m#v^;f%Z!kCeB5oiF`W33W5Srdt zdU?YeOdPG@98H7NpI{(uN{FJdu14r(URPH^F6tOpXuhU7T9a{3G3_#Ldfx_nT(Hec zo<1dyhsVsTw;ZkVcJ_0-h-T3G1W@q)_Q30LNv)W?FbMH+XJ* zy=$@39Op|kZv`Rt>X`zg&at(?PO^I=X8d9&myFEx#S`dYTg1W+iE?vt#b47QwoHI9 zNP+|3WjtXo{u}VG(lLUaW0&@yD|O?4TS4dfJI`HC-^q;M(b3r2;7|FONXphw-%7~* z&;2!X17|05+kZOpQ3~3!Nb>O94b&ZSs%p)TK)n3m=4eiblVtSx@KNFgBY_xV6ts;NF;GcGxMP8OKV^h6LmSb2E#Qnw ze!6Mnz7>lE9u{AgQ~8u2zM8CYD5US8dMDX-5iMlgpE9m*s+Lh~A#P1er*rF}GHV3h z=`STo?kIXw8I<`W0^*@mB1$}pj60R{aJ7>C2m=oghKyxMbFNq#EVLgP0cH3q7H z%0?L93-z6|+jiN|@v>ix?tRBU(v-4RV`}cQH*fp|)vd3)8i9hJ3hkuh^8dz{F5-~_ zUUr1T3cP%cCaTooM8dj|4*M=e6flH0&8ve32Q)0dyisl))XkZ7Wg~N}6y`+Qi2l+e zUd#F!nJp{#KIjbQdI`%oZ`?h=5G^kZ_uN`<(`3;a!~EMsWV|j-o>c?x#;zR2ktiB! z);5rrHl?GPtr6-o!tYd|uK;Vbsp4P{v_4??=^a>>U4_aUXPWQ$FPLE4PK$T^3Gkf$ zHo&9$U&G`d(Os6xt1r?sg14n)G8HNyWa^q8#nf0lbr4A-Fi;q6t-`pAx1T*$eKM*$ z|CX|gDrk#&1}>5H+`EjV$9Bm)Njw&7-ZR{1!CJTaXuP!$Pcg69`{w5BRHysB$(tWUes@@6aM69kb|Lx$%BRY^-o6bjH#0!7b;5~{6J+jKxU!Kmi# zndh@+?}WKSRY2gZ?Q`{(Uj|kb1%VWmRryOH0T)f3cKtG4oIF=F7RaRnH0Rc_&372={_3lRNsr95%ZO{IX{p@YJ^EI%+gvvKes5cY+PE@unghjdY5#9A!G z70u6}?zmd?v+{`vCu-53_v5@z)X{oPC@P)iA3jK$`r zSA2a7&!^zmUiZ82R2=1cumBQwOJUPz5Ay`RLfY(EiwKkrx%@YN^^XuET;tE zmr-6~I7j!R!KrHu5CWGSChO6deaLWa*9LLJbcAJsFd%Dy>a!>J`N)Z&oiU4OEP-!Ti^_!p}O?7`}i7Lsf$-gBkuY*`Zb z7=!nTT;5z$_5$=J=Ko+Cp|Q0J=%oFr>hBgnL3!tvFoLNhf#D0O=X^h+x08iB;@8pXdRHxX}6R4k@i6%vmsQwu^5z zk1ip`#^N)^#Lg#HOW3sPI33xqFB4#bOPVnY%d6prwxf;Y-w9{ky4{O6&94Ra8VN@K zb-lY;&`HtxW@sF!doT5T$2&lIvJpbKGMuDAFM#!QPXW87>}=Q4J3JeXlwHys?!1^#37q_k?N@+u&Ns20pEoBeZC*np;i;M{2C0Z4_br2gsh6eL z#8`#sn41+$iD?^GL%5?cbRcaa-Nx0vE(D=*WY%rXy3B%gNz0l?#noGJGP728RMY#q z=2&aJf@DcR?QbMmN)ItUe+VM_U!ryqA@1VVt$^*xYt~-qvW!J4Tp<-3>jT=7Zow5M z8mSKp0v4b%a8bxFr>3MwZHSWD73D@+$5?nZAqGM#>H@`)mIeC#->B)P8T$zh-Pxnc z8)~Zx?TWF4(YfKuF3WN_ckpCe5;x4V4AA3(i$pm|78{%!q?|~*eH0f=?j6i)n~Hso zmTo>vqEtB)`%hP55INf7HM@taH)v`Fw40Ayc*R!T?O{ziUpYmP)AH`euTK!zg9*6Z z!>M=$3pd0!&TzU=hc_@@^Yd3eUQpX4-33}b{?~5t5lgW=ldJ@dUAH%`l5US1y_`40 zs(X`Qk}vvMDYYq+@Rm+~IyCX;iD~pMgq^KY)T*aBz@DYEB={PxA>)mI6tM*sx-DmGQHEaHwRrAmNjO!ZLHO4b;;5mf@zzlPhkP($JeZGE7 z?^XN}Gf_feGoG~BjUgVa*)O`>lX=$BSR2)uD<9 z>o^|nb1^oVDhQbfW>>!;8-7<}nL6L^V*4pB=>wwW+RXAeRvKED(n1;R`A6v$6gy0I(;Vf?!4;&sgn7F%LpM}6PQ?0%2Z@b{It<(G1CZ|>913E0nR2r^Pa*Bp z@tFGi*CQ~@Yc-?{cwu1 zsilf=k^+Qs>&WZG(3WDixisHpR>`+ihiRwkL(3T|=xsoNP*@XX3BU8hr57l3k;pni zI``=3Nl4xh4oDj<%>Q1zYXHr%Xg_xrK3Nq?vKX3|^Hb(Bj+lONTz>4yhU-UdXt2>j z<>S4NB&!iE+ao{0Tx^N*^|EZU;0kJkx@zh}S^P{ieQjGl468CbC`SWnwLRYYiStXm zOxt~Rb3D{dz=nHMcY)#r^kF8|q8KZHVb9FCX2m^X*(|L9FZg!5a7((!J8%MjT$#Fs)M1Pb zq6hBGp%O1A+&%2>l0mpaIzbo&jc^!oN^3zxap3V2dNj3x<=TwZ&0eKX5PIso9j1;e zwUg+C&}FJ`k(M|%%}p=6RPUq4sT3-Y;k-<68ciZ~_j|bt>&9ZLHNVrp#+pk}XvM{8 z`?k}o-!if>hVlCP9j%&WI2V`5SW)BCeR5>MQhF)po=p~AYN%cNa_BbV6EEh_kk^@a zD>4&>uCGCUmyA-c)%DIcF4R6!>?6T~Mj_m{Hpq`*(wj>foHL;;%;?(((YOxGt)Bhx zuS+K{{CUsaC++%}S6~CJ=|vr(iIs-je)e9uJEU8ZJAz)w166q)R^2XI?@E2vUQ!R% zn@dxS!JcOimXkWJBz8Y?2JKQr>`~SmE2F2SL38$SyR1^yqj8_mkBp)o$@+3BQ~Mid z9U$XVqxX3P=XCKj0*W>}L0~Em`(vG<>srF8+*kPrw z20{z(=^w+ybdGe~Oo_i|hYJ@kZl*(9sHw#Chi&OIc?w`nBODp?ia$uF%Hs(X>xm?j zqZQ`Ybf@g#wli`!-al~3GWiE$K+LCe=Ndi!#CVjzUZ z!sD2O*;d28zkl))m)YN7HDi^z5IuNo3^w(zy8 zszJG#mp#Cj)Q@E@r-=NP2FVxxEAeOI2e=|KshybNB6HgE^(r>HD{*}S}mO>LuRGJT{*tfTzw_#+er-0${}%YPe@CMJ1Ng#j#)i)SnY@ss3gL;g zg2D~#Kpdfu#G;q1qz_TwSz1VJT(b3zby$Vk&;Y#1(A)|xj`_?i5YQ;TR%jice5E;0 zYHg;`zS5{S*9xI6o^j>rE8Ua*XhIw{_-*&@(R|C(am8__>+Ws&Q^ymy*X4~hR2b5r zm^p3sw}yv=tdyncy_Ui7{BQS732et~Z_@{-IhHDXAV`(Wlay<#hb>%H%WDi+K$862nA@BDtM#UCKMu+kM`!JHyWSi?&)A7_ z3{cyNG%a~nnH_!+;g&JxEMAmh-Z}rC!o7>OVzW&PoMyTA_g{hqXG)SLraA^OP**<7 zjWbr7z!o2n3hnx7A=2O=WL;`@9N{vQIM@&|G-ljrPvIuJHYtss0Er0fT5cMXNUf1B z7FAwBDixt0X7C3S)mPe5g`YtME23wAnbU)+AtV}z+e8G;0BP=bI;?(#|Ep!vVfDbK zvx+|CKF>yt0hWQ3drchU#XBU+HiuG*V^snFAPUp-5<#R&BUAzoB!aZ+e*KIxa26V}s6?nBK(U-7REa573wg-jqCg>H8~>O{ z*C0JL-?X-k_y%hpUFL?I>0WV{oV`Nb)nZbJG01R~AG>flIJf)3O*oB2i8~;!P?Wo_ z0|QEB*fifiL6E6%>tlAYHm2cjTFE@*<);#>689Z6S#BySQ@VTMhf9vYQyLeDg1*F} zjq>i1*x>5|CGKN{l9br3kB0EHY|k4{%^t7-uhjd#NVipUZa=EUuE5kS1_~qYX?>hJ z$}!jc9$O$>J&wnu0SgfYods^z?J4X;X7c77Me0kS-dO_VUQ39T(Kv(Y#s}Qqz-0AH z^?WRL(4RzpkD+T5FG_0NyPq-a-B7A5LHOCqwObRJi&oRi(<;OuIN7SV5PeHU$<@Zh zPozEV`dYmu0Z&Tqd>t>8JVde9#Pt+l95iHe$4Xwfy1AhI zDM4XJ;bBTTvRFtW>E+GzkN)9k!hA5z;xUOL2 zq4}zn-DP{qc^i|Y%rvi|^5k-*8;JZ~9a;>-+q_EOX+p1Wz;>i7c}M6Nv`^NY&{J-> z`(mzDJDM}QPu5i44**2Qbo(XzZ-ZDu%6vm8w@DUarqXj41VqP~ zs&4Y8F^Waik3y1fQo`bVUH;b=!^QrWb)3Gl=QVKr+6sxc=ygauUG|cm?|X=;Q)kQ8 zM(xrICifa2p``I7>g2R~?a{hmw@{!NS5`VhH8+;cV(F>B94M*S;5#O`YzZH1Z%yD? zZ61w(M`#aS-*~Fj;x|J!KM|^o;MI#Xkh0ULJcA?o4u~f%Z^16ViA27FxU5GM*rKq( z7cS~MrZ=f>_OWx8j#-Q3%!aEU2hVuTu(7`TQk-Bi6*!<}0WQi;_FpO;fhpL4`DcWp zGOw9vx0N~6#}lz(r+dxIGZM3ah-8qrqMmeRh%{z@dbUD2w15*_4P?I~UZr^anP}DB zU9CCrNiy9I3~d#&!$DX9e?A});BjBtQ7oGAyoI$8YQrkLBIH@2;lt4E^)|d6Jwj}z z&2_E}Y;H#6I4<10d_&P0{4|EUacwFHauvrjAnAm6yeR#}f}Rk27CN)vhgRqEyPMMS7zvunj2?`f;%?alsJ+-K+IzjJx>h8 zu~m_y$!J5RWAh|C<6+uiCNsOKu)E72M3xKK(a9Okw3e_*O&}7llNV!=P87VM2DkAk zci!YXS2&=P0}Hx|wwSc9JP%m8dMJA*q&VFB0yMI@5vWoAGraygwn){R+Cj6B1a2Px z5)u(K5{+;z2n*_XD!+Auv#LJEM)(~Hx{$Yb^ldQmcYF2zNH1V30*)CN_|1$v2|`LnFUT$%-tO0Eg|c5$BB~yDfzS zcOXJ$wpzVK0MfTjBJ0b$r#_OvAJ3WRt+YOLlJPYMx~qp>^$$$h#bc|`g0pF-Ao43? z>*A+8lx>}L{p(Tni2Vvk)dtzg$hUKjSjXRagj)$h#8=KV>5s)J4vGtRn5kP|AXIz! zPgbbVxW{2o4s-UM;c#We8P&mPN|DW7_uLF!a|^0S=wr6Esx9Z$2|c1?GaupU6$tb| zY_KU`(_29O_%k(;>^|6*pZURH3`@%EuKS;Ns z1lujmf;r{qAN&Q0&m{wJSZ8MeE7RM5+Sq;ul_ z`+ADrd_Um+G37js6tKsArNB}n{p*zTUxQr>3@wA;{EUbjNjlNd6$Mx zg0|MyU)v`sa~tEY5$en7^PkC=S<2@!nEdG6L=h(vT__0F=S8Y&eM=hal#7eM(o^Lu z2?^;05&|CNliYrq6gUv;|i!(W{0N)LWd*@{2q*u)}u*> z7MQgk6t9OqqXMln?zoMAJcc zMKaof_Up})q#DzdF?w^%tTI7STI^@8=Wk#enR*)&%8yje>+tKvUYbW8UAPg55xb70 zEn5&Ba~NmOJlgI#iS8W3-@N%>V!#z-ZRwfPO1)dQdQkaHsiqG|~we2ALqG7Ruup(DqSOft2RFg_X%3w?6VqvV1uzX_@F(diNVp z4{I|}35=11u$;?|JFBEE*gb;T`dy+8gWJ9~pNsecrO`t#V9jW-6mnfO@ff9od}b(3s4>p0i30gbGIv~1@a^F2kl7YO;DxmF3? zWi-RoXhzRJV0&XE@ACc?+@6?)LQ2XNm4KfalMtsc%4!Fn0rl zpHTrHwR>t>7W?t!Yc{*-^xN%9P0cs0kr=`?bQ5T*oOo&VRRu+1chM!qj%2I!@+1XF z4GWJ=7ix9;Wa@xoZ0RP`NCWw0*8247Y4jIZ>GEW7zuoCFXl6xIvz$ezsWgKdVMBH> z{o!A7f;R-@eK9Vj7R40xx)T<2$?F2E<>Jy3F;;=Yt}WE59J!1WN367 zA^6pu_zLoZIf*x031CcwotS{L8bJE(<_F%j_KJ2P_IusaZXwN$&^t716W{M6X2r_~ zaiMwdISX7Y&Qi&Uh0upS3TyEIXNDICQlT5fHXC`aji-c{U(J@qh-mWl-uMN|T&435 z5)a1dvB|oe%b2mefc=Vpm0C%IUYYh7HI*;3UdgNIz}R##(#{(_>82|zB0L*1i4B5j-xi9O4x10rs_J6*gdRBX=@VJ+==sWb&_Qc6tSOowM{BX@(zawtjl zdU!F4OYw2@Tk1L^%~JCwb|e#3CC>srRHQ*(N%!7$Mu_sKh@|*XtR>)BmWw!;8-mq7 zBBnbjwx8Kyv|hd*`5}84flTHR1Y@@uqjG`UG+jN_YK&RYTt7DVwfEDXDW4U+iO{>K zw1hr{_XE*S*K9TzzUlJH2rh^hUm2v7_XjwTuYap|>zeEDY$HOq3X4Tz^X}E9z)x4F zs+T?Ed+Hj<#jY-`Va~fT2C$=qFT-5q$@p9~0{G&eeL~tiIAHXA!f6C(rAlS^)&k<- zXU|ZVs}XQ>s5iONo~t!XXZgtaP$Iau;JT%h)>}v54yut~pykaNye4axEK#5@?TSsQ zE;Jvf9I$GVb|S`7$pG)4vgo9NXsKr?u=F!GnA%VS2z$@Z(!MR9?EPcAqi5ft)Iz6sNl`%kj+_H-X`R<>BFrBW=fSlD|{`D%@Rcbu2?%>t7i34k?Ujb)2@J-`j#4 zLK<69qcUuniIan-$A1+fR=?@+thwDIXtF1Tks@Br-xY zfB+zblrR(ke`U;6U~-;p1Kg8Lh6v~LjW@9l2P6s+?$2!ZRPX`(ZkRGe7~q(4&gEi<$ch`5kQ?*1=GSqkeV z{SA1EaW_A!t{@^UY2D^YO0(H@+kFVzZaAh0_`A`f(}G~EP~?B|%gtxu&g%^x{EYSz zk+T;_c@d;+n@$<>V%P=nk36?L!}?*=vK4>nJSm+1%a}9UlmTJTrfX4{Lb7smNQn@T zw9p2%(Zjl^bWGo1;DuMHN(djsEm)P8mEC2sL@KyPjwD@d%QnZ$ zMJ3cnn!_!iP{MzWk%PI&D?m?C(y2d|2VChluN^yHya(b`h>~GkI1y;}O_E57zOs!{ zt2C@M$^PR2U#(dZmA-sNreB@z-yb0Bf7j*yONhZG=onhx>t4)RB`r6&TP$n zgmN*)eCqvgriBO-abHQ8ECN0bw?z5Bxpx z=jF@?zFdVn?@gD5egM4o$m`}lV(CWrOKKq(sv*`mNcHcvw&Xryfw<{ch{O&qc#WCTXX6=#{MV@q#iHYba!OUY+MGeNTjP%Fj!WgM&`&RlI^=AWTOqy-o zHo9YFt!gQ*p7{Fl86>#-JLZo(b^O`LdFK~OsZBRR@6P?ad^Ujbqm_j^XycM4ZHFyg ziUbIFW#2tj`65~#2V!4z7DM8Z;fG0|APaQ{a2VNYpNotB7eZ5kp+tPDz&Lqs0j%Y4tA*URpcfi z_M(FD=fRGdqf430j}1z`O0I=;tLu81bwJXdYiN7_&a-?ly|-j*+=--XGvCq#32Gh(=|qj5F?kmihk{%M&$}udW5)DHK zF_>}5R8&&API}o0osZJRL3n~>76nUZ&L&iy^s>PMnNcYZ|9*1$v-bzbT3rpWsJ+y{ zPrg>5Zlery96Um?lc6L|)}&{992{_$J&=4%nRp9BAC6!IB=A&=tF>r8S*O-=!G(_( zwXbX_rGZgeiK*&n5E;f=k{ktyA1(;x_kiMEt0*gpp_4&(twlS2e5C?NoD{n>X2AT# zY@Zp?#!b1zNq96MQqeO*M1MMBin5v#RH52&Xd~DO6-BZLnA6xO1$sou(YJ1Dlc{WF zVa%2DyYm`V#81jP@70IJ;DX@y*iUt$MLm)ByAD$eUuji|5{ptFYq(q)mE(5bOpxjM z^Q`AHWq44SG3`_LxC9fwR)XRVIp=B%<(-lOC3jI#bb@dK(*vjom!=t|#<@dZql%>O z15y^{4tQoeW9Lu%G&V$90x6F)xN6y_oIn;!Q zs)8jT$;&;u%Y>=T3hg34A-+Y*na=|glcStr5D;&5*t5*DmD~x;zQAV5{}Ya`?RRGa zT*t9@$a~!co;pD^!J5bo?lDOWFx%)Y=-fJ+PDGc0>;=q=s?P4aHForSB+)v0WY2JH z?*`O;RHum6j%#LG)Vu#ciO#+jRC3!>T(9fr+XE7T2B7Z|0nR5jw@WG)kDDzTJ=o4~ zUpeyt7}_nd`t}j9BKqryOha{34erm)RmST)_9Aw)@ zHbiyg5n&E{_CQR@h<}34d7WM{s{%5wdty1l+KX8*?+-YkNK2Be*6&jc>@{Fd;Ps|| z26LqdI3#9le?;}risDq$K5G3yoqK}C^@-8z^wj%tdgw-6@F#Ju{Sg7+y)L?)U$ez> zoOaP$UFZ?y5BiFycir*pnaAaY+|%1%8&|(@VB)zweR%?IidwJyK5J!STzw&2RFx zZV@qeaCB01Hu#U9|1#=Msc8Pgz5P*4Lrp!Q+~(G!OiNR{qa7|r^H?FC6gVhkk3y7=uW#Sh;&>78bZ}aK*C#NH$9rX@M3f{nckYI+5QG?Aj1DM)@~z_ zw!UAD@gedTlePB*%4+55naJ8ak_;))#S;4ji!LOqY5VRI){GMwHR~}6t4g>5C_#U# ztYC!tjKjrKvRy=GAsJVK++~$|+s!w9z3H4G^mACv=EErXNSmH7qN}%PKcN|8%9=i)qS5+$L zu&ya~HW%RMVJi4T^pv?>mw*Gf<)-7gf#Qj|e#w2|v4#t!%Jk{&xlf;$_?jW*n!Pyx zkG$<18kiLOAUPuFfyu-EfWX%4jYnjBYc~~*9JEz6oa)_R|8wjZA|RNrAp%}14L7fW zi7A5Wym*K+V8pkqqO-X#3ft{0qs?KVt^)?kS>AicmeO&q+~J~ zp0YJ_P~_a8j= zsAs~G=8F=M{4GZL{|B__UorX@MRNQLn?*_gym4aW(~+i13knnk1P=khoC-ViMZk+x zLW(l}oAg1H`dU+Fv**;qw|ANDSRs>cGqL!Yw^`; zv;{E&8CNJcc)GHzTYM}f&NPw<6j{C3gaeelU#y!M)w-utYEHOCCJo|Vgp7K6C_$14 zqIrLUB0bsgz^D%V%fbo2f9#yb#CntTX?55Xy|Kps&Xek*4_r=KDZ z+`TQuv|$l}MWLzA5Ay6Cvsa^7xvwXpy?`w(6vx4XJ zWuf1bVSb#U8{xlY4+wlZ$9jjPk)X_;NFMqdgq>m&W=!KtP+6NL57`AMljW+es zzqjUjgz;V*kktJI?!NOg^s_)ph45>4UDA!Vo0hn>KZ+h-3=?Y3*R=#!fOX zP$Y~+14$f66ix?UWB_6r#fMcC^~X4R-<&OD1CSDNuX~y^YwJ>sW0j`T<2+3F9>cLo z#!j57$ll2K9(%$4>eA7(>FJX5e)pR5&EZK!IMQzOfik#FU*o*LGz~7u(8}XzIQRy- z!U7AlMTIe|DgQFmc%cHy_9^{o`eD%ja_L>ckU6$O4*U**o5uR7`FzqkU8k4gxtI=o z^P^oGFPm5jwZMI{;nH}$?p@uV8FT4r=|#GziKXK07bHJLtK}X%I0TON$uj(iJ`SY^ zc$b2CoxCQ>7LH@nxcdW&_C#fMYBtTxcg46dL{vf%EFCZ~eErMvZq&Z%Lhumnkn^4A zsx$ay(FnN7kYah}tZ@0?-0Niroa~13`?hVi6`ndno`G+E8;$<6^gsE-K3)TxyoJ4M zb6pj5=I8^FD5H@`^V#Qb2^0cx7wUz&cruA5g>6>qR5)O^t1(-qqP&1g=qvY#s&{bx zq8Hc%LsbK1*%n|Y=FfojpE;w~)G0-X4i*K3{o|J7`krhIOd*c*$y{WIKz2n2*EXEH zT{oml3Th5k*vkswuFXdGDlcLj15Nec5pFfZ*0?XHaF_lVuiB%Pv&p7z)%38}%$Gup zVTa~C8=cw%6BKn_|4E?bPNW4PT7}jZQLhDJhvf4z;~L)506IE0 zX!tWXX(QOQPRj-p80QG79t8T2^az4Zp2hOHziQlvT!|H)jv{Ixodabzv6lBj)6WRB z{)Kg@$~~(7$-az?lw$4@L%I&DI0Lo)PEJJziWP33a3azb?jyXt1v0N>2kxwA6b%l> zZqRpAo)Npi&loWbjFWtEV)783BbeIAhqyuc+~>i7aQ8shIXt)bjCWT6$~ro^>99G} z2XfmT0(|l!)XJb^E!#3z4oEGIsL(xd; zYX1`1I(cG|u#4R4T&C|m*9KB1`UzKvho5R@1eYtUL9B72{i(ir&ls8g!pD ztR|25xGaF!4z5M+U@@lQf(12?xGy`!|3E}7pI$k`jOIFjiDr{tqf0va&3pOn6Pu)% z@xtG2zjYuJXrV)DUrIF*y<1O1<$#54kZ#2;=X51J^F#0nZ0(;S$OZDt_U2bx{RZ=Q zMMdd$fH|!s{ zXq#l;{`xfV`gp&C>A`WrQU?d{!Ey5(1u*VLJt>i27aZ-^&2IIk=zP5p+{$q(K?2(b z8?9h)kvj9SF!Dr zoyF}?V|9;6abHxWk2cEvGs$-}Pg}D+ZzgkaN&$Snp%;5m%zh1E#?Wac-}x?BYlGN#U#Mek*}kek#I9XaHt?mz3*fDrRTQ#&#~xyeqJk1QJ~E$7qsw6 z?sV;|?*=-{M<1+hXoj?@-$y+(^BJ1H~wQ9G8C0#^aEAyhDduNX@haoa=PuPp zYsGv8UBfQaRHgBgLjmP^eh>fLMeh{8ic)?xz?#3kX-D#Z{;W#cd_`9OMFIaJg-=t`_3*!YDgtNQ2+QUEAJB9M{~AvT$H`E)IKmCR21H532+ata8_i_MR@ z2Xj<3w<`isF~Ah$W{|9;51ub*f4#9ziKrOR&jM{x7I_7()O@`F*5o$KtZ?fxU~g`t zUovNEVKYn$U~VX8eR)qb`7;D8pn*Pp$(otYTqL)5KH$lUS-jf}PGBjy$weoceAcPp z&5ZYB$r&P$MN{0H0AxCe4Qmd3T%M*5d4i%#!nmBCN-WU-4m4Tjxn-%j3HagwTxCZ9 z)j5vO-C7%s%D!&UfO>bi2oXiCw<-w{vVTK^rVbv#W=WjdADJy8$khnU!`ZWCIU`># zyjc^1W~pcu>@lDZ{zr6gv%)2X4n27~Ve+cQqcND%0?IFSP4sH#yIaXXYAq^z3|cg` z`I3$m%jra>e2W-=DiD@84T!cb%||k)nPmEE09NC%@PS_OLhkrX*U!cgD*;;&gIaA(DyVT4QD+q_xu z>r`tg{hiGY&DvD-)B*h+YEd+Zn)WylQl}<4>(_NlsKXCRV;a)Rcw!wtelM2_rWX`j zTh5A|i6=2BA(iMCnj_fob@*eA;V?oa4Z1kRBGaU07O70fb6-qmA$Hg$ps@^ka1=RO zTbE_2#)1bndC3VuK@e!Sftxq4=Uux}fDxXE#Q5_x=E1h>T5`DPHz zbH<_OjWx$wy7=%0!mo*qH*7N4tySm+R0~(rbus`7;+wGh;C0O%x~fEMkt!eV>U$`i z5>Q(o z=t$gPjgGh0&I7KY#k50V7DJRX<%^X z>6+ebc9efB3@eE2Tr){;?_w`vhgF>`-GDY(YkR{9RH(MiCnyRtd!LxXJ75z+?2 zGi@m^+2hKJ5sB1@Xi@s_@p_Kwbc<*LQ_`mr^Y%j}(sV_$`J(?_FWP)4NW*BIL~sR>t6 zM;qTJZ~GoY36&{h-Pf}L#y2UtR}>ZaI%A6VkU>vG4~}9^i$5WP2Tj?Cc}5oQxe2=q z8BeLa$hwCg_psjZyC2+?yX4*hJ58Wu^w9}}7X*+i5Rjqu5^@GzXiw#SUir1G1`jY% zOL=GE_ENYxhcyUrEt9XlMNP6kx6h&%6^u3@zB8KUCAa18T(R2J`%JjWZ z!{7cXaEW+Qu*iJPu+m>QqW}Lo$4Z+!I)0JNzZ&_M%=|B1yejFRM04bGAvu{=lNPd+ zJRI^DRQ(?FcVUD+bgEcAi@o(msqys9RTCG#)TjI!9~3-dc`>gW;HSJuQvH~d`MQs86R$|SKXHh zqS9Qy)u;T`>>a!$LuaE2keJV%;8g)tr&Nnc;EkvA-RanHXsy)D@XN0a>h}z2j81R; zsUNJf&g&rKpuD0WD@=dDrPHdBoK42WoBU|nMo17o(5^;M|dB4?|FsAGVrSyWcI`+FVw^vTVC`y}f(BwJl zrw3Sp151^9=}B})6@H*i4-dIN_o^br+BkcLa^H56|^2XsT0dESw2 zMX>(KqNl=x2K5=zIKg}2JpGAZu{I_IO}0$EQ5P{4zol**PCt3F4`GX}2@vr8#Y)~J zKb)gJeHcFnR@4SSh%b;c%J`l=W*40UPjF#q{<}ywv-=vHRFmDjv)NtmC zQx9qm)d%0zH&qG7AFa3VAU1S^(n8VFTC~Hb+HjYMjX8r#&_0MzlNR*mnLH5hi}`@{ zK$8qiDDvS_(L9_2vHgzEQ${DYSE;DqB!g*jhJghE&=LTnbgl&Xepo<*uRtV{2wDHN z)l;Kg$TA>Y|K8Lc&LjWGj<+bp4Hiye_@BfU(y#nF{fpR&|Ltbye?e^j0}8JC4#xi% zv29ZR%8%hk=3ZDvO-@1u8KmQ@6p%E|dlHuy#H1&MiC<*$YdLkHmR#F3ae;bKd;@*i z2_VfELG=B}JMLCO-6UQy^>RDE%K4b>c%9ki`f~Z2Qu8hO7C#t%Aeg8E%+}6P7Twtg z-)dj(w}_zFK&86KR@q9MHicUAucLVshUdmz_2@32(V`y3`&Kf8Q2I)+!n0mR=rrDU zXvv^$ho;yh*kNqJ#r1}b0|i|xRUF6;lhx$M*uG3SNLUTC@|htC z-=fsw^F%$qqz4%QdjBrS+ov}Qv!z00E+JWas>p?z@=t!WWU3K*?Z(0meTuTOC7OTx zU|kFLE0bLZ+WGcL$u4E}5dB0g`h|uwv3=H6f+{5z9oLv-=Q45+n~V4WwgO=CabjM% zBAN+RjM65(-}>Q2V#i1Na@a0`08g&y;W#@sBiX6Tpy8r}*+{RnyGUT`?XeHSqo#|J z^ww~c;ou|iyzpErDtlVU=`8N7JSu>4M z_pr9=tX0edVn9B}YFO2y(88j#S{w%E8vVOpAboK*27a7e4Ekjt0)hIX99*1oE;vex z7#%jhY=bPijA=Ce@9rRO(Vl_vnd00!^TAc<+wVvRM9{;hP*rqEL_(RzfK$er_^SN; z)1a8vo8~Dr5?;0X0J62Cusw$A*c^Sx1)dom`-)Pl7hsW4i(r*^Mw`z5K>!2ixB_mu z*Ddqjh}zceRFdmuX1akM1$3>G=#~|y?eYv(e-`Qy?bRHIq=fMaN~fB zUa6I8Rt=)jnplP>yuS+P&PxeWpJ#1$F`iqRl|jF$WL_aZFZl@kLo&d$VJtu&w?Q0O zzuXK>6gmygq(yXJy0C1SL}T8AplK|AGNUOhzlGeK_oo|haD@)5PxF}rV+5`-w{Aag zus45t=FU*{LguJ11Sr-28EZkq;!mJO7AQGih1L4rEyUmp>B!%X0YemsrV3QFvlgt* z5kwlPzaiJ+kZ^PMd-RRbl(Y?F*m`4*UIhIuf#8q>H_M=fM*L_Op-<_r zBZagV=4B|EW+KTja?srADTZXCd3Yv%^Chfpi)cg{ED${SI>InNpRj5!euKv?=Xn92 zsS&FH(*w`qLIy$doc>RE&A5R?u zzkl1sxX|{*fLpXvIW>9d<$ePROttn3oc6R!sN{&Y+>Jr@yeQN$sFR z;w6A<2-0%UA?c8Qf;sX7>>uKRBv3Ni)E9pI{uVzX|6Bb0U)`lhLE3hK58ivfRs1}d zNjlGK0hdq0qjV@q1qI%ZFMLgcpWSY~mB^LK)4GZ^h_@H+3?dAe_a~k*;9P_d7%NEFP6+ zgV(oGr*?W(ql?6SQ~`lUsjLb%MbfC4V$)1E0Y_b|OIYxz4?O|!kRb?BGrgiH5+(>s zoqM}v*;OBfg-D1l`M6T6{K`LG+0dJ1)!??G5g(2*vlNkm%Q(MPABT$r13q?|+kL4- zf)Mi5r$sn;u41aK(K#!m+goyd$c!KPl~-&-({j#D4^7hQkV3W|&>l_b!}!z?4($OA z5IrkfuT#F&S1(`?modY&I40%gtroig{YMvF{K{>5u^I51k8RriGd${z)=5k2tG zM|&Bp5kDTfb#vfuTTd?)a=>bX=lokw^y9+2LS?kwHQIWI~pYgy7 zb?A-RKVm_vM5!9?C%qYdfRAw& zAU7`up~%g=p@}pg#b7E)BFYx3g%(J36Nw(Dij!b>cMl@CSNbrW!DBDbTD4OXk!G4x zi}JBKc8HBYx$J~31PXH+4^x|UxK~(<@I;^3pWN$E=sYma@JP|8YL`L(zI6Y#c%Q{6 z*APf`DU$S4pr#_!60BH$FGViP14iJmbrzSrOkR;f3YZa{#E7Wpd@^4E-zH8EgPc-# zKWFPvh%WbqU_%ZEt`=Q?odKHc7@SUmY{GK`?40VuL~o)bS|is$Hn=<=KGHOsEC5tB zFb|q}gGlL97NUf$G$>^1b^3E18PZ~Pm9kX%*ftnolljiEt@2#F2R5ah$zbXd%V_Ev zyDd{1o_uuoBga$fB@Fw!V5F3jIr=a-ykqrK?WWZ#a(bglI_-8pq74RK*KfQ z0~Dzus7_l;pMJYf>Bk`)`S8gF!To-BdMnVw5M-pyu+aCiC5dwNH|6fgRsIKZcF&)g zr}1|?VOp}I3)IR@m1&HX1~#wsS!4iYqES zK}4J{Ei>;e3>LB#Oly>EZkW14^@YmpbgxCDi#0RgdM${&wxR+LiX}B+iRioOB0(pDKpVEI;ND?wNx>%e|m{RsqR_{(nmQ z3ZS}@t!p4a(BKx_-CYwrcyJ5u1TO9bcXti$8sy>xcLKqKCc#~UOZYD{llKTSFEjJ~ zyNWt>tLU}*>^`TvPxtP%F`ZJQw@W0^>x;!^@?k_)9#bF$j0)S3;mH-IR5y82l|%=F z2lR8zhP?XNP-ucZZ6A+o$xOyF!w;RaLHGh57GZ|TCXhJqY~GCh)aXEV$1O&$c}La1 zjuJxkY9SM4av^Hb;i7efiYaMwI%jGy`3NdY)+mcJhF(3XEiSlU3c|jMBi|;m-c?~T z+x0_@;SxcoY=(6xNgO$bBt~Pj8`-<1S|;Bsjrzw3@zSjt^JC3X3*$HI79i~!$RmTz zsblZsLYs7L$|=1CB$8qS!tXrWs!F@BVuh?kN(PvE5Av-*r^iYu+L^j^m9JG^#=m>@ z=1soa)H*w6KzoR$B8mBCXoU;f5^bVuwQ3~2LKg!yxomG1#XPmn(?YH@E~_ED+W6mxs%x{%Z<$pW`~ON1~2XjP5v(0{C{+6Dm$00tsd3w=f=ZENy zOgb-=f}|Hb*LQ$YdWg<(u7x3`PKF)B7ZfZ6;1FrNM63 z?O6tE%EiU@6%rVuwIQjvGtOofZBGZT1Sh(xLIYt9c4VI8`!=UJd2BfLjdRI#SbVAX ziT(f*RI^T!IL5Ac>ql7uduF#nuCRJ1)2bdvAyMxp-5^Ww5p#X{rb5)(X|fEhDHHW{ zw(Lfc$g;+Q`B0AiPGtmK%*aWfQQ$d!*U<|-@n2HZvCWSiw^I>#vh+LyC;aaVWGbmkENr z&kl*8o^_FW$T?rDYLO1Pyi%>@&kJKQoH2E0F`HjcN}Zlnx1ddoDA>G4Xu_jyp6vuT zPvC}pT&Owx+qB`zUeR|4G;OH(<<^_bzkjln0k40t`PQxc$7h(T8Ya~X+9gDc8Z9{Z z&y0RAU}#_kQGrM;__MK9vwIwK^aoqFhk~dK!ARf1zJqHMxF2?7-8|~yoO@_~Ed;_wvT%Vs{9RK$6uUQ|&@#6vyBsFK9eZW1Ft#D2)VpQRwpR(;x^ zdoTgMqfF9iBl%{`QDv7B0~8{8`8k`C4@cbZAXBu00v#kYl!#_Wug{)2PwD5cNp?K^ z9+|d-4z|gZ!L{57>!Ogfbzchm>J1)Y%?NThxIS8frAw@z>Zb9v%3_3~F@<=LG%r*U zaTov}{{^z~SeX!qgSYow`_5)ij*QtGp4lvF`aIGQ>@3ZTkDmsl#@^5*NGjOuu82}o zzLF~Q9SW+mP=>88%eSA1W4_W7-Q>rdq^?t=m6}^tDPaBRGFLg%ak93W!kOp#EO{6& zP%}Iff5HZQ9VW$~+9r=|Quj#z*=YwcnssS~9|ub2>v|u1JXP47vZ1&L1O%Z1DsOrDfSIMHU{VT>&>H=9}G3i@2rP+rx@eU@uE8rJNec zij~#FmuEBj03F1~ct@C@$>y)zB+tVyjV3*n`mtAhIM0$58vM9jOQC}JJOem|EpwqeMuYPxu3sv}oMS?S#o6GGK@8PN59)m&K4Dc&X% z(;XL_kKeYkafzS3Wn5DD>Yiw{LACy_#jY4op(>9q>>-*9@C0M+=b#bknAWZ37^(Ij zq>H%<@>o4a#6NydoF{_M4i4zB_KG)#PSye9bk0Ou8h%1Dtl7Q_y#7*n%g)?m>xF~( zjqvOwC;*qvN_3(*a+w2|ao0D?@okOvg8JskUw(l7n`0fncglavwKd?~l_ryKJ^Ky! zKCHkIC-o7%fFvPa$)YNh022lakMar^dgL=t#@XLyNHHw!b?%WlM)R@^!)I!smZL@k zBi=6wE5)2v&!UNV(&)oOYW(6Qa!nUjDKKBf-~Da=#^HE4(@mWk)LPvhyN3i4goB$3K8iV7uh zsv+a?#c4&NWeK(3AH;ETrMOIFgu{_@%XRwCZ;L=^8Ts)hix4Pf3yJRQ<8xb^CkdmC z?c_gB)XmRsk`9ch#tx4*hO=#qS7={~Vb4*tTf<5P%*-XMfUUYkI9T1cEF;ObfxxI-yNuA=I$dCtz3ey znVkctYD*`fUuZ(57+^B*R=Q}~{1z#2!ca?)+YsRQb+lt^LmEvZt_`=j^wqig+wz@n@ z`LIMQJT3bxMzuKg8EGBU+Q-6cs5(@5W?N>JpZL{$9VF)veF`L5%DSYTNQEypW%6$u zm_~}T{HeHj1bAlKl8ii92l9~$dm=UM21kLemA&b$;^!wB7#IKWGnF$TVq!!lBlG4 z{?Rjz?P(uvid+|i$VH?`-C&Gcb3{(~Vpg`w+O);Wk1|Mrjxrht0GfRUnZqz2MhrXa zqgVC9nemD5)H$to=~hp)c=l9?#~Z_7i~=U-`FZxb-|TR9@YCxx;Zjo-WpMNOn2)z) zFPGGVl%3N$f`gp$gPnWC+f4(rmts%fidpo^BJx72zAd7|*Xi{2VXmbOm)1`w^tm9% znM=0Fg4bDxH5PxPEm{P3#A(mxqlM7SIARP?|2&+c7qmU8kP&iApzL|F>Dz)Ixp_`O zP%xrP1M6@oYhgo$ZWwrAsYLa4 z|I;DAvJxno9HkQrhLPQk-8}=De{9U3U%)dJ$955?_AOms!9gia%)0E$Mp}$+0er@< zq7J&_SzvShM?e%V?_zUu{niL@gt5UFOjFJUJ}L?$f%eU%jUSoujr{^O=?=^{19`ON zlRIy8Uo_nqcPa6@yyz`CM?pMJ^^SN^Fqtt`GQ8Q#W4kE7`V9^LT}j#pMChl!j#g#J zr-=CCaV%xyFeQ9SK+mG(cTwW*)xa(eK;_Z(jy)woZp~> zA(4}-&VH+TEeLzPTqw&FOoK(ZjD~m{KW05fiGLe@E3Z2`rLukIDahE*`u!ubU)9`o zn^-lyht#E#-dt~S>}4y$-mSbR8{T@}22cn^refuQ08NjLOv?JiEWjyOnzk<^R5%gO zhUH_B{oz~u#IYwVnUg8?3P*#DqD8#X;%q%HY**=I>>-S|!X*-!x1{^l#OnR56O>iD zc;i;KS+t$koh)E3)w0OjWJl_aW2;xF=9D9Kr>)(5}4FqUbk# zI#$N8o0w;IChL49m9CJTzoC!|u{Ljd%ECgBOf$}&jA^$(V#P#~)`&g`H8E{uv52pp zwto`xUL-L&WTAVREEm$0g_gYPL(^vHq(*t1WCH_6alhkeW&GCZ3hL)|{O-jiFOBrF z!EW=Jej|dqQitT6!B-7&io2K)WIm~Q)v@yq%U|VpV+I?{y0@Yd%n8~-NuuM*pM~KA z85YB};IS~M(c<}4Hxx>qRK0cdl&e?t253N%vefkgds>Ubn8X}j6Vpgs>a#nFq$osY z1ZRwLqFv=+BTb=i%D2Wv>_yE0z}+niZ4?rE|*a3d7^kndWGwnFqt+iZ(7+aln<}jzbAQ(#Z2SS}3S$%Bd}^ zc9ghB%O)Z_mTZMRC&H#)I#fiLuIkGa^`4e~9oM5zKPx?zjkC&Xy0~r{;S?FS%c7w< zWbMpzc(xSw?9tGxG~_l}Acq}zjt5ClaB7-!vzqnlrX;}$#+PyQ9oU)_DfePh2E1<7 ztok6g6K^k^DuHR*iJ?jw?bs_whk|bx`dxu^nC6#e{1*m~z1eq7m}Cf$*^Eua(oi_I zAL+3opNhJteu&mWQ@kQWPucmiP)4|nFG`b2tpC;h{-PI@`+h?9v=9mn|0R-n8#t=+Z*FD(c5 zjj79Jxkgck*DV=wpFgRZuwr%}KTm+dx?RT@aUHJdaX-ODh~gByS?WGx&czAkvkg;x zrf92l8$Or_zOwJVwh>5rB`Q5_5}ef6DjS*$x30nZbuO3dijS*wvNEqTY5p1_A0gWr znH<(Qvb!os14|R)n2Ost>jS2;d1zyLHu`Svm|&dZD+PpP{Bh>U&`Md;gRl64q;>{8MJJM$?UNUd`aC>BiLe>*{ zJY15->yW+<3rLgYeTruFDtk1ovU<$(_y7#HgUq>)r0{^}Xbth}V#6?%5jeFYt;SG^ z3qF)=uWRU;Jj)Q}cpY8-H+l_n$2$6{ZR?&*IGr{>ek!69ZH0ZoJ*Ji+ezzlJ^%qL3 zO5a`6gwFw(moEzqxh=yJ9M1FTn!eo&qD#y5AZXErHs%22?A+JmS&GIolml!)rZTnUDM3YgzYfT#;OXn)`PWv3Ta z!-i|-Wojv*k&bC}_JJDjiAK(Ba|YZgUI{f}TdEOFT2+}nPmttytw7j%@bQZDV1vvj z^rp{gRkCDmYJHGrE1~e~AE!-&6B6`7UxVQuvRrfdFkGX8H~SNP_X4EodVd;lXd^>eV1jN+Tt4}Rsn)R0LxBz0c=NXU|pUe!MQQFkGBWbR3&(jLm z%RSLc#p}5_dO{GD=DEFr=Fc% z85CBF>*t!6ugI?soX(*JNxBp+-DdZ4X0LldiK}+WWGvXV(C(Ht|!3$psR=&c*HIM=BmX;pRIpz@Ale{9dhGe(U2|Giv;# zOc|;?p67J=Q(kamB*aus=|XP|m{jN^6@V*Bpm?ye56Njh#vyJqE=DweC;?Rv7faX~ zde03n^I~0B2vUmr;w^X37tVxUK?4}ifsSH5_kpKZIzpYu0;Kv}SBGfI2AKNp+VN#z`nI{UNDRbo-wqa4NEls zICRJpu)??cj^*WcZ^MAv+;bDbh~gpN$1Cor<{Y2oyIDws^JsfW^5AL$azE(T0p&pP z1Mv~6Q44R&RHoH95&OuGx2srIr<@zYJTOMKiVs;Bx3py89I87LOb@%mr`0)#;7_~Z zzcZj8?w=)>%5@HoCHE_&hnu(n_yQ-L(~VjpjjkbT7e)Dk5??fApg(d>vwLRJ-x{um z*Nt?DqTSxh_MIyogY!vf1mU1`Gld-&L)*43f6dilz`Q@HEz;+>MDDYv9u!s;WXeao zUq=TaL$P*IFgJzrGc>j1dDOd zed+=ZBo?w4mr$2)Ya}?vedDopomhW1`#P<%YOJ_j=WwClX0xJH-f@s?^tmzs_j7t!k zK@j^zS0Q|mM4tVP5Ram$VbS6|YDY&y?Q1r1joe9dj08#CM{RSMTU}(RCh`hp_Rkl- zGd|Cv~G@F{DLhCizAm9AN!^{rNs8hu!G@8RpnGx7e`-+K$ffN<0qjR zGq^$dj_Tv!n*?zOSyk5skI7JVKJ)3jysnjIu-@VSzQiP8r6MzudCU=~?v-U8yzo^7 zGf~SUTvEp+S*!X9uX!sq=o}lH;r{pzk~M*VA(uyQ`3C8!{C;)&6)95fv(cK!%Cuz$ z_Zal57H6kPN>25KNiI6z6F)jzEkh#%OqU#-__Xzy)KyH};81#N6OfX$$IXWzOn`Q& z4f$Z1t>)8&8PcYfEwY5UadU1yg+U*(1m2ZlHoC-!2?gB!!fLhmTl))D@dhvkx#+Yj z1O=LV{(T%{^IeCuFK>%QR!VZ4GnO5tK8a+thWE zg4VytZrwcS?7^ zuZfhYnB8dwd%VLO?DK7pV5Wi<(`~DYqOXn8#jUIL^)12*Dbhk4GmL_E2`WX&iT16o zk(t|hok(Y|v-wzn?4x34T)|+SfZP>fiq!><*%vnxGN~ypST-FtC+@TPv*vYv@iU!_ z@2gf|PrgQ?Ktf*9^CnJ(x*CtZVB8!OBfg0%!wL;Z8(tYYre0vcnPGlyCc$V(Ipl*P z_(J!a=o@vp^%Efme!K74(Ke7A>Y}|sxV+JL^aYa{~m%5#$$+R1? zGaQhZTTX!#s#=Xtpegqero$RNt&`4xn3g$)=y*;=N=Qai)}~`xtxI_N*#MMCIq#HFifT zz(-*m;pVH&+4bixL&Bbg)W5FN^bH87pAHp)zPkWNMfTFqS=l~AC$3FX3kQUSh_C?-ZftyClgM)o_D7cX$RGlEYblux0jv5 zTr|i-I3@ZPCGheCl~BGhImF)K4!9@?pC(gi3ozX=a!|r1)LFxy_8c&wY0<^{2cm|P zv6Y`QktY*;I)IUd5y3ne1CqpVanlY45z8hf4&$EUBnucDj16pDa4&GI&TArYhf*xh zdj>*%APH8(h~c>o@l#%T>R$e>rwVx_WUB|~V`p^JHsg*y12lzj&zF}w6W09HwB2yb z%Q~`es&(;7#*DUC_w-Dmt7|$*?TA_m;zB+-u{2;Bg{O}nV7G_@7~<)Bv8fH^G$XG8$(&{A zwXJK5LRK%M34(t$&NI~MHT{UQ9qN-V_yn|%PqC81EIiSzmMM=2zb`mIwiP_b)x+2M z7Gd`83h79j#SItpQ}luuf2uOU`my_rY5T{6P#BNlb%h%<#MZb=m@y5aW;#o1^2Z)SWo+b`y0gV^iRcZtz5!-05vF z7wNo=hc6h4hc&s@uL^jqRvD6thVYtbErDK9k!;+a0xoE0WL7zLixjn5;$fXvT=O3I zT6jI&^A7k6R{&5#lVjz#8%_RiAa2{di{`kx79K+j72$H(!ass|B%@l%KeeKchYLe_ z>!(JC2fxsv>XVen+Y42GeYPxMWqm`6F$(E<6^s|g(slNk!lL*6v^W2>f6hh^mE$s= z3D$)}{V5(Qm&A6bp%2Q}*GZ5Qrf}n7*Hr51?bJOyA-?B4vg6y_EX<*-e20h{=0Mxs zbuQGZ$fLyO5v$nQ&^kuH+mNq9O#MWSfThtH|0q1i!NrWj^S}_P;Q1OkYLW6U^?_7G zx2wg?CULj7))QU(n{$0JE%1t2dWrMi2g-Os{v|8^wK{@qlj%+1b^?NI z$}l2tjp0g>K3O+p%yK<9!XqmQ?E9>z&(|^Pi~aSRwI5x$jaA62GFz9%fmO3t3a>cq zK8Xbv=5Ps~4mKN5+Eqw12(!PEyedFXv~VLxMB~HwT1Vfo51pQ#D8e$e4pFZ{&RC2P z5gTIzl{3!&(tor^BwZfR8j4k{7Rq#`riKXP2O-Bh66#WWK2w=z;iD9GLl+3 zpHIaI4#lQ&S-xBK8PiQ%dwOh?%BO~DCo06pN7<^dnZCN@NzY{_Z1>rrB0U|nC&+!2 z2y!oBcTd2;@lzyk(B=TkyZ)zy0deK05*Q0zk+o$@nun`VI1Er7pjq>8V zNmlW{p7S^Btgb(TA}jL(uR>`0w8gHP^T~Sh5Tkip^spk4SBAhC{TZU}_Z)UJw-}zm zPq{KBm!k)?P{`-(9?LFt&YN4s%SIZ-9lJ!Ws~B%exHOeVFk3~}HewnnH(d)qkLQ_d z6h>O)pEE{vbOVw}E+jdYC^wM+AAhaI(YAibUc@B#_mDss0Ji&BK{WG`4 zOk>vSNq(Bq2IB@s>>Rxm6Wv?h;ZXkpb1l8u|+_qXWdC*jjcPCixq;!%BVPSp#hP zqo`%cNf&YoQXHC$D=D45RiT|5ngPlh?0T~?lUf*O)){K@*Kbh?3RW1j9-T?%lDk@y z4+~?wKI%Y!-=O|_IuKz|=)F;V7ps=5@g)RrE;;tvM$gUhG>jHcw2Hr@fS+k^Zr~>G z^JvPrZc}_&d_kEsqAEMTMJw!!CBw)u&ZVzmq+ZworuaE&TT>$pYsd9|g9O^0orAe8 z221?Va!l1|Y5X1Y?{G7rt1sX#qFA^?RLG^VjoxPf63;AS=_mVDfGJKg73L zsGdnTUD40y(>S##2l|W2Cy!H(@@5KBa(#gs`vlz}Y~$ot5VsqPQ{{YtjYFvIumZzt zA{CcxZLJR|4#{j7k~Tu*jkwz8QA|5G1$Cl895R`Zyp;irp1{KN){kB30O8P1W5;@bG znvX74roeMmQlUi=v9Y%(wl$ZC#9tKNFpvi3!C}f1m6Ct|l2g%psc{TJp)@yu)*e2> z((p0Fg*8gJ!|3WZke9;Z{8}&NRkv7iP=#_y-F}x^y?2m%-D_aj^)f04%mneyjo_;) z6qc_Zu$q37d~X``*eP~Q>I2gg%rrV8v=kDfpp$=%Vj}hF)^dsSWygoN(A$g*E=Do6FX?&(@F#7pbiJ`;c0c@Ul zDqW_90Wm#5f2L<(Lf3)3TeXtI7nhYwRm(F;*r_G6K@OPW4H(Y3O5SjUzBC}u3d|eQ8*8d@?;zUPE+i#QNMn=r(ap?2SH@vo*m z3HJ%XuG_S6;QbWy-l%qU;8x;>z>4pMW7>R}J%QLf%@1BY(4f_1iixd-6GlO7Vp*yU zp{VU^3?s?90i=!#>H`lxT!q8rk>W_$2~kbpz7eV{3wR|8E=8**5?qn8#n`*(bt1xRQrdGxyx2y%B$qmw#>ZV$c7%cO#%JM1lY$Y0q?Yuo> ze9KdJoiM)RH*SB%^;TAdX-zEjA7@%y=!0=Zg%iWK7jVI9b&Dk}0$Af&08KHo+ zOwDhFvA(E|ER%a^cdh@^wLUlmIv6?_3=BvX8jKk92L=Y}7Jf5OGMfh` zBdR1wFCi-i5@`9km{isRb0O%TX+f~)KNaEz{rXQa89`YIF;EN&gN)cigu6mNh>?Cm zAO&Im2flv6D{jwm+y<%WsPe4!89n~KN|7}Cb{Z;XweER73r}Qp2 zz}WP4j}U0&(uD&9yGy6`!+_v-S(yG*iytsTR#x_Rc>=6u^vnRDnf1gP{#2>`ffrAC% zTZ5WQ@hAK;P;>kX{D)mIXe4%a5p=LO1xXH@8T?mz7Q@d)$3pL{{B!2{-v70L*o1AO+|n5beiw~ zk@(>m?T3{2k2c;NWc^`4@P&Z?BjxXJ@;x1qhn)9Mn*IFdt_J-dIqx5#d`NfyfX~m( zIS~5)MfZ2Uy?_4W`47i}u0ZgPh<{D|w_d#;D}Q&U$Q-G}xM1A@1f{#%A$jh6Qp&0hQ<0bPOM z-{1Wm&p%%#eb_?x7i;bol EfAhh=DF6Tf literal 0 HcmV?d00001 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..9b66d8c --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java index f10d219..3bed424 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -9,19 +9,13 @@ @Schema(description = "콘텐츠 목록 조회 응답") public record ContentsListResponse( - @Schema(type = "Long", description = "미디어 ID", example = "1") - Long mediaId, + @Schema(type = "Long", description = "미디어 ID", example = "1") Long mediaId, - @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") - String posterUrl, + @Schema(type = "String", description = "포스터(세로, 5:7) URL", example = "https://cdn.example.com/thumbnail.jpg") String posterUrl, - @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") - String title, + @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") String title, - @Schema(type = "String", description = "공개 여부", example = "PUBLIC") - PublicStatus publicStatus, + @Schema(type = "String", description = "공개 여부", example = "PUBLIC") PublicStatus publicStatus, - @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") - LocalDate uploadedDate -) { + @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") LocalDate uploadedDate) { } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java index 7aef11d..c8ebdb7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java @@ -12,34 +12,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; - @Tag(name = "Auth API", description = "인증/인가 API") public interface AuthApi { - @Operation(summary = "Access Token 재발급", description = "access token + refresh token 재발급.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "재발급 성공"), - @ApiResponse( - responseCode = "401", - description = "refreshToken이 없거나 만료/유효하지 않음", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class)) - ) - }) - ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); - + @Operation(summary = "Access Token 재발급", description = "access token + refresh token 재발급.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse(responseCode = "401", description = "refreshToken이 없거나 만료/유효하지 않음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); - @Operation( - summary = "로그아웃", description = "DB refreshToken을 삭제, accessToken/refreshToken 쿠키 제거" - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse( - responseCode = "401", - description = "인증되지 않은 사용자", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class)) - ) - }) - ResponseEntity logout(Authentication authentication, HttpServletResponse response); + @Operation(summary = "로그아웃", description = "DB refreshToken을 삭제, accessToken/refreshToken 쿠키 제거") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity logout(Authentication authentication, HttpServletResponse response); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java index a314540..598ba87 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -1,6 +1,5 @@ package com.ott.api_user.auth.controller; - import com.ott.api_user.auth.dto.TokenResponse; import com.ott.api_user.auth.service.AuthService; import com.ott.common.web.exception.BusinessException; @@ -30,7 +29,6 @@ public class AuthController implements AuthApi { @Value("${jwt.refresh-token-expiry}") private int refreshTokenExpiry; - // Access Token 재발급 @PostMapping("reissue") public ResponseEntity reissue( @@ -71,26 +69,20 @@ public ResponseEntity logout( return ResponseEntity.noContent().build(); } - - // 임시 테스트 코드 -> 추후 프론트 페이지로 변경 예정 @GetMapping("logincheck") public ResponseEntity> logincheck( @RequestParam(value = "isNewMember") boolean isNewMember, - HttpServletRequest request - ) { + HttpServletRequest request) { String accessToken = extractCookie(request, "accessToken"); String refreshToken = extractCookie(request, "refreshToken"); - return ResponseEntity.ok(Map.of( "isNewMember", isNewMember, "accessToken", accessToken, - "refreshToken", refreshToken - )); + "refreshToken", refreshToken)); } - // 인가 테스트용 코드 -> 이렇게 @AuthenticationPrincipal로 쓰시면 됩니다. // 추후 memberId -> UserDetails로 리팩토링 예정 @GetMapping("/me") @@ -98,15 +90,13 @@ public Long me(@AuthenticationPrincipal Long memberId) { return memberId; } - - // 쿠키에 대한 접근은 HTTP고 서비스로 내려가면 안되기 때문에 Controller에서 구현 private String extractCookie(HttpServletRequest request, String name) { if (request.getCookies() == null) { return null; } - for (Cookie cookie: request.getCookies()) { + for (Cookie cookie : request.getCookies()) { if (name.equals(cookie.getName())) { return cookie.getValue(); } @@ -117,7 +107,7 @@ private String extractCookie(HttpServletRequest request, String name) { private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { Cookie cookie = new Cookie(name, value); cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 + cookie.setSecure(false); // 배포 시 true 변경 cookie.setPath("/"); cookie.setMaxAge(maxAge); response.addCookie(cookie); @@ -126,9 +116,9 @@ private void addCookie(HttpServletResponse response, String name, String value, private void deleteCookie(HttpServletResponse response, String name) { Cookie cookie = new Cookie(name, null); cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 + cookie.setSecure(false); // 배포 시 true 변경 cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 삭제 + cookie.setMaxAge(0); // 즉시 삭제 response.addCookie(cookie); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java index a00ac40..045fd65 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/AuthService.java @@ -31,16 +31,15 @@ public TokenResponse reissue(String refreshToken) { // refresh 토큰 유효성 검증 ErrorCode errorCode = jwtTokenProvider.validateAndGetErrorCode(refreshToken); - if(errorCode != null) { - throw new BusinessException(errorCode); + if (errorCode != null) { + throw new BusinessException(errorCode); } // DB에 저장된 토큰과 일치 여부 확인 Long memberId = jwtTokenProvider.getMemberId(refreshToken); Member member = findMemberById(memberId); - - if(!refreshToken.equals(member.getRefreshToken())) { + if (!refreshToken.equals(member.getRefreshToken())) { throw new BusinessException(ErrorCode.INVALID_TOKEN); } @@ -60,14 +59,13 @@ public TokenResponse reissue(String refreshToken) { } /** - * 로그아웃 - Refresh 토큰 삭제 + * 로그아웃 - Refresh 토큰 삭제 */ public void logout(Long memberId) { Member member = findMemberById(memberId); member.clearRefreshToken(); } - // Optipnal 처리를 위해 사용 private Member findMemberById(Long memberId) { return memberRepository.findById(memberId) diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java index 1565268..38daac7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -34,17 +34,14 @@ public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { .map(existingMember -> { existingMember.updateKakaoProfile( kakaoUserInfo.getEmail(), - kakaoUserInfo.getNickname() - ); + kakaoUserInfo.getNickname()); return existingMember; }) .orElseGet(() -> memberRepository.save( Member.createKakaoMember( kakaoUserInfo.getProviderId(), kakaoUserInfo.getEmail(), - kakaoUserInfo.getNickname() - ) - )); + kakaoUserInfo.getNickname()))); } // 신규 회원 판별 -> 태그 소유 유무로 판단 @@ -59,5 +56,4 @@ public void saveRefreshToken(Long memberId, String refreshToken) { member.updateRefreshToken(refreshToken); } - } diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java b/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java new file mode 100644 index 0000000..27b8efe --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java @@ -0,0 +1,5 @@ +package com.ott.api_user.common.dto; + +public class ContentListElement { + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/content/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java new file mode 100644 index 0000000..3bf78c2 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java @@ -0,0 +1,41 @@ +package com.ott.api_user.content.controller; + +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.RequestParam; + +import com.ott.api_user.content.dto.ContentDetailResponse; +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.ContentSource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +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; + +@Tag(name = "Contents", description = "콘텐츠(영상) 상세 및 재생 관련 API") +public interface ContentApi { + + @Operation(summary = "콘텐츠 상세 조회", description = "단편 영화/에피소드의 상세 정보를 조회합니다.(콘텐츠 상세 페이지)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "콘텐츠 상세 조회 성공", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), + @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{contentsId}") + ResponseEntity> getContentDetail( + @Parameter(description = "콘텐츠 ID", required = true, example = "1") @PathVariable("contentsId") Long contentsId, + @Parameter(hidden = true) Long memberId); + + // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 + + // 댓글 조회 +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java new file mode 100644 index 0000000..bfa3708 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java @@ -0,0 +1,34 @@ +package com.ott.api_user.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.content.dto.ContentDetailResponse; +import com.ott.api_user.content.service.ContentService; +import com.ott.common.web.response.SuccessResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/contents") +public class ContentController implements ContentApi { + private final ContentService contentService; + + @Override + public ResponseEntity> getContentDetail( + @PathVariable(value = "contentsId") Long contentsId, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok( + SuccessResponse.of(contentService.getContentDetail(contentsId, memberId))); + } + + // 플레이 리스트 API + + // 댓글 조회 API + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/content/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java new file mode 100644 index 0000000..a118b8a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java @@ -0,0 +1,73 @@ +package com.ott.api_user.content.dto; + +import java.util.List; + +import com.ott.domain.common.MediaType; +import com.ott.domain.contents.domain.Contents; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "컨텐츠 상세(재생) 조회 응답 DTO") +public class ContentDetailResponse { + @Schema(description = "콘텐츠 ID", example = "1") + private Long id; + + @Schema(description = "미디어 타입 (SERIES, CONTENTS)", example = "SERIES") + private MediaType mediaType; + + @Schema(description = "콘텐츠 제목", example = "비밀의 숲") + private String title; + + @Schema(description = "콘텐츠 설명", example = "검경 수사극의 새로운 지평을 연 드라마") + private String description; + + @Schema(description = "출연진", example = "송혜교, 이도현, 임지연") + private String actors; + + @Schema(description = "가로형 썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + + @Schema(description = "카테고리", example = "드라마") + private String category; + + @Schema(description = "태그 목록", example = "드라마, 범죄, 수사") + private List tags; + + @Schema(description = "사용자 북마크 여부", example = "true") + private Boolean isBookmarked; + + @Schema(description = "사용자 좋아요 여부", example = "true") + private Boolean isLiked; + + @Schema(description = "마스터 재생목록 URL(HLS)", example = "https://example.com/master.m3u8") + private String masterPlaylistUrl; + + @Schema(description = "기존 이어보기 지점(없으면 0)", example = "150") + private Integer positionSec; + + public static ContentDetailResponse of(MediaType mediaType, Contents contents, List tags, + List categories, Boolean isBookmarked, Boolean isLiked, String masterPlaylistUrl, + Integer positionSec) { + + return ContentDetailResponse.builder() + .id(contents.getId()) + .mediaType(mediaType) + .title(contents.getMedia().getTitle()) + .description(contents.getMedia().getDescription()) + .actors(contents.getActors()) + .thumbnailUrl(contents.getMedia().getThumbnailUrl()) + .category(categories.isEmpty() ? null : categories.get(0)) + .tags(tags) + .isBookmarked(isBookmarked) + .isLiked(isLiked) + .masterPlaylistUrl(masterPlaylistUrl) + .positionSec(positionSec) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/content/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java new file mode 100644 index 0000000..b0ab699 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java @@ -0,0 +1,61 @@ +package com.ott.api_user.content.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.ott.api_user.content.dto.ContentDetailResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.MediaType; +import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.playback.repository.PlaybackRepository; +import com.ott.domain.tag.repository.TagRepository; + +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContentService { + private final ContentsRepository contentsRepository; + // private final PlaybackRepository playbackRepository; + + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + private final TagRepository tagRepository; + private final CategoryRepository categoryRepository; + + // 재생 상세 + public ContentDetailResponse getContentDetail(Long contentsId, Long memberId) { + Contents contents = contentsRepository.findByIdWithMedia(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Long mediaId = contents.getMedia().getId(); + + List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); + List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); + + Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, + Status.ACTIVE); + Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); + + String masterPlaylistUrl = contents.getMasterPlaylistUrl(); + MediaType mediaType = MediaType.CONTENTS; // 재생 화면이므로 무조건 CONTENTS로 고정 (시리즈 아님) + + Integer positionSec = 0; + + return ContentDetailResponse.of(mediaType, contents, tags, categories, isBookmarked, isLiked, masterPlaylistUrl, + positionSec); + + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index f084a00..7a93eb2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -22,7 +22,7 @@ @Tag(name = "Series API", description = "시리즈 관련 API입니다.") public interface SeriesApi { - @Operation(summary = "시리즈 상세 조회", description = "특정 시리즈의 상세 정보를 조회합니다.") + @Operation(summary = "시리즈 상세 조회", description = "특정 시리즈의 상세 정보를 조회합니다.(시리즈 상세 페이지)") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "시리즈 상세 조회 성공", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = SeriesDetailResponse.class)) }), diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java index 32871e6..ab1c3bc 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java @@ -27,9 +27,6 @@ public class SeriesDetailResponse { @Schema(description = "출연진", example = "송혜교, 이도현, 임지연") private String actors; - @Schema(description = "시리즈 포스터 이미지 URL", example = "https://cdn.ott.com/posters/101.jpg") - private String posterUrl; - @Schema(description = "썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") private String thumbnailUrl; @@ -53,7 +50,6 @@ public static SeriesDetailResponse of(Series series, List tags, List, ContentsRepositoryCustom { - @EntityGraph(attributePaths = { "media" }) - Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long seriesId, Status status, - PublicStatus publicStatus, Pageable pageable); + @EntityGraph(attributePaths = { "media" }) + Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long seriesId, Status status, + PublicStatus publicStatus, Pageable pageable); + + @Query("SELECT c FROM Contents c JOIN FETCH c.media m WHERE c.id = :contentsId AND c.status = :status AND m.publicStatus = :publicStatus") + Optional findByIdWithMedia(@Param("contentsId") Long contentsId, @Param("status") Status status, + @Param("publicStatus") PublicStatus publicStatus); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java index 6cb2419..02f694e 100644 --- a/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/ingest_job/repository/IngestJobRepositoryImpl.java @@ -22,15 +22,15 @@ public class IngestJobRepositoryImpl implements IngestJobRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pageable pageable, String searchWord, Long uploaderId) { + public Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pageable pageable, String searchWord, + Long uploaderId) { List ingestJobList = queryFactory .selectFrom(ingestJob) .join(ingestJob.media, media).fetchJoin() .join(media.uploader, member).fetchJoin() .where( titleContains(searchWord), - uploaderIdEq(uploaderId) - ) + uploaderIdEq(uploaderId)) .orderBy(ingestJob.createdDate.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -42,8 +42,7 @@ public Page findIngestJobListWithMediaBySearchWordAndUploaderId(Pagea .join(ingestJob.media, media) .where( titleContains(searchWord), - uploaderIdEq(uploaderId) - ); + uploaderIdEq(uploaderId)); return PageableExecutionUtils.getPage(ingestJobList, pageable, countQuery::fetchOne); } diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index 4033293..6ef7613 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -19,6 +19,6 @@ public interface TagRepository extends JpaRepository { WHERE mt.media.id = :mediaId AND t.status = :status AND mt.status = :status - """) + """) // 태그 삭제 x, 태그 연결 ` List findTagNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); } \ No newline at end of file From f133e41266dfaa075aade157eb617e6092719f5e Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Mon, 23 Feb 2026 18:38:14 +0900 Subject: [PATCH 079/257] =?UTF-8?q?[OT-104]=20[FEAT]:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20S3/infra=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-admin/build.gradle | 3 +- .../ott/api_admin/upload/controller/.gitkeep | 0 .../upload/controller/ContentsUploadApi.java | 19 ++ .../controller/ContentsUploadController.java | 35 +++ .../upload/controller/SeriesUploadApi.java | 19 ++ .../controller/SeriesUploadController.java | 35 +++ .../upload/controller/ShortFormUploadApi.java | 19 ++ .../controller/ShortFormUploadController.java | 35 +++ .../request/ContentsUploadInitRequest.java | 32 +++ .../dto/request/SeriesUploadInitRequest.java | 24 ++ .../request/ShortFormUploadInitRequest.java | 32 +++ .../response/ContentsUploadInitResponse.java | 24 ++ .../response/SeriesUploadInitResponse.java | 18 ++ .../response/ShortFormUploadInitResponse.java | 24 ++ .../upload/service/ContentsUploadService.java | 197 ++++++++++++++++ .../upload/service/SeriesUploadService.java | 148 ++++++++++++ .../service/ShortFormUploadService.java | 219 ++++++++++++++++++ apps/api-user/build.gradle | 4 +- apps/transcoder/build.gradle | 4 +- docker-compose.yml | 27 ++- .../ott/domain/contents/domain/Contents.java | 5 + .../com/ott/domain/media/domain/Media.java | 5 + .../domain/short_form/domain/ShortForm.java | 5 + modules/{infra => infra-db}/build.gradle | 3 +- .../main/resources/db/migration/V1__init.sql | 0 .../migration/V2__media_table_inheritance.sql | 0 modules/infra-s3/build.gradle | 5 + .../infra/s3/config/S3PresignerConfig.java | 20 ++ .../infra/s3/service/S3PresignService.java | 68 ++++++ .../src/main/java/com/ott/infra/db/.gitkeep | 0 .../src/main/java/com/ott/infra/s3/.gitkeep | 0 settings.gradle | 5 +- 32 files changed, 1016 insertions(+), 18 deletions(-) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java rename modules/{infra => infra-db}/build.gradle (76%) rename modules/{infra => infra-db}/src/main/resources/db/migration/V1__init.sql (100%) rename modules/{infra => infra-db}/src/main/resources/db/migration/V2__media_table_inheritance.sql (100%) create mode 100644 modules/infra-s3/build.gradle create mode 100644 modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java create mode 100644 modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java delete mode 100644 modules/infra/src/main/java/com/ott/infra/db/.gitkeep delete mode 100644 modules/infra/src/main/java/com/ott/infra/s3/.gitkeep diff --git a/apps/api-admin/build.gradle b/apps/api-admin/build.gradle index faf3af7..3a531d7 100644 --- a/apps/api-admin/build.gradle +++ b/apps/api-admin/build.gradle @@ -2,7 +2,8 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') + implementation project(':modules:infra-s3') implementation project(':modules:common-web') implementation project(':modules:common-security') diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java new file mode 100644 index 0000000..90bb6ca --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.upload.controller; + +import com.ott.api_admin.upload.dto.request.ContentsUploadInitRequest; +import com.ott.api_admin.upload.dto.response.ContentsUploadInitResponse; +import com.ott.common.web.response.SuccessResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * 콘텐츠 업로드 초기화 API 명세입니다. + */ +public interface ContentsUploadApi { + /** + * 콘텐츠 업로드에 필요한 DB 레코드와 Presigned URL을 생성합니다. + */ + ResponseEntity> createContentsUpload( + @RequestBody ContentsUploadInitRequest request + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java new file mode 100644 index 0000000..56fbd17 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java @@ -0,0 +1,35 @@ +package com.ott.api_admin.upload.controller; + +import com.ott.api_admin.upload.dto.request.ContentsUploadInitRequest; +import com.ott.api_admin.upload.dto.response.ContentsUploadInitResponse; +import com.ott.api_admin.upload.service.ContentsUploadService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.http.ResponseEntity; +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; + +/** + * 콘텐츠 업로드 초기화 요청을 처리하는 컨트롤러입니다. + */ +@RestController +@RequestMapping("/back-office/admin/upload/contents") +@RequiredArgsConstructor +public class ContentsUploadController implements ContentsUploadApi { + + private final ContentsUploadService contentsUploadService; + + @Override + @PostMapping("/init") + @PreAuthorize("hasRole('ADMIN')") + // ADMIN 권한으로 콘텐츠 업로드 초기화를 수행합니다. + public ResponseEntity> createContentsUpload( + @Valid @RequestBody ContentsUploadInitRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(contentsUploadService.createContentsUpload(request))); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java new file mode 100644 index 0000000..e49cc52 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.upload.controller; + +import com.ott.api_admin.upload.dto.request.SeriesUploadInitRequest; +import com.ott.api_admin.upload.dto.response.SeriesUploadInitResponse; +import com.ott.common.web.response.SuccessResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * 시리즈 업로드 초기화 API 명세입니다. + */ +public interface SeriesUploadApi { + /** + * 시리즈 업로드에 필요한 DB 레코드와 Presigned URL을 생성합니다. + */ + ResponseEntity> createSeriesUpload( + @RequestBody SeriesUploadInitRequest request + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java new file mode 100644 index 0000000..8a02f57 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java @@ -0,0 +1,35 @@ +package com.ott.api_admin.upload.controller; + +import com.ott.api_admin.upload.dto.request.SeriesUploadInitRequest; +import com.ott.api_admin.upload.dto.response.SeriesUploadInitResponse; +import com.ott.api_admin.upload.service.SeriesUploadService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.http.ResponseEntity; +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; + +/** + * 시리즈 업로드 초기화 요청을 처리하는 컨트롤러입니다. + */ +@RestController +@RequestMapping("/back-office/admin/upload/series") +@RequiredArgsConstructor +public class SeriesUploadController implements SeriesUploadApi { + + private final SeriesUploadService seriesUploadService; + + @Override + @PostMapping("/init") + @PreAuthorize("hasRole('ADMIN')") + // ADMIN 권한으로 시리즈 업로드 초기화를 수행합니다. + public ResponseEntity> createSeriesUpload( + @Valid @RequestBody SeriesUploadInitRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(seriesUploadService.createSeriesUpload(request))); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java new file mode 100644 index 0000000..501d882 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java @@ -0,0 +1,19 @@ +package com.ott.api_admin.upload.controller; + +import com.ott.api_admin.upload.dto.request.ShortFormUploadInitRequest; +import com.ott.api_admin.upload.dto.response.ShortFormUploadInitResponse; +import com.ott.common.web.response.SuccessResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * 숏폼 업로드 초기화 API 명세입니다. + */ +public interface ShortFormUploadApi { + /** + * 숏폼 업로드에 필요한 DB 레코드와 Presigned URL을 생성합니다. + */ + ResponseEntity> createShortFormUpload( + @RequestBody ShortFormUploadInitRequest request + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java new file mode 100644 index 0000000..7034c7b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java @@ -0,0 +1,35 @@ +package com.ott.api_admin.upload.controller; + +import com.ott.api_admin.upload.dto.request.ShortFormUploadInitRequest; +import com.ott.api_admin.upload.dto.response.ShortFormUploadInitResponse; +import com.ott.api_admin.upload.service.ShortFormUploadService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.http.ResponseEntity; +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; + +/** + * 숏폼 업로드 초기화 요청을 처리하는 컨트롤러입니다. + */ +@RestController +@RequestMapping("/back-office/admin/upload/short-forms") +@RequiredArgsConstructor +public class ShortFormUploadController implements ShortFormUploadApi { + + private final ShortFormUploadService shortFormUploadService; + + @Override + @PostMapping("/init") + @PreAuthorize("hasAnyRole('ADMIN','EDITOR')") + // ADMIN 또는 EDITOR 권한으로 숏폼 업로드 초기화를 수행합니다. + public ResponseEntity> createShortFormUpload( + @Valid @RequestBody ShortFormUploadInitRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(shortFormUploadService.createShortFormUpload(request))); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java new file mode 100644 index 0000000..c0fbd50 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java @@ -0,0 +1,32 @@ +package com.ott.api_admin.upload.dto.request; + +import com.ott.domain.common.PublicStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 콘텐츠 업로드 초기화 요청 DTO입니다. + */ +public record ContentsUploadInitRequest( + // 연결할 시리즈 ID (없으면 단일 콘텐츠) + Long seriesId, + // 콘텐츠 제목 + @NotBlank String title, + // 콘텐츠 설명 + @NotBlank String description, + // 출연진 문자열 + @NotBlank String actors, + // 공개 상태 + @NotNull PublicStatus publicStatus, + // 영상 길이(초) + Integer duration, + // 영상 크기(바이트 또는 내부 단위) + Integer videoSize, + // 포스터 원본 파일명 + @NotBlank String posterFileName, + // 썸네일 원본 파일명 + @NotBlank String thumbnailFileName, + // 원본 영상 파일명 + @NotBlank String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java new file mode 100644 index 0000000..4267522 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.upload.dto.request; + +import com.ott.domain.common.PublicStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 시리즈 업로드 초기화 요청 DTO입니다. + */ +public record SeriesUploadInitRequest( + // 시리즈 제목 + @NotBlank String title, + // 시리즈 설명 + @NotBlank String description, + // 출연진 문자열 + @NotBlank String actors, + // 공개 상태 + @NotNull PublicStatus publicStatus, + // 포스터 원본 파일명 + @NotBlank String posterFileName, + // 썸네일 원본 파일명 + @NotBlank String thumbnailFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java new file mode 100644 index 0000000..d23fcda --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java @@ -0,0 +1,32 @@ +package com.ott.api_admin.upload.dto.request; + +import com.ott.domain.common.PublicStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 숏폼 업로드 초기화 요청 DTO입니다. + */ +public record ShortFormUploadInitRequest( + // 연결할 시리즈 ID (선택) + Long seriesId, + // 연결할 콘텐츠 ID (선택) + Long contentsId, + // 숏폼 제목 + @NotBlank String title, + // 숏폼 설명 + @NotBlank String description, + // 공개 상태 + @NotNull PublicStatus publicStatus, + // 영상 길이(초) + Integer duration, + // 영상 크기(바이트 또는 내부 단위) + Integer videoSize, + // 포스터 원본 파일명 + @NotBlank String posterFileName, + // 썸네일 원본 파일명 + @NotBlank String thumbnailFileName, + // 원본 영상 파일명 + @NotBlank String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java new file mode 100644 index 0000000..259299c --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.upload.dto.response; + +/** + * 콘텐츠 업로드 초기화 응답 DTO입니다. + */ +public record ContentsUploadInitResponse( + // 생성된 콘텐츠 ID + Long contentsId, + // 포스터 저장 오브젝트 키 + String posterObjectKey, + // 썸네일 저장 오브젝트 키 + String thumbnailObjectKey, + // 원본 영상 저장 오브젝트 키 + String originObjectKey, + // 트랜스코딩 결과 마스터 플레이리스트 오브젝트 키 + String masterPlaylistObjectKey, + // 포스터 업로드용 Presigned URL + String posterUploadUrl, + // 썸네일 업로드용 Presigned URL + String thumbnailUploadUrl, + // 원본 영상 업로드용 Presigned URL + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java new file mode 100644 index 0000000..8f27ce9 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java @@ -0,0 +1,18 @@ +package com.ott.api_admin.upload.dto.response; + +/** + * 시리즈 업로드 초기화 응답 DTO입니다. + */ +public record SeriesUploadInitResponse( + // 생성된 시리즈 ID + Long seriesId, + // 포스터 저장 오브젝트 키 + String posterObjectKey, + // 썸네일 저장 오브젝트 키 + String thumbnailObjectKey, + // 포스터 업로드용 Presigned URL + String posterUploadUrl, + // 썸네일 업로드용 Presigned URL + String thumbnailUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java new file mode 100644 index 0000000..aeba09f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.upload.dto.response; + +/** + * 숏폼 업로드 초기화 응답 DTO입니다. + */ +public record ShortFormUploadInitResponse( + // 생성된 숏폼 ID + Long shortFormId, + // 포스터 저장 오브젝트 키 + String posterObjectKey, + // 썸네일 저장 오브젝트 키 + String thumbnailObjectKey, + // 원본 영상 저장 오브젝트 키 + String originObjectKey, + // 트랜스코딩 결과 마스터 플레이리스트 오브젝트 키 + String masterPlaylistObjectKey, + // 포스터 업로드용 Presigned URL + String posterUploadUrl, + // 썸네일 업로드용 Presigned URL + String thumbnailUploadUrl, + // 원본 영상 업로드용 Presigned URL + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java new file mode 100644 index 0000000..0ad6637 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java @@ -0,0 +1,197 @@ +package com.ott.api_admin.upload.service; + +import com.ott.api_admin.upload.dto.request.ContentsUploadInitRequest; +import com.ott.api_admin.upload.dto.response.ContentsUploadInitResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.MediaType; +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.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.infra.s3.service.S3PresignService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 콘텐츠 업로드 초기화 비즈니스 로직을 담당합니다. + */ +@Service +@RequiredArgsConstructor +public class ContentsUploadService { + + private final ContentsRepository contentsRepository; + private final SeriesRepository seriesRepository; + private final MediaRepository mediaRepository; + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; + + @Transactional + // 콘텐츠/미디어 레코드를 생성하고 포스터/썸네일/원본 업로드 URL을 발급합니다. + public ContentsUploadInitResponse createContentsUpload(ContentsUploadInitRequest request) { + Member uploader = resolveUploader(); + Series series = resolveSeries(request.seriesId()); + // S3 object key 안정성을 위해 파일명을 정규화합니다. + String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + // 콘텐츠 ID가 생성되기 전이라 최종 S3 URL을 만들 수 없어 임시값으로 저장합니다. + .posterUrl("PENDING") + // 콘텐츠 ID 기반 object key를 만든 뒤 실제 S3 URL로 즉시 갱신됩니다. + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.CONTENTS) + .publicStatus(request.publicStatus()) + .build() + ); + + Contents contents = contentsRepository.save( + Contents.builder() + .media(media) + .series(series) + .actors(request.actors()) + .duration(request.duration()) + .videoSize(request.videoSize()) + // 콘텐츠 ID 생성 후 origin object key가 확정되므로 우선 임시값으로 저장합니다. + .originUrl("PENDING") + // 트랜스코딩 결과 경로를 ID 기준으로 계산한 뒤 실제 S3 URL로 갱신됩니다. + .masterPlaylistUrl("PENDING") + .build() + ); + + Long contentsId = contents.getId(); + String posterObjectKey = buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); + String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; + + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + contents.updateStorageKeys( + s3PresignService.toObjectUrl(originObjectKey), + s3PresignService.toObjectUrl(masterPlaylistObjectKey) + ); + + return new ContentsUploadInitResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) + ); + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + // 요청으로 전달된 seriesId의 존재 여부를 확인합니다. + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + + private String buildObjectKey(String root, Long id, String mediaType, String fileName) { + // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + private String resolveContentType(String fileName) { + // 파일 확장자를 기반으로 업로드 Content-Type을 추론합니다. + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String resolveOriginContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".mp4")) { + return "video/mp4"; + } + if (lowerFileName.endsWith(".mov")) { + return "video/quicktime"; + } + if (lowerFileName.endsWith(".webm")) { + return "video/webm"; + } + if (lowerFileName.endsWith(".m4v")) { + return "video/x-m4v"; + } + // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String sanitizeFileName(String fileName) { + // path traversal/특수문자 이슈를 줄이기 위해 파일명을 안전한 문자 집합으로 제한합니다. + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + private Member resolveUploader() { + // JWT 필터가 설정한 인증 정보에서 현재 로그인 사용자 ID를 꺼냅니다. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + // 최종적으로 DB에서 업로더 회원 엔티티를 조회합니다. + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java new file mode 100644 index 0000000..209d924 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java @@ -0,0 +1,148 @@ +package com.ott.api_admin.upload.service; + +import com.ott.api_admin.upload.dto.request.SeriesUploadInitRequest; +import com.ott.api_admin.upload.dto.response.SeriesUploadInitResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.infra.s3.service.S3PresignService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 시리즈 업로드 초기화 비즈니스 로직을 담당합니다. + */ +@Service +@RequiredArgsConstructor +public class SeriesUploadService { + + private final SeriesRepository seriesRepository; + private final MediaRepository mediaRepository; + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; + + @Transactional + // 시리즈/미디어 레코드를 생성하고 포스터/썸네일 업로드 URL을 발급합니다. + public SeriesUploadInitResponse createSeriesUpload(SeriesUploadInitRequest request) { + Member uploader = resolveUploader(); + // S3 object key 안정성을 위해 파일명을 정규화합니다. + String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + // 시리즈 ID가 생성되기 전이라 최종 S3 URL을 만들 수 없어 임시값으로 저장합니다. + .posterUrl("PENDING") + // 시리즈 ID 기반 object key를 만든 뒤 실제 S3 URL로 즉시 갱신됩니다. + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SERIES) + .publicStatus(request.publicStatus()) + .build() + ); + + Series series = seriesRepository.save( + Series.builder() + .media(media) + .actors(request.actors()) + .build() + ); + + Long seriesId = series.getId(); + String posterObjectKey = buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + + return new SeriesUploadInitResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)) + ); + } + + private String buildObjectKey(String root, Long id, String mediaType, String fileName) { + // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + private String resolveContentType(String fileName) { + // 파일 확장자를 기반으로 업로드 Content-Type을 추론합니다. + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String sanitizeFileName(String fileName) { + // path traversal/특수문자 이슈를 줄이기 위해 파일명을 안전한 문자 집합으로 제한합니다. + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + private Member resolveUploader() { + // JWT 필터가 설정한 인증 정보에서 현재 로그인 사용자 ID를 꺼냅니다. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + // 최종적으로 DB에서 업로더 회원 엔티티를 조회합니다. + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java new file mode 100644 index 0000000..7448cb0 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java @@ -0,0 +1,219 @@ +package com.ott.api_admin.upload.service; + +import com.ott.api_admin.upload.dto.request.ShortFormUploadInitRequest; +import com.ott.api_admin.upload.dto.response.ShortFormUploadInitResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.MediaType; +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.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.domain.short_form.domain.ShortForm; +import com.ott.domain.short_form.repository.ShortFormRepository; +import com.ott.infra.s3.service.S3PresignService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 숏폼 업로드 초기화 비즈니스 로직을 담당합니다. + */ +@Service +@RequiredArgsConstructor +public class ShortFormUploadService { + + private final ShortFormRepository shortFormRepository; + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; + private final MediaRepository mediaRepository; + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; + + @Transactional + // 숏폼/미디어 레코드를 생성하고 포스터/썸네일/원본 업로드 URL을 발급합니다. + public ShortFormUploadInitResponse createShortFormUpload(ShortFormUploadInitRequest request) { + // 숏폼은 시리즈 또는 콘텐츠 중 하나에만 연결되도록 강제합니다. + validateExclusiveTarget(request.seriesId(), request.contentsId()); + + Member uploader = resolveUploader(); + Series series = resolveSeries(request.seriesId()); + Contents contents = resolveContents(request.contentsId()); + // S3 object key 안정성을 위해 파일명을 정규화합니다. + String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + // 숏폼 ID가 생성되기 전이라 최종 S3 URL을 만들 수 없어 임시값으로 저장합니다. + .posterUrl("PENDING") + // 숏폼 ID 기반 object key를 만든 뒤 실제 S3 URL로 즉시 갱신됩니다. + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SHORT_FORM) + .publicStatus(request.publicStatus()) + .build() + ); + + ShortForm shortForm = shortFormRepository.save( + ShortForm.builder() + .media(media) + .series(series) + .contents(contents) + .duration(request.duration()) + .videoSize(request.videoSize()) + // 숏폼 ID 생성 후 origin object key가 확정되므로 우선 임시값으로 저장합니다. + .originUrl("PENDING") + // 트랜스코딩 결과 경로를 ID 기준으로 계산한 뒤 실제 S3 URL로 갱신됩니다. + .masterPlaylistUrl("PENDING") + .build() + ); + + Long shortFormId = shortForm.getId(); + String posterObjectKey = buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); + String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; + + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + shortForm.updateStorageKeys( + s3PresignService.toObjectUrl(originObjectKey), + s3PresignService.toObjectUrl(masterPlaylistObjectKey) + ); + + return new ShortFormUploadInitResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) + ); + } + + private void validateExclusiveTarget(Long seriesId, Long contentsId) { + // 둘 다 비었거나 둘 다 값이 있으면 잘못된 요청입니다. + if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + // 요청으로 전달된 seriesId의 존재 여부를 확인합니다. + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + + private Contents resolveContents(Long contentsId) { + if (contentsId == null) { + return null; + } + // 요청으로 전달된 contentsId의 존재 여부를 확인합니다. + return contentsRepository.findById(contentsId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + } + + private String buildObjectKey(String root, Long id, String mediaType, String fileName) { + // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + private String resolveContentType(String fileName) { + // 파일 확장자를 기반으로 업로드 Content-Type을 추론합니다. + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String resolveOriginContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".mp4")) { + return "video/mp4"; + } + if (lowerFileName.endsWith(".mov")) { + return "video/quicktime"; + } + if (lowerFileName.endsWith(".webm")) { + return "video/webm"; + } + if (lowerFileName.endsWith(".m4v")) { + return "video/x-m4v"; + } + // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String sanitizeFileName(String fileName) { + // path traversal/특수문자 이슈를 줄이기 위해 파일명을 안전한 문자 집합으로 제한합니다. + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + private Member resolveUploader() { + // JWT 필터가 설정한 인증 정보에서 현재 로그인 사용자 ID를 꺼냅니다. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + // 최종적으로 DB에서 업로더 회원 엔티티를 조회합니다. + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } +} diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index 800db85..5a3ef52 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') implementation project(':modules:common-web') implementation project(':modules:common-security') @@ -28,7 +28,7 @@ dependencies { * Flyway가 infra 모듈의 db/migration 리소스를 확실히 인식하도록 설정 */ tasks.named('processResources') { - from(project(':modules:infra').sourceSets.main.resources.srcDirs) { + from(project(':modules:infra-db').sourceSets.main.resources.srcDirs) { include 'db/migration/**' } } diff --git a/apps/transcoder/build.gradle b/apps/transcoder/build.gradle index 7ca7b84..9f63a10 100644 --- a/apps/transcoder/build.gradle +++ b/apps/transcoder/build.gradle @@ -2,8 +2,8 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') implementation project(':modules:common-web') implementation project(':modules:common-security') implementation 'org.springframework.boot:spring-boot-starter-web' -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index 0018ec2..f67b9a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ - services: + # ============ DB ============ mysql: image: mysql:8.0 container_name: ott-mysql @@ -10,7 +10,6 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_CHARACTER_SET_SERVER: utf8mb4 MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci - ports: - "3307:3306" volumes: @@ -21,7 +20,7 @@ services: timeout: 5s retries: 20 - # ============ Flyway 마이그레이션 담당 (먼저 기동) ============ + # ============ Flyway 마이그레이션 해당 (먼저 기동) ============ api-user: build: context: . @@ -34,6 +33,12 @@ services: SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PUBLIC_BASE_URL: ${AWS_S3_PUBLIC_BASE_URL} + AWS_S3_PRESIGN_EXPIRE_SECONDS: ${AWS_S3_PRESIGN_EXPIRE_SECONDS} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} FRONTEND_URL: ${FRONTEND_URL} @@ -41,16 +46,15 @@ services: mysql: condition: service_healthy healthcheck: - test: [ "CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1" ] + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] interval: 10s timeout: 5s retries: 12 start_period: 30s - - # ============ Flyway 완류 이후 빌드하는 앱 ============ + # ============ Flyway 완료 이후 빌드하는 앱 ============ api-admin: - build: # 이미지를 다운받는게 아니라 해당 경로에서 빌드 + build: context: . dockerfile: apps/api-admin/Dockerfile container_name: ott-api-admin @@ -61,12 +65,19 @@ services: SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PUBLIC_BASE_URL: ${AWS_S3_PUBLIC_BASE_URL} + AWS_S3_PRESIGN_EXPIRE_SECONDS: ${AWS_S3_PRESIGN_EXPIRE_SECONDS} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} depends_on: mysql: condition: service_healthy api-user: condition: service_healthy + # ============ Transcoder 워커 ============ transcoder: build: context: . @@ -86,5 +97,3 @@ services: volumes: mysql-data: - - \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index 105653b..f31ac99 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -53,4 +53,9 @@ public class Contents extends BaseEntity { @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + + public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { + this.originUrl = originUrl; + this.masterPlaylistUrl = masterPlaylistUrl; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index 4ebf89a..ff2bfec 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -62,4 +62,9 @@ public class Media extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "public_status", nullable = false) private PublicStatus publicStatus; + + public void updateImageKeys(String posterUrl, String thumbnailUrl) { + this.posterUrl = posterUrl; + this.thumbnailUrl = thumbnailUrl; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 2663489..ab2ee5f 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -58,6 +58,11 @@ public class ShortForm extends BaseEntity { @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { + this.originUrl = originUrl; + this.masterPlaylistUrl = masterPlaylistUrl; + } + public Optional findOriginMedia() { if (series != null) return Optional.of(series.getMedia()); if (contents != null) return Optional.of(contents.getMedia()); diff --git a/modules/infra/build.gradle b/modules/infra-db/build.gradle similarity index 76% rename from modules/infra/build.gradle rename to modules/infra-db/build.gradle index e42dc85..0109ae5 100644 --- a/modules/infra/build.gradle +++ b/modules/infra-db/build.gradle @@ -2,5 +2,4 @@ dependencies { implementation project(':modules:domain') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' - implementation 'software.amazon.awssdk:s3:2.21.0' -} \ No newline at end of file +} diff --git a/modules/infra/src/main/resources/db/migration/V1__init.sql b/modules/infra-db/src/main/resources/db/migration/V1__init.sql similarity index 100% rename from modules/infra/src/main/resources/db/migration/V1__init.sql rename to modules/infra-db/src/main/resources/db/migration/V1__init.sql diff --git a/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql b/modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql similarity index 100% rename from modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql rename to modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql diff --git a/modules/infra-s3/build.gradle b/modules/infra-s3/build.gradle new file mode 100644 index 0000000..db4b45f --- /dev/null +++ b/modules/infra-s3/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation 'org.springframework:spring-context' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'software.amazon.awssdk:s3:2.21.0' +} diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java new file mode 100644 index 0000000..aac21c9 --- /dev/null +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java @@ -0,0 +1,20 @@ +package com.ott.infra.s3.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3PresignerConfig { + + @Bean + public S3Presigner s3Presigner(@Value("${aws.region:ap-northeast-2}") String region) { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java new file mode 100644 index 0000000..0bc37bb --- /dev/null +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java @@ -0,0 +1,68 @@ +package com.ott.infra.s3.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Service +public class S3PresignService { + + private final S3Presigner s3Presigner; + private final String region; + private final String bucket; + private final String publicBaseUrl; + private final long expireSeconds; + + public S3PresignService( + S3Presigner s3Presigner, + @Value("${aws.region:ap-northeast-2}") String region, + @Value("${aws.s3.bucket:local-bucket}") String bucket, + @Value("${aws.s3.public-base-url:}") String publicBaseUrl, + @Value("${aws.s3.presign-expire-seconds:600}") long expireSeconds + ) { + this.s3Presigner = s3Presigner; + this.region = region; + this.bucket = bucket; + this.publicBaseUrl = publicBaseUrl; + this.expireSeconds = expireSeconds; + } + + public String createPutPresignedUrl(String objectKey, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expireSeconds)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + return presignedRequest.url().toString(); + } + + // objectKey를 실제 S3 객체 URL 형식으로 변환합니다. + // - 한글/공백/특수문자 깨짐 방지를 위해 URL 인코딩 + // - 공백은 '+' 대신 '%20'으로 정규화 + // - 경로 구분자는 유지하기 위해 '%2F'를 '/'로 복원 + public String toObjectUrl(String objectKey) { + String encodedKey = URLEncoder.encode(objectKey, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%2F", "/"); + + // public-base-url이 설정되어 있으면 우선 사용하고, 없으면 기존 S3 URL 규칙으로 fallback합니다. + String baseUrl = (publicBaseUrl == null || publicBaseUrl.isBlank()) + ? "https://" + bucket + ".s3." + region + ".amazonaws.com" + : publicBaseUrl.replaceAll("/+$", ""); + return baseUrl + "/" + encodedKey; + } +} diff --git a/modules/infra/src/main/java/com/ott/infra/db/.gitkeep b/modules/infra/src/main/java/com/ott/infra/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/infra/src/main/java/com/ott/infra/s3/.gitkeep b/modules/infra/src/main/java/com/ott/infra/s3/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/settings.gradle b/settings.gradle index 10f714a..c86a7f4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,7 +8,8 @@ include( // Modules ":modules:domain", - ":modules:infra", + ":modules:infra-db", + ":modules:infra-s3", ":modules:common-web", ":modules:common-security" -) \ No newline at end of file +) From e330c76907f1dee8745d6a1718baea440e0f7390 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 14:40:02 +0900 Subject: [PATCH 080/257] =?UTF-8?q?[OT-104]=20[DOCS]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20API=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 23 +++++++++++++- .../controller/BackOfficeSeriesApi.java | 22 ++++++++++++++ .../controller/BackOfficeShortFormApi.java | 30 +++++++++++++++---- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index e818a5d..fa4883f 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -1,7 +1,9 @@ package com.ott.api_admin.content.controller; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -16,8 +18,8 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; - @Tag(name = "BackOffice Contents API", description = "[백오피스] 콘텐츠 관리 API") public interface BackOfficeContentsApi { @@ -65,4 +67,23 @@ ResponseEntity>> getContents( ResponseEntity> getContentsDetail( @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable Long mediaId ); + + @Operation(summary = "콘텐츠 메타데이터 업로드", description = "콘텐츠 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "콘텐츠 메타데이터 업로드 및 Presigned URL 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "콘텐츠 메타데이터 업로드 및 Presigned URL 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN만 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createContentsUpload( + @RequestBody ContentsUploadRequest request + ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 7270949..b8a2d35 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -1,8 +1,10 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -16,6 +18,7 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Series API", description = "[백오피스] 시리즈 관리 API") @@ -89,4 +92,23 @@ ResponseEntity>> getSeries ResponseEntity> getSeriesDetail( @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable Long mediaId ); + + @Operation(summary = "시리즈 메타데이터 업로드", description = "시리즈 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "시리즈 메타데이터 업로드 및 Presigned URL 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = SeriesUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "시리즈 메타데이터 업로드 및 Presigned URL 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN만 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createSeriesUpload( + @RequestBody SeriesUploadRequest request + ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index b7bfa4b..a41ff42 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -1,10 +1,10 @@ package com.ott.api_admin.shortform.controller; -import com.ott.api_admin.content.dto.response.ContentsDetailResponse; -import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -20,6 +20,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Short-Form API", description = "[백오피스] 숏폼 관리 API") @@ -84,4 +85,23 @@ ResponseEntity> getShortFormDetail( @Parameter(description = "조회할 숏폼의 미디어 ID", required = true) @PathVariable Long mediaId, Authentication authentication ); + + @Operation(summary = "숏폼 메타데이터 업로드", description = "숏폼 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "숏폼 메타데이터 업로드 및 Presigned URL 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "숏폼 메타데이터 업로드 및 Presigned URL 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN, EDITOR 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createShortFormUpload( + @RequestBody ShortFormUploadRequest request + ); } \ No newline at end of file From 7e1a16010188df8d08938b3837c1fbd697d69e54 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 14:40:22 +0900 Subject: [PATCH 081/257] =?UTF-8?q?[OT-104]=20[DOCS]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20Response=20DTO=20Schema=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ContentsUploadResponse.java | 27 +++++++++++++++++++ .../dto/response/SeriesUploadResponse.java | 18 +++++++++++++ .../dto/response/ShortFormUploadResponse.java | 24 +++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java new file mode 100644 index 0000000..d8ace3d --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 콘텐츠 업로드 초기화 응답 DTO입니다. + */ +@Schema(description = "콘텐츠 업로드 응답") +public record ContentsUploadResponse( + // 생성된 콘텐츠 ID + Long contentsId, + // 포스터 S3 object key + String posterObjectKey, + // 썸네일 S3 object key + String thumbnailObjectKey, + // 원본 영상 S3 object key + String originObjectKey, + // 트랜스코딩 결과 마스터 플레이리스트 object key + String masterPlaylistObjectKey, + // 포스터 업로드용 Presigned URL + String posterUploadUrl, + // 썸네일 업로드용 Presigned URL + String thumbnailUploadUrl, + // 원본 영상 업로드용 Presigned URL + String originUploadUrl +) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java new file mode 100644 index 0000000..b64c4a3 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java @@ -0,0 +1,18 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "시리즈 업로드 응답") +public record SeriesUploadResponse( + // 생성된 시리즈 ID + Long seriesId, + // 포스터 S3 object key + String posterObjectKey, + // 썸네일 S3 object key + String thumbnailObjectKey, + // 포스터 업로드용 Presigned URL + String posterUploadUrl, + // 썸네일 업로드용 Presigned URL + String thumbnailUploadUrl +) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java new file mode 100644 index 0000000..6ae7288 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java @@ -0,0 +1,24 @@ +package com.ott.api_admin.shortform.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "숏폼 업로드 응답") +public record ShortFormUploadResponse( + // 생성된 숏폼 ID + Long shortFormId, + // 포스터 S3 object key + String posterObjectKey, + // 썸네일 S3 object key + String thumbnailObjectKey, + // 원본 영상 S3 object key + String originObjectKey, + // 트랜스코딩 결과 마스터 플레이리스트 object key + String masterPlaylistObjectKey, + // 포스터 업로드용 Presigned URL + String posterUploadUrl, + // 썸네일 업로드용 Presigned URL + String thumbnailUploadUrl, + // 원본 영상 업로드용 Presigned URL + String originUploadUrl +) { +} \ No newline at end of file From 32a0169ccec96df1f6bb3991403725f35e93efb7 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 14:40:40 +0900 Subject: [PATCH 082/257] =?UTF-8?q?[OT-104]=20[REFACTOR]:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?Mapper=20=ED=8C=A8=ED=84=B4=EC=9C=BC=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/BackOfficeContentsMapper.java | 23 ++ .../service/BackOfficeContentsService.java | 176 ++++++++++++++- .../series/mapper/BackOfficeSeriesMapper.java | 17 ++ .../service/BackOfficeSeriesService.java | 126 ++++++++++- .../mapper/BackOfficeShortFormMapper.java | 29 ++- .../service/BackOfficeShortFormService.java | 204 ++++++++++++++++-- 6 files changed, 543 insertions(+), 32 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java index 3897988..f0850d5 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -2,6 +2,7 @@ import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.domain.contents.domain.Contents; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; @@ -45,6 +46,28 @@ public ContentsDetailResponse toContentsDetailResponse(Contents contents, Media ); } + public ContentsUploadResponse toContentsUploadResponse( + Long contentsId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ContentsUploadResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index a5e3a46..2853344 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -1,7 +1,9 @@ package com.ott.api_admin.content.service; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -15,10 +17,17 @@ import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +42,9 @@ public class BackOfficeContentsService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; private final ContentsRepository contentsRepository; + private final SeriesRepository seriesRepository; + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; @Transactional(readOnly = true) public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { @@ -76,4 +88,166 @@ public ContentsDetailResponse getContentsDetail(Long mediaId) { return backOfficeContentsMapper.toContentsDetailResponse(contents, media, uploaderNickname, seriesTitle, mediaTagList); } -} + + @Transactional + // 콘텐츠/미디어 레코드를 생성하고 S3 업로드용 Presigned URL을 발급합니다. + public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request) { + Member uploader = resolveUploader(); + Series series = resolveSeries(request.seriesId()); + + // S3 object key 안정성을 위해 파일명을 정규화합니다. + String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + // 콘텐츠 ID 생성 전이라 최종 URL을 만들 수 없어 임시값을 저장합니다. + .posterUrl("PENDING") + // 콘텐츠 ID 기반 object key 확정 후 실제 S3 URL로 즉시 갱신합니다. + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.CONTENTS) + .publicStatus(request.publicStatus()) + .build() + ); + + Contents contents = contentsRepository.save( + Contents.builder() + .media(media) + .series(series) + .actors(request.actors()) + .duration(request.duration()) + .videoSize(request.videoSize()) + // 콘텐츠 ID 생성 전이라 원본 URL을 확정할 수 없어 임시값을 저장합니다. + .originUrl("PENDING") + // 트랜스코딩 결과 URL도 ID 기반 경로 계산 후 갱신합니다. + .masterPlaylistUrl("PENDING") + .build() + ); + + Long contentsId = contents.getId(); + String posterObjectKey = buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); + String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; + + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + contents.updateStorageKeys( + s3PresignService.toObjectUrl(originObjectKey), + s3PresignService.toObjectUrl(masterPlaylistObjectKey) + ); + + return backOfficeContentsMapper.toContentsUploadResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) + ); + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + // 요청으로 전달된 seriesId의 존재 여부를 확인합니다. + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + + private String buildObjectKey(String root, Long id, String mediaType, String fileName) { + // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + private String resolveContentType(String fileName) { + // 파일 확장자를 기반으로 이미지 Content-Type을 결정합니다. + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String resolveOriginContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".mp4")) { + return "video/mp4"; + } + if (lowerFileName.endsWith(".mov")) { + return "video/quicktime"; + } + if (lowerFileName.endsWith(".webm")) { + return "video/webm"; + } + if (lowerFileName.endsWith(".m4v")) { + return "video/x-m4v"; + } + // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String sanitizeFileName(String fileName) { + // 경로 문자/특수문자를 제거해 업로드 파일명을 안전한 형태로 정규화합니다. + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + private Member resolveUploader() { + // SecurityContext의 principal(memberId)을 기반으로 업로더를 조회합니다. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 7e6506d..29f6eba 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -3,6 +3,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.series.domain.Series; @@ -53,6 +54,22 @@ public SeriesDetailResponse toSeriesDetailResponse(Series series, Media media, S ); } + public SeriesUploadResponse toSeriesUploadResponse( + Long seriesId, + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + return new SeriesUploadResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 9f28a11..642ec33 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,25 +1,31 @@ package com.ott.api_admin.series.service; -import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; 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.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 com.ott.common.web.response.PageInfo; -import com.ott.common.web.response.PageResponse; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -37,20 +43,19 @@ public class BackOfficeSeriesService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; private final SeriesRepository seriesRepository; + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 1. 미디어 중 시리즈 대상 페이징 Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWord(pageable, MediaType.SERIES, searchWord); - // 2. 조회된 미디어 ID 목록 추출 List mediaIdList = mediaPage.getContent().stream() .map(Media::getId) .toList(); - // 3. IN절로 태그 일괄 조회 Map> tagListByMediaId = mediaIdList.isEmpty() ? Collections.emptyMap() : mediaTagRepository.findWithTagAndCategoryByMediaIds(mediaIdList).stream() @@ -75,7 +80,6 @@ public PageResponse getSeries(int page, int size, String sea public PageResponse getSeriesTitle(Integer page, Integer size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 시리즈 + 미디어 페이징 Page seriesPage = seriesRepository.findSeriesListWithMediaBySearchWord(pageable, searchWord); List responseList = seriesPage.getContent().stream() @@ -92,16 +96,120 @@ public PageResponse getSeriesTitle(Integer page, Intege @Transactional(readOnly = true) public SeriesDetailResponse getSeriesDetail(Long mediaId) { - // 1. Series + Media + Uploader 한 번에 조회 Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Media media = series.getMedia(); String uploaderNickname = media.getUploader().getNickname(); - // 2. 태그 조회 List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); return backOfficeSeriesMapper.toSeriesDetailResponse(series, media, uploaderNickname, mediaTagList); } + + @Transactional + public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { + Member uploader = resolveUploader(); + String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + .posterUrl("PENDING") + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SERIES) + .publicStatus(request.publicStatus()) + .build() + ); + + Series series = seriesRepository.save( + Series.builder() + .media(media) + .actors(request.actors()) + .build() + ); + + Long seriesId = series.getId(); + String posterObjectKey = buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + + return backOfficeSeriesMapper.toSeriesUploadResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)) + ); + } + + private String buildObjectKey(String root, Long id, String mediaType, String fileName) { + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + private String resolveContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String sanitizeFileName(String fileName) { + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + private Member resolveUploader() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } } + diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java index 562f780..c339cab 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -1,8 +1,9 @@ package com.ott.api_admin.shortform.mapper; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; import com.ott.domain.common.MediaType; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; @@ -60,6 +61,28 @@ public OriginMediaTitleListResponse toOriginMediaTitleListResponse( ); } + public ShortFormUploadResponse toShortFormUploadResponse( + Long shortFormId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ShortFormUploadResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 32a8dc5..ca5b5b5 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,8 +1,10 @@ package com.ott.api_admin.shortform.service; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -10,22 +12,26 @@ 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 com.ott.domain.member.domain.Member; import com.ott.domain.member.domain.Role; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import com.ott.domain.short_form.domain.ShortForm; import com.ott.domain.short_form.repository.ShortFormRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,6 +51,8 @@ public class BackOfficeShortFormService { private final SeriesRepository seriesRepository; private final ContentsRepository contentsRepository; private final ShortFormRepository shortFormRepository; + private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; @Transactional(readOnly = true) public PageResponse getShortFormList( @@ -52,15 +60,14 @@ public PageResponse getShortFormList( ) { Pageable pageable = PageRequest.of(page, size); - // 1. 관리자/에디터 여부 확인 Long memberId = (Long) authentication.getPrincipal(); boolean isEditor = authentication.getAuthorities().stream() .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); Long uploaderId = null; - // 2. 에디터인 경우 본인이 업로드한 숏폼만 조회 가능 - if (isEditor) + if (isEditor) { uploaderId = memberId; + } Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId @@ -83,12 +90,10 @@ public PageResponse getShortFormList( public PageResponse getOriginMediaTitle(Integer page, Integer size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 1. Media 페이징 조회 (Series + 단편 Contents / 에피소드 제외) Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); List mediaList = mediaPage.getContent(); - // 2. mediaId를 타입별로 분리 List seriesMediaIdList = mediaList.stream() .filter(m -> m.getMediaType() == MediaType.SERIES) .map(Media::getId) @@ -99,14 +104,12 @@ public PageResponse getOriginMediaTitle(Integer pa .map(Media::getId) .toList(); - // 3. 일괄 조회: mediaId → entityId 매핑 Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList).stream() .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); - // 4. 응답 매핑 List responseList = mediaList.stream() .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, contentsIdByMediaId)) .toList(); @@ -122,30 +125,193 @@ public PageResponse getOriginMediaTitle(Integer pa @Transactional(readOnly = true) public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { - // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 조회 ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - // 2. 에디터 - 숏폼 업로더 권한 체크 Long memberId = (Long) authentication.getPrincipal(); boolean isEditor = authentication.getAuthorities().stream() .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); Media media = shortForm.getMedia(); - if (isEditor && !media.getUploader().getId().equals(memberId)) + if (isEditor && !media.getUploader().getId().equals(memberId)) { throw new BusinessException(ErrorCode.FORBIDDEN); + } String uploaderNickname = media.getUploader().getNickname(); - // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 Optional originMedia = shortForm.findOriginMedia(); String originMediaTitle = null; - if (originMedia.isPresent()) + if (originMedia.isPresent()) { originMediaTitle = originMedia.get().getTitle(); + } - // 3. 태그 조회 - List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); // 숏폼은 원본 콘텐츠의 태그를 따라가지만, 자체 태그로 생성되어 있음을 상정 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMediaTitle, mediaTagList); } + + @Transactional + public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request) { + validateExclusiveTarget(request.seriesId(), request.contentsId()); + + Member uploader = resolveUploader(); + Series series = resolveSeries(request.seriesId()); + Contents contents = resolveContents(request.contentsId()); + String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + .posterUrl("PENDING") + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SHORT_FORM) + .publicStatus(request.publicStatus()) + .build() + ); + + ShortForm shortForm = shortFormRepository.save( + ShortForm.builder() + .media(media) + .series(series) + .contents(contents) + .duration(request.duration()) + .videoSize(request.videoSize()) + .originUrl("PENDING") + .masterPlaylistUrl("PENDING") + .build() + ); + + Long shortFormId = shortForm.getId(); + String posterObjectKey = buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); + String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; + + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + shortForm.updateStorageKeys( + s3PresignService.toObjectUrl(originObjectKey), + s3PresignService.toObjectUrl(masterPlaylistObjectKey) + ); + + return backOfficeShortFormMapper.toShortFormUploadResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) + ); + } + + private void validateExclusiveTarget(Long seriesId, Long contentsId) { + if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + + private Contents resolveContents(Long contentsId) { + if (contentsId == null) { + return null; + } + return contentsRepository.findById(contentsId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + } + + private String buildObjectKey(String root, Long id, String mediaType, String fileName) { + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + private String resolveContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String resolveOriginContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".mp4")) { + return "video/mp4"; + } + if (lowerFileName.endsWith(".mov")) { + return "video/quicktime"; + } + if (lowerFileName.endsWith(".webm")) { + return "video/webm"; + } + if (lowerFileName.endsWith(".m4v")) { + return "video/x-m4v"; + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private String sanitizeFileName(String fileName) { + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + private Member resolveUploader() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } } + From 5d3708ca5cfc3035d9dc4e3ff07fc0a686eb1e90 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 14:40:56 +0900 Subject: [PATCH 083/257] =?UTF-8?q?[OT-104]=20[REFACTOR]:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20DTO=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20import=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackOfficeContentsController.java | 22 +- .../dto/request/ContentsUploadRequest.java} | 11 +- .../BackOfficeSeriesController.java | 21 +- .../dto/request/SeriesUploadRequest.java} | 11 +- .../BackOfficeShortFormController.java | 27 ++- .../dto/request/ShortFormUploadRequest.java} | 11 +- .../OriginMediaTitleListResponse.java | 2 +- .../ShortFormDetailResponse.java | 2 +- .../{ => response}/ShortFormListResponse.java | 2 +- .../upload/controller/ContentsUploadApi.java | 19 -- .../controller/ContentsUploadController.java | 35 --- .../upload/controller/SeriesUploadApi.java | 19 -- .../controller/SeriesUploadController.java | 35 --- .../upload/controller/ShortFormUploadApi.java | 19 -- .../controller/ShortFormUploadController.java | 35 --- .../com/ott/api_admin/upload/dto/.gitkeep | 0 .../response/ContentsUploadInitResponse.java | 24 -- .../response/SeriesUploadInitResponse.java | 18 -- .../response/ShortFormUploadInitResponse.java | 24 -- .../com/ott/api_admin/upload/service/.gitkeep | 0 .../upload/service/ContentsUploadService.java | 197 ---------------- .../upload/service/SeriesUploadService.java | 148 ------------ .../service/ShortFormUploadService.java | 219 ------------------ 23 files changed, 77 insertions(+), 824 deletions(-) rename apps/api-admin/src/main/java/com/ott/api_admin/{upload/dto/request/ContentsUploadInitRequest.java => content/dto/request/ContentsUploadRequest.java} (82%) rename apps/api-admin/src/main/java/com/ott/api_admin/{upload/dto/request/SeriesUploadInitRequest.java => series/dto/request/SeriesUploadRequest.java} (75%) rename apps/api-admin/src/main/java/com/ott/api_admin/{upload/dto/request/ShortFormUploadInitRequest.java => shortform/dto/request/ShortFormUploadRequest.java} (81%) rename apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/{ => response}/OriginMediaTitleListResponse.java (91%) rename apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/{ => response}/ShortFormDetailResponse.java (97%) rename apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/{ => response}/ShortFormListResponse.java (94%) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index 093daec..930982f 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -1,18 +1,18 @@ package com.ott.api_admin.content.controller; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; 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 jakarta.validation.Valid; 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; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/back-office/admin/contents") @@ -43,4 +43,14 @@ public ResponseEntity> getContentsDetail SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) ); } -} + + @Override + @PostMapping("/upload") + @PreAuthorize("hasRole('ADMIN')") + // ADMIN 권한으로 콘텐츠 업로드 초기화를 수행합니다. + public ResponseEntity> createContentsUpload( + @Valid @RequestBody ContentsUploadRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.createContentsUpload(request))); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java similarity index 82% rename from apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java rename to apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index c0fbd50..0d0d1d3 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ContentsUploadInitRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -1,13 +1,12 @@ -package com.ott.api_admin.upload.dto.request; +package com.ott.api_admin.content.dto.request; import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -/** - * 콘텐츠 업로드 초기화 요청 DTO입니다. - */ -public record ContentsUploadInitRequest( +@Schema(description = "콘텐츠 업로드 요청") +public record ContentsUploadRequest( // 연결할 시리즈 ID (없으면 단일 콘텐츠) Long seriesId, // 콘텐츠 제목 @@ -29,4 +28,4 @@ public record ContentsUploadInitRequest( // 원본 영상 파일명 @NotBlank String originFileName ) { -} +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 5786f8e..7d48045 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -1,14 +1,24 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.service.BackOfficeSeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/back-office/admin/series") @@ -48,4 +58,13 @@ public ResponseEntity> getSeriesDetail(@Pa SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(mediaId)) ); } + + @Override + @PostMapping("/upload") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> createSeriesUpload( + @Valid @RequestBody SeriesUploadRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.createSeriesUpload(request))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java similarity index 75% rename from apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java rename to apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java index 4267522..3e3298a 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/SeriesUploadInitRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -1,13 +1,12 @@ -package com.ott.api_admin.upload.dto.request; +package com.ott.api_admin.series.dto.request; import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -/** - * 시리즈 업로드 초기화 요청 DTO입니다. - */ -public record SeriesUploadInitRequest( +@Schema(description = "시리즈 업로드 요청") +public record SeriesUploadRequest( // 시리즈 제목 @NotBlank String title, // 시리즈 설명 @@ -21,4 +20,4 @@ public record SeriesUploadInitRequest( // 썸네일 원본 파일명 @NotBlank String thumbnailFileName ) { -} +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index cf205c4..4a8284f 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -1,16 +1,26 @@ package com.ott.api_admin.shortform.controller; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.service.BackOfficeShortFormService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import com.ott.domain.common.PublicStatus; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/back-office/short-forms") @@ -55,4 +65,13 @@ public ResponseEntity> getShortFormDeta SuccessResponse.of(backOfficeShortFormService.getShortFormDetail(mediaId, authentication)) ); } + + @Override + @PostMapping("/upload") + @PreAuthorize("hasAnyRole('ADMIN','EDITOR')") + public ResponseEntity> createShortFormUpload( + @Valid @RequestBody ShortFormUploadRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.createShortFormUpload(request))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java similarity index 81% rename from apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index d23fcda..ee6824a 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/request/ShortFormUploadInitRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -1,13 +1,12 @@ -package com.ott.api_admin.upload.dto.request; +package com.ott.api_admin.shortform.dto.request; import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -/** - * 숏폼 업로드 초기화 요청 DTO입니다. - */ -public record ShortFormUploadInitRequest( +@Schema(description = "숏폼 업로드 요청") +public record ShortFormUploadRequest( // 연결할 시리즈 ID (선택) Long seriesId, // 연결할 콘텐츠 ID (선택) @@ -29,4 +28,4 @@ public record ShortFormUploadInitRequest( // 원본 영상 파일명 @NotBlank String originFileName ) { -} +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java similarity index 91% rename from apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java index e01bb3c..1e68251 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.dto; +package com.ott.api_admin.shortform.dto.response; import com.ott.domain.common.MediaType; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java similarity index 97% rename from apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java index 1d4debc..c81184b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.dto; +package com.ott.api_admin.shortform.dto.response; import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java similarity index 94% rename from apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java index 15a4106..863c2cb 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.dto; +package com.ott.api_admin.shortform.dto.response; import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java deleted file mode 100644 index 90bb6ca..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadApi.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.ott.api_admin.upload.controller; - -import com.ott.api_admin.upload.dto.request.ContentsUploadInitRequest; -import com.ott.api_admin.upload.dto.response.ContentsUploadInitResponse; -import com.ott.common.web.response.SuccessResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; - -/** - * 콘텐츠 업로드 초기화 API 명세입니다. - */ -public interface ContentsUploadApi { - /** - * 콘텐츠 업로드에 필요한 DB 레코드와 Presigned URL을 생성합니다. - */ - ResponseEntity> createContentsUpload( - @RequestBody ContentsUploadInitRequest request - ); -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java deleted file mode 100644 index 56fbd17..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ContentsUploadController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ott.api_admin.upload.controller; - -import com.ott.api_admin.upload.dto.request.ContentsUploadInitRequest; -import com.ott.api_admin.upload.dto.response.ContentsUploadInitResponse; -import com.ott.api_admin.upload.service.ContentsUploadService; -import com.ott.common.web.response.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.http.ResponseEntity; -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; - -/** - * 콘텐츠 업로드 초기화 요청을 처리하는 컨트롤러입니다. - */ -@RestController -@RequestMapping("/back-office/admin/upload/contents") -@RequiredArgsConstructor -public class ContentsUploadController implements ContentsUploadApi { - - private final ContentsUploadService contentsUploadService; - - @Override - @PostMapping("/init") - @PreAuthorize("hasRole('ADMIN')") - // ADMIN 권한으로 콘텐츠 업로드 초기화를 수행합니다. - public ResponseEntity> createContentsUpload( - @Valid @RequestBody ContentsUploadInitRequest request - ) { - return ResponseEntity.ok(SuccessResponse.of(contentsUploadService.createContentsUpload(request))); - } -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java deleted file mode 100644 index e49cc52..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadApi.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.ott.api_admin.upload.controller; - -import com.ott.api_admin.upload.dto.request.SeriesUploadInitRequest; -import com.ott.api_admin.upload.dto.response.SeriesUploadInitResponse; -import com.ott.common.web.response.SuccessResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; - -/** - * 시리즈 업로드 초기화 API 명세입니다. - */ -public interface SeriesUploadApi { - /** - * 시리즈 업로드에 필요한 DB 레코드와 Presigned URL을 생성합니다. - */ - ResponseEntity> createSeriesUpload( - @RequestBody SeriesUploadInitRequest request - ); -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java deleted file mode 100644 index 8a02f57..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/SeriesUploadController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ott.api_admin.upload.controller; - -import com.ott.api_admin.upload.dto.request.SeriesUploadInitRequest; -import com.ott.api_admin.upload.dto.response.SeriesUploadInitResponse; -import com.ott.api_admin.upload.service.SeriesUploadService; -import com.ott.common.web.response.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.http.ResponseEntity; -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; - -/** - * 시리즈 업로드 초기화 요청을 처리하는 컨트롤러입니다. - */ -@RestController -@RequestMapping("/back-office/admin/upload/series") -@RequiredArgsConstructor -public class SeriesUploadController implements SeriesUploadApi { - - private final SeriesUploadService seriesUploadService; - - @Override - @PostMapping("/init") - @PreAuthorize("hasRole('ADMIN')") - // ADMIN 권한으로 시리즈 업로드 초기화를 수행합니다. - public ResponseEntity> createSeriesUpload( - @Valid @RequestBody SeriesUploadInitRequest request - ) { - return ResponseEntity.ok(SuccessResponse.of(seriesUploadService.createSeriesUpload(request))); - } -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java deleted file mode 100644 index 501d882..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadApi.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.ott.api_admin.upload.controller; - -import com.ott.api_admin.upload.dto.request.ShortFormUploadInitRequest; -import com.ott.api_admin.upload.dto.response.ShortFormUploadInitResponse; -import com.ott.common.web.response.SuccessResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; - -/** - * 숏폼 업로드 초기화 API 명세입니다. - */ -public interface ShortFormUploadApi { - /** - * 숏폼 업로드에 필요한 DB 레코드와 Presigned URL을 생성합니다. - */ - ResponseEntity> createShortFormUpload( - @RequestBody ShortFormUploadInitRequest request - ); -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java deleted file mode 100644 index 7034c7b..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/ShortFormUploadController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ott.api_admin.upload.controller; - -import com.ott.api_admin.upload.dto.request.ShortFormUploadInitRequest; -import com.ott.api_admin.upload.dto.response.ShortFormUploadInitResponse; -import com.ott.api_admin.upload.service.ShortFormUploadService; -import com.ott.common.web.response.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.http.ResponseEntity; -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; - -/** - * 숏폼 업로드 초기화 요청을 처리하는 컨트롤러입니다. - */ -@RestController -@RequestMapping("/back-office/admin/upload/short-forms") -@RequiredArgsConstructor -public class ShortFormUploadController implements ShortFormUploadApi { - - private final ShortFormUploadService shortFormUploadService; - - @Override - @PostMapping("/init") - @PreAuthorize("hasAnyRole('ADMIN','EDITOR')") - // ADMIN 또는 EDITOR 권한으로 숏폼 업로드 초기화를 수행합니다. - public ResponseEntity> createShortFormUpload( - @Valid @RequestBody ShortFormUploadInitRequest request - ) { - return ResponseEntity.ok(SuccessResponse.of(shortFormUploadService.createShortFormUpload(request))); - } -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java deleted file mode 100644 index 259299c..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ContentsUploadInitResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ott.api_admin.upload.dto.response; - -/** - * 콘텐츠 업로드 초기화 응답 DTO입니다. - */ -public record ContentsUploadInitResponse( - // 생성된 콘텐츠 ID - Long contentsId, - // 포스터 저장 오브젝트 키 - String posterObjectKey, - // 썸네일 저장 오브젝트 키 - String thumbnailObjectKey, - // 원본 영상 저장 오브젝트 키 - String originObjectKey, - // 트랜스코딩 결과 마스터 플레이리스트 오브젝트 키 - String masterPlaylistObjectKey, - // 포스터 업로드용 Presigned URL - String posterUploadUrl, - // 썸네일 업로드용 Presigned URL - String thumbnailUploadUrl, - // 원본 영상 업로드용 Presigned URL - String originUploadUrl -) { -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java deleted file mode 100644 index 8f27ce9..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/SeriesUploadInitResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ott.api_admin.upload.dto.response; - -/** - * 시리즈 업로드 초기화 응답 DTO입니다. - */ -public record SeriesUploadInitResponse( - // 생성된 시리즈 ID - Long seriesId, - // 포스터 저장 오브젝트 키 - String posterObjectKey, - // 썸네일 저장 오브젝트 키 - String thumbnailObjectKey, - // 포스터 업로드용 Presigned URL - String posterUploadUrl, - // 썸네일 업로드용 Presigned URL - String thumbnailUploadUrl -) { -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java deleted file mode 100644 index aeba09f..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/response/ShortFormUploadInitResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ott.api_admin.upload.dto.response; - -/** - * 숏폼 업로드 초기화 응답 DTO입니다. - */ -public record ShortFormUploadInitResponse( - // 생성된 숏폼 ID - Long shortFormId, - // 포스터 저장 오브젝트 키 - String posterObjectKey, - // 썸네일 저장 오브젝트 키 - String thumbnailObjectKey, - // 원본 영상 저장 오브젝트 키 - String originObjectKey, - // 트랜스코딩 결과 마스터 플레이리스트 오브젝트 키 - String masterPlaylistObjectKey, - // 포스터 업로드용 Presigned URL - String posterUploadUrl, - // 썸네일 업로드용 Presigned URL - String thumbnailUploadUrl, - // 원본 영상 업로드용 Presigned URL - String originUploadUrl -) { -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java deleted file mode 100644 index 0ad6637..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ContentsUploadService.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.ott.api_admin.upload.service; - -import com.ott.api_admin.upload.dto.request.ContentsUploadInitRequest; -import com.ott.api_admin.upload.dto.response.ContentsUploadInitResponse; -import com.ott.common.web.exception.BusinessException; -import com.ott.common.web.exception.ErrorCode; -import com.ott.domain.common.MediaType; -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.member.domain.Member; -import com.ott.domain.member.repository.MemberRepository; -import com.ott.domain.series.domain.Series; -import com.ott.domain.series.repository.SeriesRepository; -import com.ott.infra.s3.service.S3PresignService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 콘텐츠 업로드 초기화 비즈니스 로직을 담당합니다. - */ -@Service -@RequiredArgsConstructor -public class ContentsUploadService { - - private final ContentsRepository contentsRepository; - private final SeriesRepository seriesRepository; - private final MediaRepository mediaRepository; - private final MemberRepository memberRepository; - private final S3PresignService s3PresignService; - - @Transactional - // 콘텐츠/미디어 레코드를 생성하고 포스터/썸네일/원본 업로드 URL을 발급합니다. - public ContentsUploadInitResponse createContentsUpload(ContentsUploadInitRequest request) { - Member uploader = resolveUploader(); - Series series = resolveSeries(request.seriesId()); - // S3 object key 안정성을 위해 파일명을 정규화합니다. - String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); - String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); - - Media media = mediaRepository.save( - Media.builder() - .uploader(uploader) - .title(request.title()) - .description(request.description()) - // 콘텐츠 ID가 생성되기 전이라 최종 S3 URL을 만들 수 없어 임시값으로 저장합니다. - .posterUrl("PENDING") - // 콘텐츠 ID 기반 object key를 만든 뒤 실제 S3 URL로 즉시 갱신됩니다. - .thumbnailUrl("PENDING") - .bookmarkCount(0L) - .likesCount(0L) - .mediaType(MediaType.CONTENTS) - .publicStatus(request.publicStatus()) - .build() - ); - - Contents contents = contentsRepository.save( - Contents.builder() - .media(media) - .series(series) - .actors(request.actors()) - .duration(request.duration()) - .videoSize(request.videoSize()) - // 콘텐츠 ID 생성 후 origin object key가 확정되므로 우선 임시값으로 저장합니다. - .originUrl("PENDING") - // 트랜스코딩 결과 경로를 ID 기준으로 계산한 뒤 실제 S3 URL로 갱신됩니다. - .masterPlaylistUrl("PENDING") - .build() - ); - - Long contentsId = contents.getId(); - String posterObjectKey = buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); - String originObjectKey = buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); - String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; - - media.updateImageKeys( - s3PresignService.toObjectUrl(posterObjectKey), - s3PresignService.toObjectUrl(thumbnailObjectKey) - ); - contents.updateStorageKeys( - s3PresignService.toObjectUrl(originObjectKey), - s3PresignService.toObjectUrl(masterPlaylistObjectKey) - ); - - return new ContentsUploadInitResponse( - contentsId, - posterObjectKey, - thumbnailObjectKey, - originObjectKey, - masterPlaylistObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), - s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) - ); - } - - private Series resolveSeries(Long seriesId) { - if (seriesId == null) { - return null; - } - // 요청으로 전달된 seriesId의 존재 여부를 확인합니다. - return seriesRepository.findById(seriesId) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - } - - private String buildObjectKey(String root, Long id, String mediaType, String fileName) { - // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} - return root + "/" + id + "/" + mediaType + "/" + fileName; - } - - private String resolveContentType(String fileName) { - // 파일 확장자를 기반으로 업로드 Content-Type을 추론합니다. - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; - } - // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String resolveOriginContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".mp4")) { - return "video/mp4"; - } - if (lowerFileName.endsWith(".mov")) { - return "video/quicktime"; - } - if (lowerFileName.endsWith(".webm")) { - return "video/webm"; - } - if (lowerFileName.endsWith(".m4v")) { - return "video/x-m4v"; - } - // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String sanitizeFileName(String fileName) { - // path traversal/특수문자 이슈를 줄이기 위해 파일명을 안전한 문자 집합으로 제한합니다. - String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; - - String sanitizedName = namePart - .replace("/", "") - .replace("\\", "") - .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - - if (sanitizedName.isBlank()) { - sanitizedName = "file"; - } - if (sanitizedExt.isBlank()) { - // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - return sanitizedName + "." + sanitizedExt; - } - - private Member resolveUploader() { - // JWT 필터가 설정한 인증 정보에서 현재 로그인 사용자 ID를 꺼냅니다. - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - // 최종적으로 DB에서 업로더 회원 엔티티를 조회합니다. - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); - } -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java deleted file mode 100644 index 209d924..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/SeriesUploadService.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.ott.api_admin.upload.service; - -import com.ott.api_admin.upload.dto.request.SeriesUploadInitRequest; -import com.ott.api_admin.upload.dto.response.SeriesUploadInitResponse; -import com.ott.common.web.exception.BusinessException; -import com.ott.common.web.exception.ErrorCode; -import com.ott.domain.common.MediaType; -import com.ott.domain.media.domain.Media; -import com.ott.domain.media.repository.MediaRepository; -import com.ott.domain.member.domain.Member; -import com.ott.domain.member.repository.MemberRepository; -import com.ott.domain.series.domain.Series; -import com.ott.domain.series.repository.SeriesRepository; -import com.ott.infra.s3.service.S3PresignService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 시리즈 업로드 초기화 비즈니스 로직을 담당합니다. - */ -@Service -@RequiredArgsConstructor -public class SeriesUploadService { - - private final SeriesRepository seriesRepository; - private final MediaRepository mediaRepository; - private final MemberRepository memberRepository; - private final S3PresignService s3PresignService; - - @Transactional - // 시리즈/미디어 레코드를 생성하고 포스터/썸네일 업로드 URL을 발급합니다. - public SeriesUploadInitResponse createSeriesUpload(SeriesUploadInitRequest request) { - Member uploader = resolveUploader(); - // S3 object key 안정성을 위해 파일명을 정규화합니다. - String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); - - Media media = mediaRepository.save( - Media.builder() - .uploader(uploader) - .title(request.title()) - .description(request.description()) - // 시리즈 ID가 생성되기 전이라 최종 S3 URL을 만들 수 없어 임시값으로 저장합니다. - .posterUrl("PENDING") - // 시리즈 ID 기반 object key를 만든 뒤 실제 S3 URL로 즉시 갱신됩니다. - .thumbnailUrl("PENDING") - .bookmarkCount(0L) - .likesCount(0L) - .mediaType(MediaType.SERIES) - .publicStatus(request.publicStatus()) - .build() - ); - - Series series = seriesRepository.save( - Series.builder() - .media(media) - .actors(request.actors()) - .build() - ); - - Long seriesId = series.getId(); - String posterObjectKey = buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); - media.updateImageKeys( - s3PresignService.toObjectUrl(posterObjectKey), - s3PresignService.toObjectUrl(thumbnailObjectKey) - ); - - return new SeriesUploadInitResponse( - seriesId, - posterObjectKey, - thumbnailObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)) - ); - } - - private String buildObjectKey(String root, Long id, String mediaType, String fileName) { - // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} - return root + "/" + id + "/" + mediaType + "/" + fileName; - } - - private String resolveContentType(String fileName) { - // 파일 확장자를 기반으로 업로드 Content-Type을 추론합니다. - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; - } - // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String sanitizeFileName(String fileName) { - // path traversal/특수문자 이슈를 줄이기 위해 파일명을 안전한 문자 집합으로 제한합니다. - String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; - - String sanitizedName = namePart - .replace("/", "") - .replace("\\", "") - .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - - if (sanitizedName.isBlank()) { - sanitizedName = "file"; - } - if (sanitizedExt.isBlank()) { - // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - return sanitizedName + "." + sanitizedExt; - } - - private Member resolveUploader() { - // JWT 필터가 설정한 인증 정보에서 현재 로그인 사용자 ID를 꺼냅니다. - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - // 최종적으로 DB에서 업로더 회원 엔티티를 조회합니다. - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); - } -} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java deleted file mode 100644 index 7448cb0..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/ShortFormUploadService.java +++ /dev/null @@ -1,219 +0,0 @@ -package com.ott.api_admin.upload.service; - -import com.ott.api_admin.upload.dto.request.ShortFormUploadInitRequest; -import com.ott.api_admin.upload.dto.response.ShortFormUploadInitResponse; -import com.ott.common.web.exception.BusinessException; -import com.ott.common.web.exception.ErrorCode; -import com.ott.domain.common.MediaType; -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.member.domain.Member; -import com.ott.domain.member.repository.MemberRepository; -import com.ott.domain.series.domain.Series; -import com.ott.domain.series.repository.SeriesRepository; -import com.ott.domain.short_form.domain.ShortForm; -import com.ott.domain.short_form.repository.ShortFormRepository; -import com.ott.infra.s3.service.S3PresignService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 숏폼 업로드 초기화 비즈니스 로직을 담당합니다. - */ -@Service -@RequiredArgsConstructor -public class ShortFormUploadService { - - private final ShortFormRepository shortFormRepository; - private final SeriesRepository seriesRepository; - private final ContentsRepository contentsRepository; - private final MediaRepository mediaRepository; - private final MemberRepository memberRepository; - private final S3PresignService s3PresignService; - - @Transactional - // 숏폼/미디어 레코드를 생성하고 포스터/썸네일/원본 업로드 URL을 발급합니다. - public ShortFormUploadInitResponse createShortFormUpload(ShortFormUploadInitRequest request) { - // 숏폼은 시리즈 또는 콘텐츠 중 하나에만 연결되도록 강제합니다. - validateExclusiveTarget(request.seriesId(), request.contentsId()); - - Member uploader = resolveUploader(); - Series series = resolveSeries(request.seriesId()); - Contents contents = resolveContents(request.contentsId()); - // S3 object key 안정성을 위해 파일명을 정규화합니다. - String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); - String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); - - Media media = mediaRepository.save( - Media.builder() - .uploader(uploader) - .title(request.title()) - .description(request.description()) - // 숏폼 ID가 생성되기 전이라 최종 S3 URL을 만들 수 없어 임시값으로 저장합니다. - .posterUrl("PENDING") - // 숏폼 ID 기반 object key를 만든 뒤 실제 S3 URL로 즉시 갱신됩니다. - .thumbnailUrl("PENDING") - .bookmarkCount(0L) - .likesCount(0L) - .mediaType(MediaType.SHORT_FORM) - .publicStatus(request.publicStatus()) - .build() - ); - - ShortForm shortForm = shortFormRepository.save( - ShortForm.builder() - .media(media) - .series(series) - .contents(contents) - .duration(request.duration()) - .videoSize(request.videoSize()) - // 숏폼 ID 생성 후 origin object key가 확정되므로 우선 임시값으로 저장합니다. - .originUrl("PENDING") - // 트랜스코딩 결과 경로를 ID 기준으로 계산한 뒤 실제 S3 URL로 갱신됩니다. - .masterPlaylistUrl("PENDING") - .build() - ); - - Long shortFormId = shortForm.getId(); - String posterObjectKey = buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); - String originObjectKey = buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); - String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; - - media.updateImageKeys( - s3PresignService.toObjectUrl(posterObjectKey), - s3PresignService.toObjectUrl(thumbnailObjectKey) - ); - shortForm.updateStorageKeys( - s3PresignService.toObjectUrl(originObjectKey), - s3PresignService.toObjectUrl(masterPlaylistObjectKey) - ); - - return new ShortFormUploadInitResponse( - shortFormId, - posterObjectKey, - thumbnailObjectKey, - originObjectKey, - masterPlaylistObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), - s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) - ); - } - - private void validateExclusiveTarget(Long seriesId, Long contentsId) { - // 둘 다 비었거나 둘 다 값이 있으면 잘못된 요청입니다. - if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - } - - private Series resolveSeries(Long seriesId) { - if (seriesId == null) { - return null; - } - // 요청으로 전달된 seriesId의 존재 여부를 확인합니다. - return seriesRepository.findById(seriesId) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - } - - private Contents resolveContents(Long contentsId) { - if (contentsId == null) { - return null; - } - // 요청으로 전달된 contentsId의 존재 여부를 확인합니다. - return contentsRepository.findById(contentsId) - .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - } - - private String buildObjectKey(String root, Long id, String mediaType, String fileName) { - // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} - return root + "/" + id + "/" + mediaType + "/" + fileName; - } - - private String resolveContentType(String fileName) { - // 파일 확장자를 기반으로 업로드 Content-Type을 추론합니다. - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; - } - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String resolveOriginContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".mp4")) { - return "video/mp4"; - } - if (lowerFileName.endsWith(".mov")) { - return "video/quicktime"; - } - if (lowerFileName.endsWith(".webm")) { - return "video/webm"; - } - if (lowerFileName.endsWith(".m4v")) { - return "video/x-m4v"; - } - // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String sanitizeFileName(String fileName) { - // path traversal/특수문자 이슈를 줄이기 위해 파일명을 안전한 문자 집합으로 제한합니다. - String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; - - String sanitizedName = namePart - .replace("/", "") - .replace("\\", "") - .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - - if (sanitizedName.isBlank()) { - sanitizedName = "file"; - } - if (sanitizedExt.isBlank()) { - // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - return sanitizedName + "." + sanitizedExt; - } - - private Member resolveUploader() { - // JWT 필터가 설정한 인증 정보에서 현재 로그인 사용자 ID를 꺼냅니다. - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - // 최종적으로 DB에서 업로더 회원 엔티티를 조회합니다. - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); - } -} From ff7c125f0776af092ae1bd67066e9f5609863a3d Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 15:01:36 +0900 Subject: [PATCH 084/257] =?UTF-8?q?[OT-104]=20[DOCS]:=20ContentsUploadResp?= =?UTF-8?q?onse=20JavaDoc=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/dto/response/ContentsUploadResponse.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java index d8ace3d..e9477a4 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java @@ -2,9 +2,6 @@ import io.swagger.v3.oas.annotations.media.Schema; -/** - * 콘텐츠 업로드 초기화 응답 DTO입니다. - */ @Schema(description = "콘텐츠 업로드 응답") public record ContentsUploadResponse( // 생성된 콘텐츠 ID From 84665cfb43c3e47639ebc3be0d015b6d20beee03 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 16:31:24 +0900 Subject: [PATCH 085/257] =?UTF-8?q?[OT-104][FIX]:=20sanitizeFileName?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=95=9C=EA=B8=80=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EB=B3=B4=EC=A1=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/service/BackOfficeContentsService.java | 2 +- .../ott/api_admin/series/service/BackOfficeSeriesService.java | 2 +- .../api_admin/shortform/service/BackOfficeShortFormService.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index 2853344..3d91b58 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -250,4 +250,4 @@ private Member resolveUploader() { return memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); } -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 642ec33..1fd3ead 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -178,7 +178,7 @@ private String sanitizeFileName(String fileName) { String sanitizedName = namePart .replace("/", "") .replace("\\", "") - .replaceAll("[^0-9A-Za-z_-]", ""); + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); if (sanitizedName.isBlank()) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index ca5b5b5..941e180 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -280,7 +280,7 @@ private String sanitizeFileName(String fileName) { String sanitizedName = namePart .replace("/", "") .replace("\\", "") - .replaceAll("[^0-9A-Za-z_-]", ""); + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); if (sanitizedName.isBlank()) { From b4c7434bd25c548743829a5d3212bb43b1177871 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 16:31:54 +0900 Subject: [PATCH 086/257] =?UTF-8?q?[OT-104][DOCS]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9A=94=EC=B2=AD=20videoSize=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=EB=A5=BC=20KB=EB=A1=9C=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/dto/request/ContentsUploadRequest.java | 4 ++-- .../shortform/dto/request/ShortFormUploadRequest.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index 0d0d1d3..74c6a9e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -19,7 +19,7 @@ public record ContentsUploadRequest( @NotNull PublicStatus publicStatus, // 영상 길이(초) Integer duration, - // 영상 크기(바이트 또는 내부 단위) + // 영상 크기(KB) Integer videoSize, // 포스터 원본 파일명 @NotBlank String posterFileName, @@ -28,4 +28,4 @@ public record ContentsUploadRequest( // 원본 영상 파일명 @NotBlank String originFileName ) { -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index ee6824a..3636bdd 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -19,7 +19,7 @@ public record ShortFormUploadRequest( @NotNull PublicStatus publicStatus, // 영상 길이(초) Integer duration, - // 영상 크기(바이트 또는 내부 단위) + // 영상 크기(KB) Integer videoSize, // 포스터 원본 파일명 @NotBlank String posterFileName, @@ -28,4 +28,4 @@ public record ShortFormUploadRequest( // 원본 영상 파일명 @NotBlank String originFileName ) { -} \ No newline at end of file +} From e37a6d3115e2579a6c0681e03a7f0bd446add6f4 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 16:32:08 +0900 Subject: [PATCH 087/257] =?UTF-8?q?[OT-104][FIX]:=20Media/Contents/ShortFo?= =?UTF-8?q?rm=20=EC=A0=80=EC=9E=A5=20=ED=82=A4=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20null=20=EB=B0=A9=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/domain/contents/domain/Contents.java | 4 +++- .../src/main/java/com/ott/domain/media/domain/Media.java | 4 +++- .../main/java/com/ott/domain/short_form/domain/ShortForm.java | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index f31ac99..f0540ec 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -19,6 +19,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Objects; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -55,7 +57,7 @@ public class Contents extends BaseEntity { private String masterPlaylistUrl; public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { - this.originUrl = originUrl; + this.originUrl = Objects.requireNonNull(originUrl, "originUrl must not be null"); this.masterPlaylistUrl = masterPlaylistUrl; } } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index ff2bfec..fd49fb7 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -21,6 +21,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Objects; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -64,7 +66,7 @@ public class Media extends BaseEntity { private PublicStatus publicStatus; public void updateImageKeys(String posterUrl, String thumbnailUrl) { - this.posterUrl = posterUrl; + this.posterUrl = Objects.requireNonNull(posterUrl, "posterUrl must not be null"); this.thumbnailUrl = thumbnailUrl; } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index ab2ee5f..210f3a5 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -21,6 +21,7 @@ import lombok.NoArgsConstructor; import java.util.Optional; +import java.util.Objects; @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -59,7 +60,7 @@ public class ShortForm extends BaseEntity { private String masterPlaylistUrl; public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { - this.originUrl = originUrl; + this.originUrl = Objects.requireNonNull(originUrl, "originUrl must not be null"); this.masterPlaylistUrl = masterPlaylistUrl; } From be579059481206608078e3ca743d9d7fa1f1fff4 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 16:32:21 +0900 Subject: [PATCH 088/257] =?UTF-8?q?[OT-104][BUILD]:=20infra-s3=20AWS=20SDK?= =?UTF-8?q?=EB=A5=BC=20BOM=20=EA=B8=B0=EB=B0=98=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/infra-s3/build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/infra-s3/build.gradle b/modules/infra-s3/build.gradle index db4b45f..4ae4833 100644 --- a/modules/infra-s3/build.gradle +++ b/modules/infra-s3/build.gradle @@ -1,5 +1,6 @@ dependencies { implementation 'org.springframework:spring-context' implementation 'org.springframework.boot:spring-boot-autoconfigure' - implementation 'software.amazon.awssdk:s3:2.21.0' + implementation platform('software.amazon.awssdk:bom:2.42.0') + implementation 'software.amazon.awssdk:s3' } From ea972f9fc2813d7d6f66b9b953d05f9bb6c17cee Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 16:32:40 +0900 Subject: [PATCH 089/257] =?UTF-8?q?[OT-104][FIX]:=20S3=20Presign=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=95=88=EC=A0=84=ED=95=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A1=9C=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EB=9E=98=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/s3/service/S3PresignService.java | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java index 0bc37bb..03a771a 100644 --- a/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java @@ -2,6 +2,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -35,19 +36,23 @@ public S3PresignService( } public String createPutPresignedUrl(String objectKey, String contentType) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucket) - .key(objectKey) - .contentType(contentType) - .build(); + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(); - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofSeconds(expireSeconds)) - .putObjectRequest(putObjectRequest) - .build(); + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expireSeconds)) + .putObjectRequest(putObjectRequest) + .build(); - PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); - return presignedRequest.url().toString(); + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + return presignedRequest.url().toString(); + } catch (SdkException ex) { + throw new IllegalStateException("업로드 URL 생성에 실패했습니다.", ex); + } } // objectKey를 실제 S3 객체 URL 형식으로 변환합니다. From f390392017d2f408dcd7184bb07c0cdbd6b1d1b3 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 17:04:07 +0900 Subject: [PATCH 090/257] =?UTF-8?q?[OT-104][REFACTOR]:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?UploadHelper=EB=A1=9C=20=EC=B6=94=EC=B6=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BackOfficeContentsService.java | 113 ++---------------- .../service/BackOfficeSeriesService.java | 83 ++----------- .../service/BackOfficeShortFormService.java | 105 ++-------------- .../upload/support/UploadHelper.java | 95 +++++++++++++++ 4 files changed, 131 insertions(+), 265 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index 3d91b58..e5f15e8 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -1,10 +1,11 @@ -package com.ott.api_admin.content.service; +package com.ott.api_admin.content.service; import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; +import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -18,7 +19,6 @@ import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.media_tag.repository.MediaTagRepository; import com.ott.domain.member.domain.Member; -import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import com.ott.infra.s3.service.S3PresignService; @@ -26,8 +26,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,8 +41,8 @@ public class BackOfficeContentsService { private final MediaTagRepository mediaTagRepository; private final ContentsRepository contentsRepository; private final SeriesRepository seriesRepository; - private final MemberRepository memberRepository; private final S3PresignService s3PresignService; + private final UploadHelper uploadHelper; @Transactional(readOnly = true) public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { @@ -92,13 +90,13 @@ public ContentsDetailResponse getContentsDetail(Long mediaId) { @Transactional // 콘텐츠/미디어 레코드를 생성하고 S3 업로드용 Presigned URL을 발급합니다. public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request) { - Member uploader = resolveUploader(); + Member uploader = uploadHelper.resolveUploader(); Series series = resolveSeries(request.seriesId()); // S3 object key 안정성을 위해 파일명을 정규화합니다. - String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); - String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); Media media = mediaRepository.save( Media.builder() @@ -131,9 +129,9 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request ); Long contentsId = contents.getId(); - String posterObjectKey = buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); - String originObjectKey = buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); + String posterObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; media.updateImageKeys( @@ -151,9 +149,9 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request thumbnailObjectKey, originObjectKey, masterPlaylistObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), - s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) + s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, uploadHelper.resolveVideoContentType(sanitizedOriginFileName)) ); } @@ -165,89 +163,4 @@ private Series resolveSeries(Long seriesId) { return seriesRepository.findById(seriesId) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); } - - private String buildObjectKey(String root, Long id, String mediaType, String fileName) { - // S3 저장 경로 규칙: {root}/{id}/{mediaType}/{fileName} - return root + "/" + id + "/" + mediaType + "/" + fileName; - } - - private String resolveContentType(String fileName) { - // 파일 확장자를 기반으로 이미지 Content-Type을 결정합니다. - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; - } - // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String resolveOriginContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".mp4")) { - return "video/mp4"; - } - if (lowerFileName.endsWith(".mov")) { - return "video/quicktime"; - } - if (lowerFileName.endsWith(".webm")) { - return "video/webm"; - } - if (lowerFileName.endsWith(".m4v")) { - return "video/x-m4v"; - } - // 미지원 확장자는 Presigned URL 발급 전에 차단합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String sanitizeFileName(String fileName) { - // 경로 문자/특수문자를 제거해 업로드 파일명을 안전한 형태로 정규화합니다. - String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; - - String sanitizedName = namePart - .replace("/", "") - .replace("\\", "") - .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - - if (sanitizedName.isBlank()) { - sanitizedName = "file"; - } - if (sanitizedExt.isBlank()) { - // 확장자가 없으면 MIME 추론이 불가능하므로 요청을 거부합니다. - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - return sanitizedName + "." + sanitizedExt; - } - - private Member resolveUploader() { - // SecurityContext의 principal(memberId)을 기반으로 업로더를 조회합니다. - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); - } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 1fd3ead..7f1e620 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.series.service; +package com.ott.api_admin.series.service; import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; @@ -6,6 +6,7 @@ import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; +import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -16,7 +17,6 @@ import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.media_tag.repository.MediaTagRepository; import com.ott.domain.member.domain.Member; -import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import com.ott.infra.s3.service.S3PresignService; @@ -24,8 +24,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,8 +41,8 @@ public class BackOfficeSeriesService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; private final SeriesRepository seriesRepository; - private final MemberRepository memberRepository; private final S3PresignService s3PresignService; + private final UploadHelper uploadHelper; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { @@ -109,9 +107,9 @@ public SeriesDetailResponse getSeriesDetail(Long mediaId) { @Transactional public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { - Member uploader = resolveUploader(); - String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); + Member uploader = uploadHelper.resolveUploader(); + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); Media media = mediaRepository.save( Media.builder() @@ -135,8 +133,8 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { ); Long seriesId = series.getId(); - String posterObjectKey = buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); + String posterObjectKey = uploadHelper.buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = uploadHelper.buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); media.updateImageKeys( s3PresignService.toObjectUrl(posterObjectKey), s3PresignService.toObjectUrl(thumbnailObjectKey) @@ -146,70 +144,9 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { seriesId, posterObjectKey, thumbnailObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)) + s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)) ); } - - private String buildObjectKey(String root, Long id, String mediaType, String fileName) { - return root + "/" + id + "/" + mediaType + "/" + fileName; - } - - private String resolveContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; - } - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String sanitizeFileName(String fileName) { - String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; - - String sanitizedName = namePart - .replace("/", "") - .replace("\\", "") - .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - - if (sanitizedName.isBlank()) { - sanitizedName = "file"; - } - if (sanitizedExt.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - return sanitizedName + "." + sanitizedExt; - } - - private Member resolveUploader() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); - } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 941e180..4238adf 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.service; +package com.ott.api_admin.shortform.service; import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; @@ -6,6 +6,7 @@ import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; +import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -20,7 +21,6 @@ import com.ott.domain.media_tag.repository.MediaTagRepository; import com.ott.domain.member.domain.Member; import com.ott.domain.member.domain.Role; -import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import com.ott.domain.short_form.domain.ShortForm; @@ -31,7 +31,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,8 +50,8 @@ public class BackOfficeShortFormService { private final SeriesRepository seriesRepository; private final ContentsRepository contentsRepository; private final ShortFormRepository shortFormRepository; - private final MemberRepository memberRepository; private final S3PresignService s3PresignService; + private final UploadHelper uploadHelper; @Transactional(readOnly = true) public PageResponse getShortFormList( @@ -154,12 +153,12 @@ public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication a public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request) { validateExclusiveTarget(request.seriesId(), request.contentsId()); - Member uploader = resolveUploader(); + Member uploader = uploadHelper.resolveUploader(); Series series = resolveSeries(request.seriesId()); Contents contents = resolveContents(request.contentsId()); - String sanitizedPosterFileName = sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = sanitizeFileName(request.thumbnailFileName()); - String sanitizedOriginFileName = sanitizeFileName(request.originFileName()); + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); Media media = mediaRepository.save( Media.builder() @@ -188,9 +187,9 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ ); Long shortFormId = shortForm.getId(); - String posterObjectKey = buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); - String originObjectKey = buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); + String posterObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; media.updateImageKeys( @@ -208,9 +207,9 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ thumbnailObjectKey, originObjectKey, masterPlaylistObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, resolveContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, resolveContentType(sanitizedThumbnailFileName)), - s3PresignService.createPutPresignedUrl(originObjectKey, resolveOriginContentType(sanitizedOriginFileName)) + s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, uploadHelper.resolveVideoContentType(sanitizedOriginFileName)) ); } @@ -235,83 +234,5 @@ private Contents resolveContents(Long contentsId) { return contentsRepository.findById(contentsId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); } - - private String buildObjectKey(String root, Long id, String mediaType, String fileName) { - return root + "/" + id + "/" + mediaType + "/" + fileName; - } - - private String resolveContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; - } - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String resolveOriginContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".mp4")) { - return "video/mp4"; - } - if (lowerFileName.endsWith(".mov")) { - return "video/quicktime"; - } - if (lowerFileName.endsWith(".webm")) { - return "video/webm"; - } - if (lowerFileName.endsWith(".m4v")) { - return "video/x-m4v"; - } - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - - private String sanitizeFileName(String fileName) { - String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; - - String sanitizedName = namePart - .replace("/", "") - .replace("\\", "") - .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - - if (sanitizedName.isBlank()) { - sanitizedName = "file"; - } - if (sanitizedExt.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT); - } - return sanitizedName + "." + sanitizedExt; - } - - private Member resolveUploader() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - return memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); - } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java new file mode 100644 index 0000000..6e144ef --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -0,0 +1,95 @@ +package com.ott.api_admin.upload.support; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UploadHelper { + + private final MemberRepository memberRepository; + + public String buildObjectKey(String root, Long id, String mediaType, String fileName) { + return root + "/" + id + "/" + mediaType + "/" + fileName; + } + + public String resolveImageContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerFileName.endsWith(".png")) { + return "image/png"; + } + if (lowerFileName.endsWith(".webp")) { + return "image/webp"; + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + public String resolveVideoContentType(String fileName) { + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".mp4")) { + return "video/mp4"; + } + if (lowerFileName.endsWith(".mov")) { + return "video/quicktime"; + } + if (lowerFileName.endsWith(".webm")) { + return "video/webm"; + } + if (lowerFileName.endsWith(".m4v")) { + return "video/x-m4v"; + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + public String sanitizeFileName(String fileName) { + String trimmed = fileName == null ? "" : fileName.trim(); + int lastDot = trimmed.lastIndexOf('.'); + String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; + String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + + String sanitizedName = namePart + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); + String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedName.isBlank()) { + sanitizedName = "file"; + } + if (sanitizedExt.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedName + "." + sanitizedExt; + } + + public Member resolveUploader() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } +} From 43199163ddd68fc21c8ad8507afe39c2a4daf5ea Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 24 Feb 2026 17:04:28 +0900 Subject: [PATCH 091/257] =?UTF-8?q?[OT-104][FIX]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9A=94=EC=B2=AD=20duration/videoSize=20=EC=9D=8C?= =?UTF-8?q?=EC=88=98=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/dto/request/ContentsUploadRequest.java | 3 +++ .../shortform/dto/request/ShortFormUploadRequest.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index 74c6a9e..07124b8 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; @Schema(description = "콘텐츠 업로드 요청") public record ContentsUploadRequest( @@ -18,8 +19,10 @@ public record ContentsUploadRequest( // 공개 상태 @NotNull PublicStatus publicStatus, // 영상 길이(초) + @PositiveOrZero Integer duration, // 영상 크기(KB) + @PositiveOrZero Integer videoSize, // 포스터 원본 파일명 @NotBlank String posterFileName, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index 3636bdd..3b9e0e2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; @Schema(description = "숏폼 업로드 요청") public record ShortFormUploadRequest( @@ -18,8 +19,10 @@ public record ShortFormUploadRequest( // 공개 상태 @NotNull PublicStatus publicStatus, // 영상 길이(초) + @PositiveOrZero Integer duration, // 영상 크기(KB) + @PositiveOrZero Integer videoSize, // 포스터 원본 파일명 @NotBlank String posterFileName, From a9235b04654fcb9eb21311a65bcb257993001cb8 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 24 Feb 2026 20:26:29 +0900 Subject: [PATCH 092/257] =?UTF-8?q?[FEAT]:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/member/controller/MemberApi.java | 50 +++++++++++++++++++ .../member/controller/MemberController.java | 25 ++++++++++ .../member/dto/response/MyPageResponse.java | 40 +++++++++++++++ .../member/service/MemberService.java | 37 ++++++++++++++ .../repository/PreferredTagRepository.java | 19 +++++++ 5 files changed, 171 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java new file mode 100644 index 0000000..0038076 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -0,0 +1,50 @@ +package com.ott.api_user.member.controller; + +import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Tag(name = "Member", description = "마이페이지 API") +@SecurityRequirement(name = "BearerAuth") // 인증인가 확인 +public interface MemberApi { + + @Operation( + summary = "마이페이지 조회", + description = "로그인한 회원의 닉네임과 선호 태그 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MyPageResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "회원을 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java new file mode 100644 index 0000000..5068bf0 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -0,0 +1,25 @@ +package com.ott.api_user.member.controller; + +import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.service.MemberService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/member") +@RequiredArgsConstructor +public class MemberController implements MemberApi { + + private final MemberService memberService; + + @GetMapping("/me") + public ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId) { + MyPageResponse response = memberService.getMyPage(memberId); + return ResponseEntity.ok(SuccessResponse.of(response)); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java new file mode 100644 index 0000000..14b2e0d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java @@ -0,0 +1,40 @@ +package com.ott.api_user.member.dto.response; + +import com.ott.domain.member.domain.Member; +import com.ott.domain.preferred_tag.domain.PreferredTag; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "마이페이지 조회 DTO") +public class MyPageResponse { + + @Schema(type = "Long", example = "1", description = "회원 고유 ID") + private Long memberId; + + @Schema(type = "String", example = "김마루", description = "닉네임") + private String nickname; + + @Schema(type = "List", example = "[영화 | 판타지, 드라마 | 로맨스]", description = "선호 태그 목록") + private List preferredTags; + + + public static MyPageResponse from(Member member, List preferredTags) { + List displays = preferredTags.stream() + .map(pt -> pt.getTag().getCategory().getName() + " | " + pt.getTag().getName()) + .toList(); + + return MyPageResponse.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .preferredTags(displays) // 선호태그 없으면 빈 리스트 출력 + .build(); + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java new file mode 100644 index 0000000..3acf82e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -0,0 +1,37 @@ +package com.ott.api_user.member.service; + +import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.Status; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.preferred_tag.domain.PreferredTag; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final PreferredTagRepository preferredTagRepository; + + + public MyPageResponse getMyPage(Long memberId) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 건너뛰기한 유저는 빈리스트를 반환 + List preferredTags = preferredTagRepository. + findAllWithTagAndCategoryByMemberIdAndStatus(memberId, Status.ACTIVE); + + return MyPageResponse.from(findMember, preferredTags); + } + +} diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java index 823bed6..fdb4164 100644 --- a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -1,8 +1,27 @@ package com.ott.domain.preferred_tag.repository; +import com.ott.domain.common.Status; import com.ott.domain.preferred_tag.domain.PreferredTag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface PreferredTagRepository extends JpaRepository { boolean existsByMemberId(Long memberId); + + @Query(""" + select pt + from PreferredTag pt + join fetch pt.tag t + join fetch t.category c + where pt.member.id = :memberId + and pt.status = :status + and t.status = :status + and c.status = :status + order by pt.id asc + """) + List findAllWithTagAndCategoryByMemberIdAndStatus(@Param("memberId") Long memberId, + @Param("status") Status status); } From 4a8fd176631073df83dc463c68aef78584dd7469 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 24 Feb 2026 20:56:07 +0900 Subject: [PATCH 093/257] =?UTF-8?q?[FEAT]:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CategoryController.java | 25 +++++++++++++++++ .../dto/response/CategoryResponse.java | 27 +++++++++++++++++++ .../category/service/CategoryService.java | 27 +++++++++++++++++++ .../repository/CategoryRepository.java | 4 +++ 4 files changed, 83 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java new file mode 100644 index 0000000..d2532d7 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java @@ -0,0 +1,25 @@ +package com.ott.api_user.category.controller; + +import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.api_user.category.service.CategoryService; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/categories") +@RequiredArgsConstructor +public class CategoryController implements CategoryApi { + + private final CategoryService categoryService; + + @GetMapping + public ResponseEntity>> getCategories() { + return ResponseEntity.ok(SuccessResponse.of(categoryService.getCategories())); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java b/apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java new file mode 100644 index 0000000..89af275 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/dto/response/CategoryResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_user.category.dto.response; + +import com.ott.domain.category.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "카테고리 응답 DTO") +public class CategoryResponse { + + @Schema(type= "Long", example = "1", description = "카테고리 ID") + private Long categoryId; + + @Schema(type ="String", example = "영화", description = "카테고리 이름") + private String name; + + public static CategoryResponse from(Category category) { + return CategoryResponse.builder() + .categoryId(category.getId()) + .name(category.getName()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java b/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java new file mode 100644 index 0000000..508e361 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java @@ -0,0 +1,27 @@ +package com.ott.api_user.category.service; + +import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.Status; +import com.ott.domain.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryService { + + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + + public List getCategories() { + return categoryRepository.findAllByStatus(Status.ACTIVE) + .stream() + .map(CategoryResponse::from) + .toList(); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index 80d19ea..c7cb64c 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -21,4 +21,8 @@ public interface CategoryRepository extends JpaRepository { AND c.status = :status """) List findCategoryNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); + + List findAllByStatus(Status status); + + } From de8cf87fbe42503d63063e4ed1b200e2482145fd Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 24 Feb 2026 21:40:19 +0900 Subject: [PATCH 094/257] =?UTF-8?q?[FEAT]:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=ED=83=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CategoryController.java | 9 +++++++ .../category/service/CategoryService.java | 15 +++++++++++ .../tag/dto/response/TagResponse.java | 27 +++++++++++++++++++ .../ott/common/web/exception/ErrorCode.java | 3 ++- .../repository/CategoryRepository.java | 3 +++ .../domain/tag/repository/TagRepository.java | 3 +++ 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java index d2532d7..a2f9cd3 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java @@ -2,10 +2,12 @@ import com.ott.api_user.category.dto.response.CategoryResponse; import com.ott.api_user.category.service.CategoryService; +import com.ott.api_user.tag.dto.response.TagResponse; import com.ott.common.web.response.SuccessResponse; 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.RestController; @@ -22,4 +24,11 @@ public class CategoryController implements CategoryApi { public ResponseEntity>> getCategories() { return ResponseEntity.ok(SuccessResponse.of(categoryService.getCategories())); } + + @GetMapping("{categoryId}/tags") + public ResponseEntity>> getTagsByCategory( + @PathVariable Long categoryId + ) { + return ResponseEntity.ok(SuccessResponse.of(categoryService.getTagsByCategory(categoryId))); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java b/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java index 508e361..724c3e2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/category/service/CategoryService.java @@ -1,6 +1,10 @@ package com.ott.api_user.category.service; import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.api_user.tag.dto.response.TagResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.category.domain.Category; import com.ott.domain.category.repository.CategoryRepository; import com.ott.domain.common.Status; import com.ott.domain.tag.repository.TagRepository; @@ -24,4 +28,15 @@ public List getCategories() { .map(CategoryResponse::from) .toList(); } + + // 카테고리별 태그 목록 조회 + public List getTagsByCategory(Long categoryId) { + Category findCategory = categoryRepository.findByIdAndStatus(categoryId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.CATEGORY_NOT_FOUND)); + + return tagRepository.findAllByCategoryAndStatus(findCategory, Status.ACTIVE) + .stream() + .map(TagResponse::from) + .toList(); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java new file mode 100644 index 0000000..3c6e111 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_user.tag.dto.response; + +import com.ott.domain.tag.domain.Tag; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "태그 응답 DTO") +public class TagResponse { + + @Schema(type = "Long", example = "1", description = "태그 고유 ID") + private Long tagId; + + @Schema(type = "String", example = "로맨스", description = "태그명") + private String name; + + public static TagResponse from(Tag tag) { + return TagResponse.builder() + .tagId(tag.getId()) + .name(tag.getName()) + .build(); + } +} diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index fb7c39a..55a23d7 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -45,7 +45,8 @@ public enum ErrorCode { CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), - INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다") + INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다.") ; diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index c7cb64c..fc5eb5b 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -1,6 +1,7 @@ package com.ott.domain.category.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -24,5 +25,7 @@ public interface CategoryRepository extends JpaRepository { List findAllByStatus(Status status); + Optional findByIdAndStatus(Long id, Status status); + } diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index 4033293..6e0fdf7 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -2,6 +2,7 @@ import java.util.List; +import com.ott.domain.category.domain.Category; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,4 +22,6 @@ public interface TagRepository extends JpaRepository { AND mt.status = :status """) List findTagNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); + + List findAllByCategoryAndStatus(Category category, Status status); } \ No newline at end of file From 77e86a56c32f549e05be46eef08eb4d09f5bfae5 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 24 Feb 2026 23:07:59 +0900 Subject: [PATCH 095/257] =?UTF-8?q?[FEAT]:=20=EC=9D=B4=EB=A6=84,=20?= =?UTF-8?q?=EC=84=A0=ED=98=B8=20=ED=83=9C=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 14 ++++-- .../dto/request/UpdateMemberRequest.java | 19 ++++++++ .../member/service/MemberService.java | 43 +++++++++++++++++++ .../ott/common/web/exception/ErrorCode.java | 4 +- .../com/ott/domain/member/domain/Member.java | 5 +++ .../repository/PreferredTagRepository.java | 7 +++ .../domain/tag/repository/TagRepository.java | 6 +++ 7 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 5068bf0..3457356 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -1,14 +1,13 @@ package com.ott.api_user.member.controller; +import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; import com.ott.api_user.member.service.MemberService; import com.ott.common.web.response.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/member") @@ -22,4 +21,13 @@ public ResponseEntity> getMyPage(@Authentication MyPageResponse response = memberService.getMyPage(memberId); return ResponseEntity.ok(SuccessResponse.of(response)); } + + @PatchMapping("/me") + public ResponseEntity> updateMyInfo( + @AuthenticationPrincipal Long memberId, + @RequestBody UpdateMemberRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(memberService.updateMyInfo(memberId, request))); + + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java new file mode 100644 index 0000000..0ccce7a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java @@ -0,0 +1,19 @@ +package com.ott.api_user.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@Schema(description = "내 정보 수정 요청 DTO") +public class UpdateMemberRequest { + + @Schema(type = "String", example = "김마루1", description = "변경할 닉네임 / null인 경우 변경 x") + private String nickname; + + @Schema(type = "List", example = "[1, 3, 14]", description = "변경할 선호 태그 ID 목록 / null이면 변경 x") + private List tagIds; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 3acf82e..430f823 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -1,5 +1,6 @@ package com.ott.api_user.member.service; +import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -8,6 +9,8 @@ import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.preferred_tag.domain.PreferredTag; import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +24,7 @@ public class MemberService { private final MemberRepository memberRepository; private final PreferredTagRepository preferredTagRepository; + private final TagRepository tagRepository; public MyPageResponse getMyPage(Long memberId) { @@ -34,4 +38,43 @@ public MyPageResponse getMyPage(Long memberId) { return MyPageResponse.from(findMember, preferredTags); } + // 쓰기작업 readOnly = false + @Transactional + public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 닉네임 변경 (null이면??) -> 논의 필요 + if (request.getNickname() != null) { + findMember.updateNickname(request.getNickname()); + } + + + // 선호 태그 변경 -> 태그가 널인 경우? -> 일단 만들고 추가 예정 + // 1. 모든 태그 삭제 + if (request.getTagIds() != null) { + preferredTagRepository.deleteAllByMember(findMember); + + // 새 태그 저장 + List tags = tagRepository.findAllByIdInAndStatus(request.getTagIds(), Status.ACTIVE); + if (tags.size() != request.getTagIds().size()) { + throw new BusinessException(ErrorCode.TAG_NOT_FOUND); + } + + List newTags = tags.stream() + .map(tag -> PreferredTag.builder() + .member(findMember) + .tag(tag) + .build()) + .toList(); + preferredTagRepository.saveAll(newTags); + } + + // 변경 하고 최신 상태 유지 + List preferredTags = preferredTagRepository + .findAllWithTagAndCategoryByMemberIdAndStatus(memberId, Status.ACTIVE); + + return MyPageResponse.from(findMember, preferredTags); + } } + diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 55a23d7..5256647 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -46,7 +46,9 @@ public enum ErrorCode { SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), - CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다.") + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다.") + ; diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index c07e1f6..08b759c 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -79,4 +79,9 @@ public void changeRole(Role targetRole) { this.role = targetRole; } + + // 닉네임 변경 + public void updateNickname(String nickname) { + this.nickname = nickname; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java index fdb4164..4787ae4 100644 --- a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -1,8 +1,10 @@ package com.ott.domain.preferred_tag.repository; import com.ott.domain.common.Status; +import com.ott.domain.member.domain.Member; import com.ott.domain.preferred_tag.domain.PreferredTag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -24,4 +26,9 @@ public interface PreferredTagRepository extends JpaRepository findAllWithTagAndCategoryByMemberIdAndStatus(@Param("memberId") Long memberId, @Param("status") Status status); + + // 선호 태그 삭제 + @Modifying + @Query("DELETE FROM PreferredTag pt WHERE pt.member = :member") + void deleteAllByMember(@Param("member") Member member); } diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index 6e0fdf7..e6b12ca 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -1,5 +1,6 @@ package com.ott.domain.tag.repository; +import java.util.Collection; import java.util.List; import com.ott.domain.category.domain.Category; @@ -23,5 +24,10 @@ public interface TagRepository extends JpaRepository { """) List findTagNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); + + // 모든 카테고리에 있는 태그 조회 List findAllByCategoryAndStatus(Category category, Status status); + + // ACTIVE&&리스트안에 있는 태그 조회 + List findAllByIdInAndStatus(List ids, Status status); } \ No newline at end of file From c4f7d642cb40a9d25fa2b1548fe5f56e7d26047b Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 11:12:41 +0900 Subject: [PATCH 096/257] =?UTF-8?q?[FEAT]:=20=EC=98=A8=EB=B3=B4=EB=94=A9?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=EC=9D=98=20=EC=84=A0?= =?UTF-8?q?=ED=98=B8=20=ED=83=9C=EA=B7=B8=20=EC=84=A4=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 12 +++++++ .../dto/request/SetPreferredTagRequest.java | 21 +++++++++++ .../member/service/MemberService.java | 35 +++++++++++++++++-- 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 3457356..3481790 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -1,10 +1,13 @@ package com.ott.api_user.member.controller; +import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; import com.ott.api_user.member.service.MemberService; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -30,4 +33,13 @@ public ResponseEntity> updateMyInfo( return ResponseEntity.ok(SuccessResponse.of(memberService.updateMyInfo(memberId, request))); } + + @PostMapping("/me/tags") + public ResponseEntity> setPreferredTags( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody SetPreferredTagRequest request + ) { + memberService.setPreferredTags(memberId, request); + return ResponseEntity.ok(SuccessResponse.of(null)); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java new file mode 100644 index 0000000..16b2897 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/SetPreferredTagRequest.java @@ -0,0 +1,21 @@ +package com.ott.api_user.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@Schema(description = "선호 태그 설정 요청 DTO (온보딩)") +public class SetPreferredTagRequest { + + @NotEmpty(message = "태그를 선택해주세요") + @NotNull + @Schema(type ="List", example = "[1, 3, 13]", description = "선택한 태그 ID 목록") + private List tagsId; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 430f823..d8926b0 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -1,5 +1,6 @@ package com.ott.api_user.member.service; +import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; import com.ott.common.web.exception.BusinessException; @@ -26,7 +27,9 @@ public class MemberService { private final PreferredTagRepository preferredTagRepository; private final TagRepository tagRepository; - + /** + * 마이 페이지 조회 : 닉네임, 선호태그 List 반환 + */ public MyPageResponse getMyPage(Long memberId) { Member findMember = memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); @@ -38,7 +41,10 @@ public MyPageResponse getMyPage(Long memberId) { return MyPageResponse.from(findMember, preferredTags); } - // 쓰기작업 readOnly = false + + /** + * 마이페이지 내 정보 수정 : 닉네임, 선호태그 변경 후 반환 + */ @Transactional public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { Member findMember = memberRepository.findById(memberId) @@ -76,5 +82,28 @@ public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { return MyPageResponse.from(findMember, preferredTags); } -} + /** + * 온보딩 화면 : 초기 1회만 노출되며 + */ + @Transactional + public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + List tags = tagRepository.findAllByIdInAndStatus(request.getTagsId(), Status.ACTIVE); + if (tags.size() != request.getTagsId().size()) { + throw new BusinessException(ErrorCode.TAG_NOT_FOUND); + } + + List preferredTags = tags.stream() + .map(tag -> PreferredTag.builder() + .member(findMember) + .tag(tag) + .build()) + .toList(); + + preferredTagRepository.saveAll(preferredTags); + } + + } From 01e54399becbfa644b8aa1fdc74a3412bd6b9fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 11:31:30 +0900 Subject: [PATCH 097/257] =?UTF-8?q?[FEAT]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=ED=8F=B4=EB=8D=94=20=EB=B0=8F=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/transcoder/build.gradle | 4 ++-- .../main/java/com/ott/transcoder/{config => ffmpeg}/.gitkeep | 0 .../com/ott/transcoder/{service => queue/rabbit}/.gitkeep | 0 .../src/main/java/com/ott/transcoder/queue/sqs/.gitkeep | 0 .../src/main/java/com/ott/transcoder/validate/.gitkeep | 0 5 files changed, 2 insertions(+), 2 deletions(-) rename apps/transcoder/src/main/java/com/ott/transcoder/{config => ffmpeg}/.gitkeep (100%) rename apps/transcoder/src/main/java/com/ott/transcoder/{service => queue/rabbit}/.gitkeep (100%) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/queue/sqs/.gitkeep create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep diff --git a/apps/transcoder/build.gradle b/apps/transcoder/build.gradle index 7ca7b84..2b818b7 100644 --- a/apps/transcoder/build.gradle +++ b/apps/transcoder/build.gradle @@ -4,6 +4,6 @@ dependencies { implementation project(':modules:domain') implementation project(':modules:infra') implementation project(':modules:common-web') - implementation project(':modules:common-security') - implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation 'org.springframework.boot:spring-boot-starter-amqp' } \ No newline at end of file diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/config/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep similarity index 100% rename from apps/transcoder/src/main/java/com/ott/transcoder/config/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/service/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/.gitkeep similarity index 100% rename from apps/transcoder/src/main/java/com/ott/transcoder/service/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/sqs/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/queue/sqs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep new file mode 100644 index 0000000..e69de29 From 7d9e19f5e6b9ee1222f576b4a9f3a54a17f05df3 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 11:34:23 +0900 Subject: [PATCH 098/257] =?UTF-8?q?[FEAT]:=20=EC=9D=91=EB=8B=B5=EA=B0=92?= =?UTF-8?q?=EC=97=90=20=ED=83=9C=EA=B7=B8=20Id=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/dto/response/MyPageResponse.java | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java index 14b2e0d..1057429 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/MyPageResponse.java @@ -21,20 +21,36 @@ public class MyPageResponse { @Schema(type = "String", example = "김마루", description = "닉네임") private String nickname; - @Schema(type = "List", example = "[영화 | 판타지, 드라마 | 로맨스]", description = "선호 태그 목록") - private List preferredTags; + @Schema(description = "선호 태그 목록") + private List preferredTags; public static MyPageResponse from(Member member, List preferredTags) { - List displays = preferredTags.stream() - .map(pt -> pt.getTag().getCategory().getName() + " | " + pt.getTag().getName()) + List tagInfos = preferredTags.stream() + .map(pt -> PreferredTagInfo.builder() + .tagId(pt.getTag().getId()) + .display(pt.getTag().getCategory().getName() + " | " + pt.getTag().getName()) + .build()) .toList(); return MyPageResponse.builder() .memberId(member.getId()) .nickname(member.getNickname()) - .preferredTags(displays) // 선호태그 없으면 빈 리스트 출력 + .preferredTags(tagInfos) // 선호태그 없으면 빈 리스트 출력 .build(); } + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "선호 태그 아이템") + public static class PreferredTagInfo { + + @Schema(type = "Long", example = "13", description = "태그 고유 ID") + private Long tagId; + + @Schema(type = "String", example = "드라마 | 스릴러", description = "프론트 화면용 String") + private String display; + } + } From 634a01be744a11c54584c0344945ad34cfaa8a64 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 11:34:51 +0900 Subject: [PATCH 099/257] =?UTF-8?q?[FEAT]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../category/controller/CategoryApi.java | 55 ++++++++++++ .../api_user/member/controller/MemberApi.java | 83 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java new file mode 100644 index 0000000..c012e5c --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java @@ -0,0 +1,55 @@ +package com.ott.api_user.category.controller; + +import com.ott.api_user.category.dto.response.CategoryResponse; +import com.ott.api_user.tag.dto.response.TagResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "Category", description = "카테고리 API") +@SecurityRequirement(name = "BearerAuth") +public interface CategoryApi { + + @Operation(summary = "카테고리 목록 조회", description = "전체 카테고리 목록을 조회합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = CategoryResponse.class) + ) + ) + }) + ResponseEntity>> getCategories(); + + + @Operation(summary = "카테고리별 태그 목록 조회", description = "특정 카테고리에 속한 태그 목록을 조회합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TagResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "카테고리를 찾을 수 없음", + content = @Content(mediaType = "application/json") + ) + }) + ResponseEntity>> getTagsByCategory( + @Parameter(description = "카테고리 ID", example = "1") Long categoryId + ); +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java index 0038076..6fa7404 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -1,18 +1,25 @@ package com.ott.api_user.member.controller; +import com.ott.api_user.member.dto.request.SetPreferredTagRequest; +import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +@RequestMapping("/member") @Tag(name = "Member", description = "마이페이지 API") @SecurityRequirement(name = "BearerAuth") // 인증인가 확인 public interface MemberApi { @@ -47,4 +54,80 @@ public interface MemberApi { ) }) ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId); + + @Operation( + summary = "내 정보 수정", + description = "닉네임, 선호 태그를 수정합니다. 각 필드는 선택적으로 변경 가능하며 null이면 변경되지 않습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "수정 성공 - 변경된 마이페이지 정보 반환", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = MyPageResponse.class)) + ), + @ApiResponse(responseCode = "401", + description = "인증 실패", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse(responseCode = "404", + description = "회원 또는 태그를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity> updateMyInfo( + @AuthenticationPrincipal Long memberId, + @RequestBody UpdateMemberRequest request + ); + + /**3 + * + * POST /member/me/tags : 온보딩 화면 선호 태그 수집 API + */ + @Operation( + summary = "온보딩에서 선호 태그 저장", + description = "온보딩 화면에서 처음 선호 태그를 수집합니다. 유저마다 1회만 실행" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "선호 태그 저장 성공" + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (빈 태그 목록, 중복 태그 ID 등)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "회원 또는 태그를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @PostMapping("/me/tags") + ResponseEntity> setPreferredTags( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody SetPreferredTagRequest request + ); + } \ No newline at end of file From 45f56adbe41ed949cf7124000f0fe348a270c4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 14:07:51 +0900 Subject: [PATCH 100/257] =?UTF-8?q?[CHORE]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=B0=8F=20=ED=81=90=20yml=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/transcoder/queue/rabbit/.gitkeep | 0 .../src/main/resources/application.yml | 24 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 0b2640b..999641b 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -4,10 +4,14 @@ server: spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ott} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3307/ott} username: ${SPRING_DATASOURCE_USERNAME:ott} password: ${SPRING_DATASOURCE_PASSWORD:ottpw} + # flyway 설정 + flyway: + enabled: false + # JPA 설정 jpa: database-platform: org.hibernate.dialect.MySQL8Dialect @@ -21,3 +25,21 @@ spring: hibernate: show_sql: true format_sql: true + + rabbitmq: + host: ${RABBITMQ_HOST} + port: ${RABBITMQ_PORT} + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + +transcoder: + messaging: + provider: ${TRANSCODER_MESSAGING_PROVIDER} + ffmpeg: + path: ${FFMPEG_PATH} + temp-dir: ${TRANSCODER_TEMP_DIR} + segment-duration: ${TRANSCODER_SEGMENT_DURATION} + +storage: + local: + output-dir: ${STORAGE_LOCAL_OUTPUT_DIR} From 23885738bcff5bb5880b351177647627abee0a1c Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 25 Feb 2026 14:11:11 +0900 Subject: [PATCH 101/257] =?UTF-8?q?[OT-104][REFACTOR]:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20SecurityConfig=20requestMatcher=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/api_admin/config/SecurityConfig.java | 2 ++ .../content/controller/BackOfficeContentsController.java | 1 - .../series/controller/BackOfficeSeriesController.java | 1 - .../shortform/controller/BackOfficeShortFormController.java | 1 - .../java/com/ott/api_admin/upload/support/ExtensionEnum.java | 4 ++++ 5 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index c268785..46020bc 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -45,6 +45,8 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/swagger-resources/**" ).permitAll() + .requestMatchers("/back-office/short-forms/upload").hasAnyRole("ADMIN", "EDITOR") + .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index 930982f..ee7116e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -46,7 +46,6 @@ public ResponseEntity> getContentsDetail @Override @PostMapping("/upload") - @PreAuthorize("hasRole('ADMIN')") // ADMIN 권한으로 콘텐츠 업로드 초기화를 수행합니다. public ResponseEntity> createContentsUpload( @Valid @RequestBody ContentsUploadRequest request diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 7d48045..010e016 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -61,7 +61,6 @@ public ResponseEntity> getSeriesDetail(@Pa @Override @PostMapping("/upload") - @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> createSeriesUpload( @Valid @RequestBody SeriesUploadRequest request ) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index 4a8284f..4d6bc32 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -68,7 +68,6 @@ public ResponseEntity> getShortFormDeta @Override @PostMapping("/upload") - @PreAuthorize("hasAnyRole('ADMIN','EDITOR')") public ResponseEntity> createShortFormUpload( @Valid @RequestBody ShortFormUploadRequest request ) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java new file mode 100644 index 0000000..f9709fb --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java @@ -0,0 +1,4 @@ +package com.ott.api_admin.upload.support; + +public enum ExtensionEnum { +} From 7f8290a844ef46a8a9c991a1062569e92ff736a1 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 25 Feb 2026 14:11:44 +0900 Subject: [PATCH 102/257] =?UTF-8?q?[OT-104][REFACTOR]:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=ED=99=95=EC=9E=A5=EC=9E=90-=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=ED=83=80=EC=9E=85=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=EC=9D=84=20ExtensionEnum=EC=9C=BC=EB=A1=9C=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BackOfficeContentsService.java | 2 +- .../service/BackOfficeSeriesService.java | 2 +- .../service/BackOfficeShortFormService.java | 2 +- .../upload/support/ExtensionEnum.java | 54 +++++++++++++++++++ .../upload/support/UploadHelper.java | 53 +++++++----------- 5 files changed, 76 insertions(+), 37 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index e5f15e8..5a9d077 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.content.service; +package com.ott.api_admin.content.service; import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 7f1e620..cad6114 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.series.service; +package com.ott.api_admin.series.service; import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 4238adf..aa52827 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.service; +package com.ott.api_admin.shortform.service; import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java index f9709fb..d252b34 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java @@ -1,4 +1,58 @@ package com.ott.api_admin.upload.support; +import java.util.Arrays; +import java.util.Locale; + public enum ExtensionEnum { + JPG("jpg", "image/jpeg", Category.IMAGE), + JPEG("jpeg", "image/jpeg", Category.IMAGE), + PNG("png", "image/png", Category.IMAGE), + WEBP("webp", "image/webp", Category.IMAGE), + MP4("mp4", "video/mp4", Category.VIDEO), + MOV("mov", "video/quicktime", Category.VIDEO), + WEBM("webm", "video/webm", Category.VIDEO), + M4V("m4v", "video/x-m4v", Category.VIDEO); + + private final String extension; + private final String contentType; + private final Category category; + + ExtensionEnum(String extension, String contentType, Category category) { + this.extension = extension; + this.contentType = contentType; + this.category = category; + } + + public static String resolveImageContentType(String fileName) { + return resolveContentType(fileName, Category.IMAGE); + } + + public static String resolveVideoContentType(String fileName) { + return resolveContentType(fileName, Category.VIDEO); + } + + private static String resolveContentType(String fileName, Category expectedCategory) { + String extractedExtension = extractExtension(fileName); + + return Arrays.stream(values()) + .filter(candidate -> candidate.category == expectedCategory) + .filter(candidate -> candidate.extension.equals(extractedExtension)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported file extension: " + extractedExtension)) + .contentType; + } + + private static String extractExtension(String fileName) { + String trimmed = fileName.trim(); + int extensionDelimiterIndex = trimmed.lastIndexOf('.'); + if (extensionDelimiterIndex < 0 || extensionDelimiterIndex == trimmed.length() - 1) { + throw new IllegalArgumentException("File extension is missing"); + } + return trimmed.substring(extensionDelimiterIndex + 1).toLowerCase(Locale.ROOT); + } + + private enum Category { + IMAGE, + VIDEO + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java index 6e144ef..bb29bed 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -15,60 +15,45 @@ public class UploadHelper { private final MemberRepository memberRepository; - public String buildObjectKey(String root, Long id, String mediaType, String fileName) { - return root + "/" + id + "/" + mediaType + "/" + fileName; + public String buildObjectKey(String resourceRoot, Long resourceId, String assetType, String fileName) { + return resourceRoot + "/" + resourceId + "/" + assetType + "/" + fileName; } public String resolveImageContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (lowerFileName.endsWith(".png")) { - return "image/png"; - } - if (lowerFileName.endsWith(".webp")) { - return "image/webp"; + try { + return ExtensionEnum.resolveImageContentType(fileName); + } catch (IllegalArgumentException ex) { + throw new BusinessException(ErrorCode.INVALID_INPUT); } - throw new BusinessException(ErrorCode.INVALID_INPUT); } public String resolveVideoContentType(String fileName) { - String lowerFileName = fileName.toLowerCase(); - if (lowerFileName.endsWith(".mp4")) { - return "video/mp4"; - } - if (lowerFileName.endsWith(".mov")) { - return "video/quicktime"; - } - if (lowerFileName.endsWith(".webm")) { - return "video/webm"; - } - if (lowerFileName.endsWith(".m4v")) { - return "video/x-m4v"; + try { + return ExtensionEnum.resolveVideoContentType(fileName); + } catch (IllegalArgumentException ex) { + throw new BusinessException(ErrorCode.INVALID_INPUT); } - throw new BusinessException(ErrorCode.INVALID_INPUT); } public String sanitizeFileName(String fileName) { String trimmed = fileName == null ? "" : fileName.trim(); - int lastDot = trimmed.lastIndexOf('.'); - String namePart = lastDot > 0 ? trimmed.substring(0, lastDot) : trimmed; - String extPart = lastDot > 0 ? trimmed.substring(lastDot + 1) : ""; + int extensionDelimiterIndex = trimmed.lastIndexOf('.'); + String baseName = extensionDelimiterIndex > 0 ? trimmed.substring(0, extensionDelimiterIndex) : trimmed; + String extensionPart = extensionDelimiterIndex > 0 ? trimmed.substring(extensionDelimiterIndex + 1) : ""; - String sanitizedName = namePart + String sanitizedBaseName = baseName .replace("/", "") .replace("\\", "") .replaceAll("[^0-9A-Za-z가-힣_-]", ""); - String sanitizedExt = extPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + String sanitizedExtension = extensionPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); - if (sanitizedName.isBlank()) { - sanitizedName = "file"; + if (sanitizedBaseName.isBlank()) { + sanitizedBaseName = "file"; } - if (sanitizedExt.isBlank()) { + if (sanitizedExtension.isBlank()) { throw new BusinessException(ErrorCode.INVALID_INPUT); } - return sanitizedName + "." + sanitizedExt; + return sanitizedBaseName + "." + sanitizedExtension; } public Member resolveUploader() { From 083887763d1f66ef449e641a48a4afe3109263aa Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 25 Feb 2026 14:12:12 +0900 Subject: [PATCH 103/257] =?UTF-8?q?[OT-104][DOCS]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20Request/Response=20DTO=20Schema(type,=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85,=20=EC=98=88=EC=8B=9C)=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 1 + .../dto/request/ContentsUploadRequest.java | 52 ++++++++++++------- .../dto/response/ContentsUploadResponse.java | 27 ++++++---- .../controller/BackOfficeSeriesApi.java | 1 + .../dto/request/SeriesUploadRequest.java | 39 +++++++++----- .../dto/response/SeriesUploadResponse.java | 18 ++++--- .../controller/BackOfficeShortFormApi.java | 1 + .../dto/request/ShortFormUploadRequest.java | 49 +++++++++++------ .../dto/response/ShortFormUploadResponse.java | 27 ++++++---- 9 files changed, 139 insertions(+), 76 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index fa4883f..17627d0 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -84,6 +84,7 @@ ResponseEntity> getContentsDetail( ) }) ResponseEntity> createContentsUpload( + @Parameter(description = "api_admin.content.dto.request.ContentsUploadRequest를 참고해주세요.", required = true) @RequestBody ContentsUploadRequest request ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index 07124b8..126fcff 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -6,29 +6,45 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; -@Schema(description = "콘텐츠 업로드 요청") +@Schema(type = "Object", description = "콘텐츠 업로드 요청") public record ContentsUploadRequest( - // 연결할 시리즈 ID (없으면 단일 콘텐츠) + @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") Long seriesId, - // 콘텐츠 제목 - @NotBlank String title, - // 콘텐츠 설명 - @NotBlank String description, - // 출연진 문자열 - @NotBlank String actors, - // 공개 상태 - @NotNull PublicStatus publicStatus, - // 영상 길이(초) + + @Schema(type = "String", description = "콘텐츠 제목", example = "응답하라 1988 1화") + @NotBlank + String title, + + @Schema(type = "String", description = "콘텐츠 설명", example = "가족과 이웃의 따뜻한 이야기") + @NotBlank + String description, + + @Schema(type = "String", description = "출연진", example = "성동일, 이일화") + @NotBlank + String actors, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") @PositiveOrZero Integer duration, - // 영상 크기(KB) + + @Schema(type = "Integer", description = "영상 크기(KB)", example = "512000") @PositiveOrZero Integer videoSize, - // 포스터 원본 파일명 - @NotBlank String posterFileName, - // 썸네일 원본 파일명 - @NotBlank String thumbnailFileName, - // 원본 영상 파일명 - @NotBlank String originFileName + + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명", example = "thumb.jpg") + @NotBlank + String thumbnailFileName, + + @Schema(type = "String", description = "원본 영상 파일명", example = "origin.mp4") + @NotBlank + String originFileName ) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java index e9477a4..a5f0f25 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java @@ -2,23 +2,30 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "콘텐츠 업로드 응답") +@Schema(type = "Object", description = "콘텐츠 업로드 응답") public record ContentsUploadResponse( - // 생성된 콘텐츠 ID + @Schema(type = "Long", description = "생성된 콘텐츠 ID", example = "10") Long contentsId, - // 포스터 S3 object key + + @Schema(type = "String", description = "포스터 S3 object key", example = "contents/10/poster/poster.jpg") String posterObjectKey, - // 썸네일 S3 object key + + @Schema(type = "String", description = "썸네일 S3 object key", example = "contents/10/thumbnail/thumb.jpg") String thumbnailObjectKey, - // 원본 영상 S3 object key + + @Schema(type = "String", description = "원본 영상 S3 object key", example = "contents/10/origin/origin.mp4") String originObjectKey, - // 트랜스코딩 결과 마스터 플레이리스트 object key + + @Schema(type = "String", description = "트랜스코딩 마스터 플레이리스트 object key", example = "contents/10/transcoded/master.m3u8") String masterPlaylistObjectKey, - // 포스터 업로드용 Presigned URL + + @Schema(type = "String", description = "포스터 업로드용 사전 서명 URL") String posterUploadUrl, - // 썸네일 업로드용 Presigned URL + + @Schema(type = "String", description = "썸네일 업로드용 사전 서명 URL") String thumbnailUploadUrl, - // 원본 영상 업로드용 Presigned URL + + @Schema(type = "String", description = "원본 영상 업로드용 사전 서명 URL") String originUploadUrl ) { -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index b8a2d35..bfb18c2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -109,6 +109,7 @@ ResponseEntity> getSeriesDetail( ) }) ResponseEntity> createSeriesUpload( + @Parameter(description = "api_admin.series.dto.request.SeriesUploadRequest를 참고해주세요.", required = true) @RequestBody SeriesUploadRequest request ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java index 3e3298a..065aedd 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -5,19 +5,30 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -@Schema(description = "시리즈 업로드 요청") +@Schema(type = "Object", description = "시리즈 업로드 요청") public record SeriesUploadRequest( - // 시리즈 제목 - @NotBlank String title, - // 시리즈 설명 - @NotBlank String description, - // 출연진 문자열 - @NotBlank String actors, - // 공개 상태 - @NotNull PublicStatus publicStatus, - // 포스터 원본 파일명 - @NotBlank String posterFileName, - // 썸네일 원본 파일명 - @NotBlank String thumbnailFileName + @Schema(type = "String", description = "시리즈 제목", example = "응답하라 1988") + @NotBlank + String title, + + @Schema(type = "String", description = "시리즈 설명", example = "가족과 이웃의 따뜻한 이야기") + @NotBlank + String description, + + @Schema(type = "String", description = "출연진", example = "성동일, 이일화") + @NotBlank + String actors, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명", example = "thumb.jpg") + @NotBlank + String thumbnailFileName ) { -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java index b64c4a3..d0b8bca 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java @@ -2,17 +2,21 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "시리즈 업로드 응답") +@Schema(type = "Object", description = "시리즈 업로드 응답") public record SeriesUploadResponse( - // 생성된 시리즈 ID + @Schema(type = "Long", description = "생성된 시리즈 ID", example = "10") Long seriesId, - // 포스터 S3 object key + + @Schema(type = "String", description = "포스터 S3 object key", example = "series/10/poster/poster.jpg") String posterObjectKey, - // 썸네일 S3 object key + + @Schema(type = "String", description = "썸네일 S3 object key", example = "series/10/thumbnail/thumb.jpg") String thumbnailObjectKey, - // 포스터 업로드용 Presigned URL + + @Schema(type = "String", description = "포스터 업로드용 사전 서명 URL") String posterUploadUrl, - // 썸네일 업로드용 Presigned URL + + @Schema(type = "String", description = "썸네일 업로드용 사전 서명 URL") String thumbnailUploadUrl ) { -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index a41ff42..02ffec4 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -102,6 +102,7 @@ ResponseEntity> getShortFormDetail( ) }) ResponseEntity> createShortFormUpload( + @Parameter(description = "api_admin.shortform.dto.request.ShortFormUploadRequest 참고해주세요.", required = true) @RequestBody ShortFormUploadRequest request ); } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index 3b9e0e2..3486ffb 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -6,29 +6,44 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; -@Schema(description = "숏폼 업로드 요청") +@Schema(type = "Object", description = "숏폼 업로드 요청") public record ShortFormUploadRequest( - // 연결할 시리즈 ID (선택) + @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") Long seriesId, - // 연결할 콘텐츠 ID (선택) + + @Schema(type = "Long", description = "연결할 콘텐츠 ID(선택)", example = "2") Long contentsId, - // 숏폼 제목 - @NotBlank String title, - // 숏폼 설명 - @NotBlank String description, - // 공개 상태 - @NotNull PublicStatus publicStatus, - // 영상 길이(초) + + @Schema(type = "String", description = "숏폼 제목", example = "하이라이트") + @NotBlank + String title, + + @Schema(type = "String", description = "숏폼 설명", example = "명장면 하이라이트") + @NotBlank + String description, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "영상 길이(초)", example = "60") @PositiveOrZero Integer duration, - // 영상 크기(KB) + + @Schema(type = "Integer", description = "영상 크기(KB)", example = "10240") @PositiveOrZero Integer videoSize, - // 포스터 원본 파일명 - @NotBlank String posterFileName, - // 썸네일 원본 파일명 - @NotBlank String thumbnailFileName, - // 원본 영상 파일명 - @NotBlank String originFileName + + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명", example = "thumb.jpg") + @NotBlank + String thumbnailFileName, + + @Schema(type = "String", description = "원본 영상 파일명", example = "origin.mp4") + @NotBlank + String originFileName ) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java index 6ae7288..31f7c5f 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java @@ -2,23 +2,30 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "숏폼 업로드 응답") +@Schema(type = "Object", description = "숏폼 업로드 응답") public record ShortFormUploadResponse( - // 생성된 숏폼 ID + @Schema(type = "Long", description = "생성된 숏폼 ID", example = "10") Long shortFormId, - // 포스터 S3 object key + + @Schema(type = "String", description = "포스터 S3 object key", example = "short-forms/10/poster/poster.jpg") String posterObjectKey, - // 썸네일 S3 object key + + @Schema(type = "String", description = "썸네일 S3 object key", example = "short-forms/10/thumbnail/thumb.jpg") String thumbnailObjectKey, - // 원본 영상 S3 object key + + @Schema(type = "String", description = "원본 영상 S3 object key", example = "short-forms/10/origin/origin.mp4") String originObjectKey, - // 트랜스코딩 결과 마스터 플레이리스트 object key + + @Schema(type = "String", description = "트랜스코딩 마스터 플레이리스트 object key", example = "short-forms/10/transcoded/master.m3u8") String masterPlaylistObjectKey, - // 포스터 업로드용 Presigned URL + + @Schema(type = "String", description = "포스터 업로드용 사전 서명 URL") String posterUploadUrl, - // 썸네일 업로드용 Presigned URL + + @Schema(type = "String", description = "썸네일 업로드용 사전 서명 URL") String thumbnailUploadUrl, - // 원본 영상 업로드용 Presigned URL + + @Schema(type = "String", description = "원본 영상 업로드용 사전 서명 URL") String originUploadUrl ) { -} \ No newline at end of file +} From 2f380b0b6af7e216ce789f1248fe2dc6f7120d81 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 25 Feb 2026 14:13:21 +0900 Subject: [PATCH 104/257] =?UTF-8?q?[OT-104][REFACTOR]:=20Contents=20origin?= =?UTF-8?q?Url=20null=20=EB=B0=A9=EC=96=B4=EB=A5=BC=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EC=B1=85=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/domain/contents/domain/Contents.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index f0540ec..a1b8900 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -57,7 +57,7 @@ public class Contents extends BaseEntity { private String masterPlaylistUrl; public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { - this.originUrl = Objects.requireNonNull(originUrl, "originUrl must not be null"); + this.originUrl = originUrl; this.masterPlaylistUrl = masterPlaylistUrl; } } From 22d869e73e623a38168c2b75604f615e07c62ef7 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 25 Feb 2026 14:15:44 +0900 Subject: [PATCH 105/257] =?UTF-8?q?[OT-104][REFACTOR]:=20Media/ShortForm?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=ED=82=A4=20null=20=EB=B0=A9=EC=96=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/src/main/java/com/ott/domain/media/domain/Media.java | 2 +- .../main/java/com/ott/domain/short_form/domain/ShortForm.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index fd49fb7..6204067 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -66,7 +66,7 @@ public class Media extends BaseEntity { private PublicStatus publicStatus; public void updateImageKeys(String posterUrl, String thumbnailUrl) { - this.posterUrl = Objects.requireNonNull(posterUrl, "posterUrl must not be null"); + this.posterUrl = posterUrl; this.thumbnailUrl = thumbnailUrl; } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 210f3a5..e8cddfe 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -60,7 +60,7 @@ public class ShortForm extends BaseEntity { private String masterPlaylistUrl; public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { - this.originUrl = Objects.requireNonNull(originUrl, "originUrl must not be null"); + this.originUrl = originUrl; this.masterPlaylistUrl = masterPlaylistUrl; } From cd9686b2efa1a3d2069c1d2f4065ce7a59b5f65d Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 25 Feb 2026 14:55:35 +0900 Subject: [PATCH 106/257] =?UTF-8?q?[OT-104][REFACTOR]:=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=95=EC=B1=85=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=84=A4=EB=AA=85=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_admin/config/SecurityConfig.java | 1 - .../ott/api_admin/content/controller/BackOfficeContentsApi.java | 2 +- .../ott/api_admin/series/controller/BackOfficeSeriesApi.java | 2 +- .../api_admin/shortform/controller/BackOfficeShortFormApi.java | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 46020bc..886dff2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -45,7 +45,6 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/swagger-resources/**" ).permitAll() - .requestMatchers("/back-office/short-forms/upload").hasAnyRole("ADMIN", "EDITOR") .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") ) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index 17627d0..2962629 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -84,7 +84,7 @@ ResponseEntity> getContentsDetail( ) }) ResponseEntity> createContentsUpload( - @Parameter(description = "api_admin.content.dto.request.ContentsUploadRequest를 참고해주세요.", required = true) + @Parameter(description = "ContentsUploadRequest를 참고해주세요.", required = true) @RequestBody ContentsUploadRequest request ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index bfb18c2..bcfcf27 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -109,7 +109,7 @@ ResponseEntity> getSeriesDetail( ) }) ResponseEntity> createSeriesUpload( - @Parameter(description = "api_admin.series.dto.request.SeriesUploadRequest를 참고해주세요.", required = true) + @Parameter(description = "SeriesUploadRequest를 참고해주세요.", required = true) @RequestBody SeriesUploadRequest request ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index 02ffec4..a7a5ea6 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -102,7 +102,7 @@ ResponseEntity> getShortFormDetail( ) }) ResponseEntity> createShortFormUpload( - @Parameter(description = "api_admin.shortform.dto.request.ShortFormUploadRequest 참고해주세요.", required = true) + @Parameter(description = "ShortFormUploadRequest 참고해주세요.", required = true) @RequestBody ShortFormUploadRequest request ); } \ No newline at end of file From 7e46ce10f32a96363e619185d87c23db17f64754 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 15:07:43 +0900 Subject: [PATCH 107/257] =?UTF-8?q?[FIX]:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/api_user/member/controller/MemberApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java index 6fa7404..07d5863 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -86,7 +86,7 @@ ResponseEntity> updateMyInfo( @RequestBody UpdateMemberRequest request ); - /**3 + /** * * POST /member/me/tags : 온보딩 화면 선호 태그 수집 API */ From da7903c083cc17cd81fb52534b9fd4f785f950e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 15:31:17 +0900 Subject: [PATCH 108/257] =?UTF-8?q?[FEAT]:=20RabbitMQ=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/config/RabbitConfig.java | 70 +++++++++++++++++++ .../ott/transcoder/queue/MessageListener.java | 12 ++++ .../queue/rabbit/RabbitTranscodeListener.java | 35 ++++++++++ 3 files changed, 117 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java new file mode 100644 index 0000000..36e19dd --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java @@ -0,0 +1,70 @@ +package com.ott.transcoder.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.transcoder.queue.TranscodeMessage; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.support.converter.DefaultClassMapper; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * RabbitMQ 설정. + * + * Exchange → Binding → Queue 구조로 메시지 라우팅 + * Producer가 transcode.exchange에 routing key "transcode.request"로 메시지를 발행하면, + * Binding을 통해 transcode.queue로 전달되고, RabbitTranscodeListener가 소비 + * + * transcoder.messaging.provider=rabbit 일 때만 활성화 (SQS 등 전환 시 비활성화) + */ +@Configuration +@ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") +public class RabbitConfig { + + public static final String EXCHANGE_NAME = "transcode.exchange"; + public static final String QUEUE_NAME = "transcode.queue"; + public static final String ROUTING_KEY = "transcode.request"; + + @Bean + public DirectExchange transcodeExchange() { + return new DirectExchange(EXCHANGE_NAME); + } + + /** durable=true: RabbitMQ 재시작 시에도 큐 유지 */ + @Bean + public Queue transcodeQueue() { + return new Queue(QUEUE_NAME, true); + } + + /** Exchange와 Queue를 routing key로 연결 */ + @Bean + public Binding transcodeBinding(Queue transcodeQueue, DirectExchange transcodeExchange) { + return BindingBuilder.bind(transcodeQueue) + .to(transcodeExchange) + .with(ROUTING_KEY); + } + + /** + * JSON 메시지를 TranscodeMessage로 역직렬화할 때 사용할 기본 타입 지정 + * 메시지 헤더에 __TypeId__가 없어도 TranscodeMessage로 변환 + * (Management UI 등 외부에서 직접 발행한 메시지 처리를 위해 필요) + */ + @Bean + public DefaultClassMapper classMapper() { + DefaultClassMapper classMapper = new DefaultClassMapper(); + classMapper.setDefaultType(TranscodeMessage.class); + return classMapper; + } + + @Bean + public MessageConverter jacksonMessageConverter(ObjectMapper objectMapper, DefaultClassMapper classMapper) { + Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper); + converter.setClassMapper(classMapper); + return converter; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java new file mode 100644 index 0000000..a6999bf --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java @@ -0,0 +1,12 @@ +package com.ott.transcoder.queue; + +/** + * 메시지 큐 소비자 추상화 인터페이스 + * + * 현재 구현체: RabbitTranscodeListener (RabbitMQ) + * 큐 교체 시(SQS 등) 새 구현체만 추가하면 된다. + */ +public interface MessageListener { + + void listen(TranscodeMessage message); +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java new file mode 100644 index 0000000..b4fb3dc --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java @@ -0,0 +1,35 @@ +package com.ott.transcoder.queue.rabbit; + +import com.ott.transcoder.config.RabbitConfig; +import com.ott.transcoder.pipeline.CommandPipeline; +import com.ott.transcoder.queue.MessageListener; +import com.ott.transcoder.queue.TranscodeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * RabbitMQ 메시지 리스너 (어댑터 역할) + * + * 큐에서 메시지를 소비하여 CommandPipeline에 위임 + * RabbitMQ 전용 로직만 담당, 트랜스코딩 비즈니스 로직은 모르는 상태 + * + * SQS 전환 시 이 클래스 대신 SqsTranscodeListener를 만들면 된다 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") +public class RabbitTranscodeListener implements MessageListener { + + private final CommandPipeline commandPipeline; + + @Override + @RabbitListener(queues = RabbitConfig.QUEUE_NAME) + public void listen(TranscodeMessage message) { + log.info("트랜스코딩 요청 수신 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); + commandPipeline.execute(message.mediaId(), message.originUrl()); + } +} From f633647d4ef3ecfe4783b61ded544eeba87fd093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 15:31:54 +0900 Subject: [PATCH 109/257] =?UTF-8?q?[FEAT]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transcoder/pipeline/CommandPipeline.java | 21 +++++ .../pipeline/hls/HlsTranscodePipeline.java | 92 +++++++++++++++++++ .../transcoder/queue/TranscodeMessage.java | 14 +++ 3 files changed, 127 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java new file mode 100644 index 0000000..d0c13b2 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java @@ -0,0 +1,21 @@ +package com.ott.transcoder.pipeline; + +/** + * 미디어 처리 파이프라인 추상화 인터페이스 + * + * 하나의 미디어에 대해 pre → main → post 흐름을 실행하는 커맨드 단위 + * 커맨드 종류에 따라 구현체가 달라진다 + * + * 현재 구현체: HlsTranscodePipeline (HLS 트랜스코딩) + * 향후 구현체: ThumbnailPipeline, SpritePipeline 등 + */ +public interface CommandPipeline { + + /** + * 파이프라인을 실행 + * + * @param mediaId 대상 미디어 ID + * @param originUrl 원본 영상 위치 (로컬 경로 또는 S3 key) + */ + void execute(Long mediaId, String originUrl); +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java new file mode 100644 index 0000000..704155a --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java @@ -0,0 +1,92 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.pipeline.CommandPipeline; +import com.ott.transcoder.storage.VideoStorage; +import com.ott.transcoder.transcode.FfmpegExecutor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +/** + * HLS 트랜스코딩 파이프라인 + * + * 전체 흐름을 조율: + * 1. 임시 작업 디렉토리 생성 + * 2. 원본 다운로드 (VideoStorage) + * 3. 해상도별 FFmpeg HLS 트랜스코딩 (360p → 720p → 1080p 순차 실행) + * 4. 마스터 플레이리스트 생성 (master.m3u8) + * 5. 결과물 업로드 (VideoStorage) + * 6. 임시 작업 디렉토리 정리 (성공/실패 모두) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HlsTranscodePipeline implements CommandPipeline { + + private static final List TARGET_RESOLUTION_LIST = List.of( + Resolution.P360, Resolution.P720, Resolution.P1080 + ); + + private final VideoStorage videoStorage; + private final FfmpegExecutor ffmpegExecutor; + private final MasterPlaylistGenerator masterPlaylistGenerator; + + @Value("${transcoder.ffmpeg.temp-dir:#{systemProperties['java.io.tmpdir'] + '/ott-transcode'}}") + private String tempDir; + + @Override + public void execute(Long mediaId, String originUrl) { + Path workDir = Path.of(tempDir, "media-" + mediaId); + + try { + Files.createDirectories(workDir); + log.info("트랜스코딩 시작 - mediaId: {}, originUrl: {}", mediaId, originUrl); + + // 1. 원본 영상을 임시 작업 디렉토리로 가져옴 + Path inputFile = videoStorage.download(originUrl, workDir); + + // 2. 해상도별 HLS 트랜스코딩 (각각 media.m3u8 + segment_XXX.ts 생성) + for (Resolution resolution : TARGET_RESOLUTION_LIST) { + ffmpegExecutor.execute(inputFile, workDir, resolution); + } + + // 3. 마스터 플레이리스트 생성 (3개 variant를 참조하는 master.m3u8) + masterPlaylistGenerator.generate(workDir, TARGET_RESOLUTION_LIST); + + // 4. 결과물을 저장소에 업로드 (output-dir/media/{mediaId}/hls/) + String uploadedPath = videoStorage.upload(workDir, "media/" + mediaId + "/hls"); + + log.info("트랜스코딩 완료 - mediaId: {}, uploadedPath: {}", mediaId, uploadedPath); + + } catch (Exception e) { + log.error("트랜스코딩 실패 - mediaId: {}", mediaId, e); + throw new RuntimeException("트랜스코딩 실패 - mediaId: " + mediaId, e); + } finally { + cleanUp(workDir); + } + } + + /** 임시 작업 디렉토리 삭제. 하위 파일부터 역순으로 삭제. */ + private void cleanUp(Path workDir) { + try { + if (Files.exists(workDir)) { + Files.walk(workDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + }); + log.info("임시 디렉토리 정리 완료 - {}", workDir); + } + } catch (IOException e) { + log.warn("임시 디렉토리 정리 실패 - {}", workDir, e); + } + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java new file mode 100644 index 0000000..f4e3bd0 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/TranscodeMessage.java @@ -0,0 +1,14 @@ +package com.ott.transcoder.queue; + +/** + * 트랜스코딩 요청 메시지 DTO. + * + * @param mediaId 트랜스코딩 대상 미디어 ID (Contents 또는 ShortForm의 media_id) + * @param originUrl 원본 영상 위치 (로컬 경로 또는 S3 key) + */ +public record TranscodeMessage( + + Long mediaId, + String originUrl +) { +} From 7eb6e3088efd1d5a810e2b891d6d89d20c81c0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 15:32:16 +0900 Subject: [PATCH 110/257] =?UTF-8?q?[FEAT]:=20FFmpeg=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EA=B8=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/hls/MasterPlaylistGenerator.java | 63 ++++++++++ .../transcoder/transcode/FfmpegExecutor.java | 29 +++++ .../ProcessBuilderFfmpegExecutor.java | 112 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java new file mode 100644 index 0000000..b533185 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java @@ -0,0 +1,63 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * HLS 마스터 플레이리스트(master.m3u8) 생성기. + * + * 마스터 플레이리스트는 여러 해상도(variant)를 참조하며, + * HLS 플레이어가 네트워크 상태에 따라 적절한 해상도를 자동 선택(ABR)하게 해준다. + * + * 생성 결과 예시: + * #EXTM3U + * #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360 + * 360p/media.m3u8 + * #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720 + * 720p/media.m3u8 + * #EXT-X-STREAM-INF:BANDWIDTH=4800000,RESOLUTION=1920x1080 + * 1080p/media.m3u8 + */ +@Slf4j +@Component +public class MasterPlaylistGenerator { + + /** 해상도별 variant 메타데이터 (대역폭, 화면 크기, 상대 경로) */ + private record Variant(int bandwidth, String resolution, String playlistPath) {} + + private static final Map VARIANT_MAP = Map.of( + Resolution.P360, new Variant(800_000, "640x360", "360p/media.m3u8"), + Resolution.P720, new Variant(2_400_000, "1280x720", "720p/media.m3u8"), + Resolution.P1080, new Variant(4_800_000, "1920x1080", "1080p/media.m3u8") + ); + + /** + * @param outputDir 마스터 플레이리스트를 생성할 디렉토리 + * @param resolutionList 포함할 해상도 목록 + * @return 생성된 master.m3u8 경로 + */ + public Path generate(Path outputDir, List resolutionList) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("#EXTM3U\n"); + + for (Resolution resolution : resolutionList) { + Variant variant = VARIANT_MAP.get(resolution); + sb.append("#EXT-X-STREAM-INF:BANDWIDTH=").append(variant.bandwidth()) + .append(",RESOLUTION=").append(variant.resolution()).append("\n"); + sb.append(variant.playlistPath()).append("\n"); + } + + Path masterPath = outputDir.resolve("master.m3u8"); + Files.writeString(masterPath, sb.toString()); + + log.info("마스터 플레이리스트 생성 - path: {}", masterPath); + return masterPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java new file mode 100644 index 0000000..1178e76 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java @@ -0,0 +1,29 @@ +package com.ott.transcoder.transcode; + +import com.ott.domain.video_profile.domain.Resolution; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg 실행 추상화 인터페이스 + * + * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 + * 단일 해상도에 대한 HLS 트랜스코딩을 수행한다. + * + * 구현체 전환: transcoder.ffmpeg.engine 프로퍼티로 선택 + * - processbuilder: ProcessBuilderFfmpegExecutor (CLI 직접 호출) + * - jaffree: (향후) JaffreeFfmpegExecutor (라이브러리 호출) + */ +public interface FfmpegExecutor { + + /** + * 단일 해상도에 대해 HLS 트랜스코딩을 수행한다. + * + * @param inputFile 원본 영상 파일 경로 + * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) + * @param resolution 대상 해상도 + * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 + */ + Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 0000000..e037306 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,112 @@ +package com.ott.transcoder.transcode.processbuilder; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.transcode.FfmpegExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * ProcessBuilder 기반 FFmpeg CLI 래퍼 + * + * 시스템에 설치된 FFmpeg 바이너리를 ProcessBuilder로 직접 호출 + * 단일 해상도에 대해 HLS 트랜스코딩을 수행하며, + * 결과물로 media.m3u8 (미디어 플레이리스트) + segment_XXX.ts (세그먼트 파일)를 생성 + * + * FFmpeg 내부 처리 흐름: + * Demux(컨테이너 분리) → Decode(디코딩) → Filter(스케일링) → Encode(재인코딩) → Mux(HLS 패키징) + * 이 전체가 하나의 FFmpeg 명령어로 실행됨 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + /** 해상도별 출력 높이 (너비는 -2로 자동 계산, 짝수 보장) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** 해상도별 비디오 비트레이트 */ + private static final Map VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, "800k", + Resolution.P720, "2400k", + Resolution.P1080, "4800k" + ); + + /** 해상도별 오디오 비트레이트 */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + /** HLS 세그먼트 하나의 길이 (초) */ + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException { + int height = HEIGHT_MAP.get(resolution); + String videoBitrate = VIDEO_BITRATE_MAP.get(resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + + // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolution.getKey().toLowerCase()); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg 명령어 조립 + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + height, + "-c:v", "libx264", "-preset", "fast", + "-c:a", "aac", "-b:a", audioBitrate, + "-b:v", videoBitrate, + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg 실행 - resolution: {}, command: {}", resolution.getKey(), String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + // FFmpeg 출력을 읽어야 프로세스가 블로킹되지 않는다 (버퍼 가득 참 방지) + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); + } + + log.info("FFmpeg 완료 - resolution: {}, output: {}", resolution.getKey(), playlistPath); + return playlistPath; + } +} From 5e15a43c188cdb249d8a2ccfeb74403e15d4ec53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 15:32:33 +0900 Subject: [PATCH 111/257] =?UTF-8?q?[FEAT]:=20=EC=A0=80=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transcoder/storage/LocalVideoStorage.java | 77 +++++++++++++++++++ .../ott/transcoder/storage/VideoStorage.java | 30 ++++++++ .../src/main/resources/application.yml | 2 + 3 files changed, 109 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java b/apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java new file mode 100644 index 0000000..33fdd84 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/storage/LocalVideoStorage.java @@ -0,0 +1,77 @@ +package com.ott.transcoder.storage; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.stream.Stream; + +/** + * 로컬 파일시스템 기반 VideoStorage 구현체 + * + * 개발/테스트 환경에서 S3 없이 동작하기 위한 구현 + * - download: 로컬 경로에서 workDir로 파일 복사 + * - upload: workDir 내 모든 파일을 output-dir 하위로 재귀 복사 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "storage.provider", havingValue = "local") +public class LocalVideoStorage implements VideoStorage { + + @Value("${storage.local.output-dir:#{systemProperties['java.io.tmpdir'] + '/ott-storage'}}") + private String outputDir; + + @Override + public Path download(String sourceKey, Path workDir) { + Path source = Path.of(sourceKey); + Path target = workDir.resolve(source.getFileName()); + + try { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("원본 파일 다운로드 실패 - source: " + sourceKey, e); + } + + log.info("원본 다운로드 완료 - {} → {}", sourceKey, target); + return target; + } + + /** + * workDir 내 모든 파일을 output-dir/{destinationPrefix}/ 하위로 복사 + * 디렉토리 구조(360p/, 720p/, 1080p/) 그대로 유지 + */ + @Override + public String upload(Path localDir, String destinationPrefix) { + Path destination = Path.of(outputDir, destinationPrefix); + + try { + Files.createDirectories(destination); + + try (Stream fileStream = Files.walk(localDir)) { + fileStream.filter(Files::isRegularFile).forEach(file -> { + // workDir 기준 상대 경로를 유지하여 복사 (예: 360p/media.m3u8) + Path relativePath = localDir.relativize(file); + Path targetFile = destination.resolve(relativePath); + + try { + Files.createDirectories(targetFile.getParent()); + Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("파일 업로드 실패 - " + file, e); + } + }); + } + } catch (IOException e) { + throw new UncheckedIOException("업로드 디렉토리 생성 실패 - " + destination, e); + } + + log.info("업로드 완료 - {} → {}", localDir, destination); + return destination.toString(); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java new file mode 100644 index 0000000..377b59a --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java @@ -0,0 +1,30 @@ +package com.ott.transcoder.storage; + +import java.nio.file.Path; + +/** + * 영상 파일 저장소 추상화 인터페이스 + * + * 현재 구현체: LocalVideoStorage (로컬 파일시스템) + * S3VideoStorage를 추가하여 실제 AWS S3 연동으로 교체할 것 + */ +public interface VideoStorage { + + /** + * 원본 영상을 저장소에서 로컬 작업 디렉토리로 가져온다 + * + * @param sourceKey 원본 위치 (로컬 경로 또는 S3 key) + * @param workDir 다운로드 대상 로컬 디렉토리 + * @return 다운로드된 로컬 파일 경로 + */ + Path download(String sourceKey, Path workDir); + + /** + * 트랜스코딩 결과물을 저장소에 업로드 + * + * @param localDir 업로드할 로컬 디렉토리 (HLS 파일들이 들어있음) + * @param destinationPrefix 저장소 내 목적지 경로 (예: "media/1/hls") + * @return 업로드된 경로 (DB에 저장할 URL 또는 경로) + */ + String upload(Path localDir, String destinationPrefix); +} diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 999641b..699fe16 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -36,10 +36,12 @@ transcoder: messaging: provider: ${TRANSCODER_MESSAGING_PROVIDER} ffmpeg: + engine: ${TRANSCODER_FFMPEG_ENGINE} path: ${FFMPEG_PATH} temp-dir: ${TRANSCODER_TEMP_DIR} segment-duration: ${TRANSCODER_SEGMENT_DURATION} storage: + provider: ${STORAGE_PROVIDER} local: output-dir: ${STORAGE_LOCAL_OUTPUT_DIR} From 80343f5776bf87e48f769f75519cd59f57c48c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 15:34:09 +0900 Subject: [PATCH 112/257] =?UTF-8?q?[CHORE]:=20git=20Merge=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-admin/build.gradle | 3 +- .../ott/api_admin/config/SecurityConfig.java | 1 + .../controller/BackOfficeContentsApi.java | 24 +++- .../BackOfficeContentsController.java | 21 ++- .../dto/request/ContentsUploadRequest.java | 50 +++++++ .../dto/response/ContentsUploadResponse.java | 31 +++++ .../mapper/BackOfficeContentsMapper.java | 23 ++++ .../service/BackOfficeContentsService.java | 87 ++++++++++++ .../controller/BackOfficeSeriesApi.java | 23 ++++ .../BackOfficeSeriesController.java | 20 ++- .../dto/request/SeriesUploadRequest.java | 34 +++++ .../dto/response/SeriesUploadResponse.java | 22 +++ .../series/mapper/BackOfficeSeriesMapper.java | 17 +++ .../service/BackOfficeSeriesService.java | 63 +++++++-- .../controller/BackOfficeShortFormApi.java | 31 ++++- .../BackOfficeShortFormController.java | 26 +++- .../dto/request/ShortFormUploadRequest.java | 49 +++++++ .../OriginMediaTitleListResponse.java | 2 +- .../ShortFormDetailResponse.java | 2 +- .../{ => response}/ShortFormListResponse.java | 2 +- .../dto/response/ShortFormUploadResponse.java | 31 +++++ .../mapper/BackOfficeShortFormMapper.java | 29 +++- .../service/BackOfficeShortFormService.java | 125 +++++++++++++++--- .../ott/api_admin/upload/controller/.gitkeep | 0 .../com/ott/api_admin/upload/dto/.gitkeep | 0 .../com/ott/api_admin/upload/service/.gitkeep | 0 .../upload/support/ExtensionEnum.java | 58 ++++++++ .../upload/support/UploadHelper.java | 80 +++++++++++ apps/api-user/build.gradle | 4 +- apps/transcoder/build.gradle | 4 +- docker-compose.yml | 27 ++-- .../ott/domain/contents/domain/Contents.java | 7 + .../com/ott/domain/media/domain/Media.java | 7 + .../domain/short_form/domain/ShortForm.java | 6 + modules/{infra => infra-db}/build.gradle | 3 +- .../main/resources/db/migration/V1__init.sql | 0 .../migration/V2__media_table_inheritance.sql | 0 modules/infra-s3/build.gradle | 6 + .../infra/s3/config/S3PresignerConfig.java | 20 +++ .../infra/s3/service/S3PresignService.java | 73 ++++++++++ .../src/main/java/com/ott/infra/db/.gitkeep | 0 .../src/main/java/com/ott/infra/s3/.gitkeep | 0 settings.gradle | 5 +- 43 files changed, 947 insertions(+), 69 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java rename apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/{ => response}/OriginMediaTitleListResponse.java (91%) rename apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/{ => response}/ShortFormDetailResponse.java (97%) rename apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/{ => response}/ShortFormListResponse.java (94%) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java rename modules/{infra => infra-db}/build.gradle (76%) rename modules/{infra => infra-db}/src/main/resources/db/migration/V1__init.sql (100%) rename modules/{infra => infra-db}/src/main/resources/db/migration/V2__media_table_inheritance.sql (100%) create mode 100644 modules/infra-s3/build.gradle create mode 100644 modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java create mode 100644 modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java delete mode 100644 modules/infra/src/main/java/com/ott/infra/db/.gitkeep delete mode 100644 modules/infra/src/main/java/com/ott/infra/s3/.gitkeep diff --git a/apps/api-admin/build.gradle b/apps/api-admin/build.gradle index faf3af7..3a531d7 100644 --- a/apps/api-admin/build.gradle +++ b/apps/api-admin/build.gradle @@ -2,7 +2,8 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') + implementation project(':modules:infra-s3') implementation project(':modules:common-web') implementation project(':modules:common-security') diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index c268785..886dff2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -45,6 +45,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/swagger-resources/**" ).permitAll() + .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index e818a5d..2962629 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -1,7 +1,9 @@ package com.ott.api_admin.content.controller; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -16,8 +18,8 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; - @Tag(name = "BackOffice Contents API", description = "[백오피스] 콘텐츠 관리 API") public interface BackOfficeContentsApi { @@ -65,4 +67,24 @@ ResponseEntity>> getContents( ResponseEntity> getContentsDetail( @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable Long mediaId ); + + @Operation(summary = "콘텐츠 메타데이터 업로드", description = "콘텐츠 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "콘텐츠 메타데이터 업로드 및 Presigned URL 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "콘텐츠 메타데이터 업로드 및 Presigned URL 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN만 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createContentsUpload( + @Parameter(description = "ContentsUploadRequest를 참고해주세요.", required = true) + @RequestBody ContentsUploadRequest request + ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index 093daec..ee7116e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -1,18 +1,18 @@ package com.ott.api_admin.content.controller; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; 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 jakarta.validation.Valid; 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; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/back-office/admin/contents") @@ -43,4 +43,13 @@ public ResponseEntity> getContentsDetail SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) ); } -} + + @Override + @PostMapping("/upload") + // ADMIN 권한으로 콘텐츠 업로드 초기화를 수행합니다. + public ResponseEntity> createContentsUpload( + @Valid @RequestBody ContentsUploadRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.createContentsUpload(request))); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java new file mode 100644 index 0000000..126fcff --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -0,0 +1,50 @@ +package com.ott.api_admin.content.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +@Schema(type = "Object", description = "콘텐츠 업로드 요청") +public record ContentsUploadRequest( + @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") + Long seriesId, + + @Schema(type = "String", description = "콘텐츠 제목", example = "응답하라 1988 1화") + @NotBlank + String title, + + @Schema(type = "String", description = "콘텐츠 설명", example = "가족과 이웃의 따뜻한 이야기") + @NotBlank + String description, + + @Schema(type = "String", description = "출연진", example = "성동일, 이일화") + @NotBlank + String actors, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "영상 크기(KB)", example = "512000") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명", example = "thumb.jpg") + @NotBlank + String thumbnailFileName, + + @Schema(type = "String", description = "원본 영상 파일명", example = "origin.mp4") + @NotBlank + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java new file mode 100644 index 0000000..a5f0f25 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(type = "Object", description = "콘텐츠 업로드 응답") +public record ContentsUploadResponse( + @Schema(type = "Long", description = "생성된 콘텐츠 ID", example = "10") + Long contentsId, + + @Schema(type = "String", description = "포스터 S3 object key", example = "contents/10/poster/poster.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "썸네일 S3 object key", example = "contents/10/thumbnail/thumb.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "원본 영상 S3 object key", example = "contents/10/origin/origin.mp4") + String originObjectKey, + + @Schema(type = "String", description = "트랜스코딩 마스터 플레이리스트 object key", example = "contents/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "포스터 업로드용 사전 서명 URL") + String posterUploadUrl, + + @Schema(type = "String", description = "썸네일 업로드용 사전 서명 URL") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "원본 영상 업로드용 사전 서명 URL") + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java index 3897988..f0850d5 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -2,6 +2,7 @@ import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.domain.contents.domain.Contents; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; @@ -45,6 +46,28 @@ public ContentsDetailResponse toContentsDetailResponse(Contents contents, Media ); } + public ContentsUploadResponse toContentsUploadResponse( + Long contentsId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ContentsUploadResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index a5e3a46..5a9d077 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -1,8 +1,11 @@ package com.ott.api_admin.content.service; +import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; +import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -15,6 +18,10 @@ import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.series.domain.Series; +import com.ott.domain.series.repository.SeriesRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -33,6 +40,9 @@ public class BackOfficeContentsService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; private final ContentsRepository contentsRepository; + private final SeriesRepository seriesRepository; + private final S3PresignService s3PresignService; + private final UploadHelper uploadHelper; @Transactional(readOnly = true) public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { @@ -76,4 +86,81 @@ public ContentsDetailResponse getContentsDetail(Long mediaId) { return backOfficeContentsMapper.toContentsDetailResponse(contents, media, uploaderNickname, seriesTitle, mediaTagList); } + + @Transactional + // 콘텐츠/미디어 레코드를 생성하고 S3 업로드용 Presigned URL을 발급합니다. + public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request) { + Member uploader = uploadHelper.resolveUploader(); + Series series = resolveSeries(request.seriesId()); + + // S3 object key 안정성을 위해 파일명을 정규화합니다. + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + // 콘텐츠 ID 생성 전이라 최종 URL을 만들 수 없어 임시값을 저장합니다. + .posterUrl("PENDING") + // 콘텐츠 ID 기반 object key 확정 후 실제 S3 URL로 즉시 갱신합니다. + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.CONTENTS) + .publicStatus(request.publicStatus()) + .build() + ); + + Contents contents = contentsRepository.save( + Contents.builder() + .media(media) + .series(series) + .actors(request.actors()) + .duration(request.duration()) + .videoSize(request.videoSize()) + // 콘텐츠 ID 생성 전이라 원본 URL을 확정할 수 없어 임시값을 저장합니다. + .originUrl("PENDING") + // 트랜스코딩 결과 URL도 ID 기반 경로 계산 후 갱신합니다. + .masterPlaylistUrl("PENDING") + .build() + ); + + Long contentsId = contents.getId(); + String posterObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); + String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; + + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + contents.updateStorageKeys( + s3PresignService.toObjectUrl(originObjectKey), + s3PresignService.toObjectUrl(masterPlaylistObjectKey) + ); + + return backOfficeContentsMapper.toContentsUploadResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, uploadHelper.resolveVideoContentType(sanitizedOriginFileName)) + ); + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + // 요청으로 전달된 seriesId의 존재 여부를 확인합니다. + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 7270949..bcfcf27 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -1,8 +1,10 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -16,6 +18,7 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Series API", description = "[백오피스] 시리즈 관리 API") @@ -89,4 +92,24 @@ ResponseEntity>> getSeries ResponseEntity> getSeriesDetail( @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable Long mediaId ); + + @Operation(summary = "시리즈 메타데이터 업로드", description = "시리즈 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "시리즈 메타데이터 업로드 및 Presigned URL 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = SeriesUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "시리즈 메타데이터 업로드 및 Presigned URL 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN만 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createSeriesUpload( + @Parameter(description = "SeriesUploadRequest를 참고해주세요.", required = true) + @RequestBody SeriesUploadRequest request + ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 5786f8e..010e016 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -1,14 +1,24 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.service.BackOfficeSeriesService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/back-office/admin/series") @@ -48,4 +58,12 @@ public ResponseEntity> getSeriesDetail(@Pa SuccessResponse.of(backOfficeSeriesService.getSeriesDetail(mediaId)) ); } + + @Override + @PostMapping("/upload") + public ResponseEntity> createSeriesUpload( + @Valid @RequestBody SeriesUploadRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.createSeriesUpload(request))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java new file mode 100644 index 0000000..065aedd --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -0,0 +1,34 @@ +package com.ott.api_admin.series.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(type = "Object", description = "시리즈 업로드 요청") +public record SeriesUploadRequest( + @Schema(type = "String", description = "시리즈 제목", example = "응답하라 1988") + @NotBlank + String title, + + @Schema(type = "String", description = "시리즈 설명", example = "가족과 이웃의 따뜻한 이야기") + @NotBlank + String description, + + @Schema(type = "String", description = "출연진", example = "성동일, 이일화") + @NotBlank + String actors, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명", example = "thumb.jpg") + @NotBlank + String thumbnailFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java new file mode 100644 index 0000000..d0b8bca --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(type = "Object", description = "시리즈 업로드 응답") +public record SeriesUploadResponse( + @Schema(type = "Long", description = "생성된 시리즈 ID", example = "10") + Long seriesId, + + @Schema(type = "String", description = "포스터 S3 object key", example = "series/10/poster/poster.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "썸네일 S3 object key", example = "series/10/thumbnail/thumb.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "포스터 업로드용 사전 서명 URL") + String posterUploadUrl, + + @Schema(type = "String", description = "썸네일 업로드용 사전 서명 URL") + String thumbnailUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 7e6506d..29f6eba 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -3,6 +3,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; import com.ott.domain.series.domain.Series; @@ -53,6 +54,22 @@ public SeriesDetailResponse toSeriesDetailResponse(Series series, Media media, S ); } + public SeriesUploadResponse toSeriesUploadResponse( + Long seriesId, + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + return new SeriesUploadResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 9f28a11..cad6114 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,21 +1,25 @@ package com.ott.api_admin.series.service; -import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; +import com.ott.api_admin.upload.support.UploadHelper; 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.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 com.ott.common.web.response.PageInfo; -import com.ott.common.web.response.PageResponse; +import com.ott.domain.member.domain.Member; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -37,20 +41,19 @@ public class BackOfficeSeriesService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; private final SeriesRepository seriesRepository; + private final S3PresignService s3PresignService; + private final UploadHelper uploadHelper; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 1. 미디어 중 시리즈 대상 페이징 Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWord(pageable, MediaType.SERIES, searchWord); - // 2. 조회된 미디어 ID 목록 추출 List mediaIdList = mediaPage.getContent().stream() .map(Media::getId) .toList(); - // 3. IN절로 태그 일괄 조회 Map> tagListByMediaId = mediaIdList.isEmpty() ? Collections.emptyMap() : mediaTagRepository.findWithTagAndCategoryByMediaIds(mediaIdList).stream() @@ -75,7 +78,6 @@ public PageResponse getSeries(int page, int size, String sea public PageResponse getSeriesTitle(Integer page, Integer size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 시리즈 + 미디어 페이징 Page seriesPage = seriesRepository.findSeriesListWithMediaBySearchWord(pageable, searchWord); List responseList = seriesPage.getContent().stream() @@ -92,16 +94,59 @@ public PageResponse getSeriesTitle(Integer page, Intege @Transactional(readOnly = true) public SeriesDetailResponse getSeriesDetail(Long mediaId) { - // 1. Series + Media + Uploader 한 번에 조회 Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Media media = series.getMedia(); String uploaderNickname = media.getUploader().getNickname(); - // 2. 태그 조회 List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); return backOfficeSeriesMapper.toSeriesDetailResponse(series, media, uploaderNickname, mediaTagList); } + + @Transactional + public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { + Member uploader = uploadHelper.resolveUploader(); + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + .posterUrl("PENDING") + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SERIES) + .publicStatus(request.publicStatus()) + .build() + ); + + Series series = seriesRepository.save( + Series.builder() + .media(media) + .actors(request.actors()) + .build() + ); + + Long seriesId = series.getId(); + String posterObjectKey = uploadHelper.buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = uploadHelper.buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + + return backOfficeSeriesMapper.toSeriesUploadResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)) + ); + } } + diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index b7bfa4b..a7a5ea6 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -1,10 +1,10 @@ package com.ott.api_admin.shortform.controller; -import com.ott.api_admin.content.dto.response.ContentsDetailResponse; -import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -20,6 +20,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "BackOffice Short-Form API", description = "[백오피스] 숏폼 관리 API") @@ -84,4 +85,24 @@ ResponseEntity> getShortFormDetail( @Parameter(description = "조회할 숏폼의 미디어 ID", required = true) @PathVariable Long mediaId, Authentication authentication ); + + @Operation(summary = "숏폼 메타데이터 업로드", description = "숏폼 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "숏폼 메타데이터 업로드 및 Presigned URL 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormUploadResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "숏폼 메타데이터 업로드 및 Presigned URL 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN, EDITOR 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> createShortFormUpload( + @Parameter(description = "ShortFormUploadRequest 참고해주세요.", required = true) + @RequestBody ShortFormUploadRequest request + ); } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index cf205c4..4d6bc32 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -1,16 +1,26 @@ package com.ott.api_admin.shortform.controller; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.service.BackOfficeShortFormService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import com.ott.domain.common.PublicStatus; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/back-office/short-forms") @@ -55,4 +65,12 @@ public ResponseEntity> getShortFormDeta SuccessResponse.of(backOfficeShortFormService.getShortFormDetail(mediaId, authentication)) ); } + + @Override + @PostMapping("/upload") + public ResponseEntity> createShortFormUpload( + @Valid @RequestBody ShortFormUploadRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.createShortFormUpload(request))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java new file mode 100644 index 0000000..3486ffb --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -0,0 +1,49 @@ +package com.ott.api_admin.shortform.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +@Schema(type = "Object", description = "숏폼 업로드 요청") +public record ShortFormUploadRequest( + @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") + Long seriesId, + + @Schema(type = "Long", description = "연결할 콘텐츠 ID(선택)", example = "2") + Long contentsId, + + @Schema(type = "String", description = "숏폼 제목", example = "하이라이트") + @NotBlank + String title, + + @Schema(type = "String", description = "숏폼 설명", example = "명장면 하이라이트") + @NotBlank + String description, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "영상 길이(초)", example = "60") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "영상 크기(KB)", example = "10240") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") + @NotBlank + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명", example = "thumb.jpg") + @NotBlank + String thumbnailFileName, + + @Schema(type = "String", description = "원본 영상 파일명", example = "origin.mp4") + @NotBlank + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java similarity index 91% rename from apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java index e01bb3c..1e68251 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/OriginMediaTitleListResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.dto; +package com.ott.api_admin.shortform.dto.response; import com.ott.domain.common.MediaType; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java similarity index 97% rename from apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java index 1d4debc..c81184b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormDetailResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormDetailResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.dto; +package com.ott.api_admin.shortform.dto.response; import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java similarity index 94% rename from apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java rename to apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java index 15a4106..863c2cb 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/ShortFormListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormListResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.shortform.dto; +package com.ott.api_admin.shortform.dto.response; import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java new file mode 100644 index 0000000..31f7c5f --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.shortform.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(type = "Object", description = "숏폼 업로드 응답") +public record ShortFormUploadResponse( + @Schema(type = "Long", description = "생성된 숏폼 ID", example = "10") + Long shortFormId, + + @Schema(type = "String", description = "포스터 S3 object key", example = "short-forms/10/poster/poster.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "썸네일 S3 object key", example = "short-forms/10/thumbnail/thumb.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "원본 영상 S3 object key", example = "short-forms/10/origin/origin.mp4") + String originObjectKey, + + @Schema(type = "String", description = "트랜스코딩 마스터 플레이리스트 object key", example = "short-forms/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "포스터 업로드용 사전 서명 URL") + String posterUploadUrl, + + @Schema(type = "String", description = "썸네일 업로드용 사전 서명 URL") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "원본 영상 업로드용 사전 서명 URL") + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java index 562f780..c339cab 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -1,8 +1,9 @@ package com.ott.api_admin.shortform.mapper; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; import com.ott.domain.common.MediaType; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; @@ -60,6 +61,28 @@ public OriginMediaTitleListResponse toOriginMediaTitleListResponse( ); } + public ShortFormUploadResponse toShortFormUploadResponse( + Long shortFormId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ShortFormUploadResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 32a8dc5..aa52827 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,26 +1,31 @@ package com.ott.api_admin.shortform.service; -import com.ott.api_admin.shortform.dto.OriginMediaTitleListResponse; -import com.ott.api_admin.shortform.dto.ShortFormDetailResponse; -import com.ott.api_admin.shortform.dto.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; +import com.ott.api_admin.upload.support.UploadHelper; 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 com.ott.domain.member.domain.Member; import com.ott.domain.member.domain.Role; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.contents.repository.ContentsRepository; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; import com.ott.domain.short_form.domain.ShortForm; import com.ott.domain.short_form.repository.ShortFormRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -45,6 +50,8 @@ public class BackOfficeShortFormService { private final SeriesRepository seriesRepository; private final ContentsRepository contentsRepository; private final ShortFormRepository shortFormRepository; + private final S3PresignService s3PresignService; + private final UploadHelper uploadHelper; @Transactional(readOnly = true) public PageResponse getShortFormList( @@ -52,15 +59,14 @@ public PageResponse getShortFormList( ) { Pageable pageable = PageRequest.of(page, size); - // 1. 관리자/에디터 여부 확인 Long memberId = (Long) authentication.getPrincipal(); boolean isEditor = authentication.getAuthorities().stream() .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); Long uploaderId = null; - // 2. 에디터인 경우 본인이 업로드한 숏폼만 조회 가능 - if (isEditor) + if (isEditor) { uploaderId = memberId; + } Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId @@ -83,12 +89,10 @@ public PageResponse getShortFormList( public PageResponse getOriginMediaTitle(Integer page, Integer size, String searchWord) { Pageable pageable = PageRequest.of(page, size); - // 1. Media 페이징 조회 (Series + 단편 Contents / 에피소드 제외) Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); List mediaList = mediaPage.getContent(); - // 2. mediaId를 타입별로 분리 List seriesMediaIdList = mediaList.stream() .filter(m -> m.getMediaType() == MediaType.SERIES) .map(Media::getId) @@ -99,14 +103,12 @@ public PageResponse getOriginMediaTitle(Integer pa .map(Media::getId) .toList(); - // 3. 일괄 조회: mediaId → entityId 매핑 Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList).stream() .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); - // 4. 응답 매핑 List responseList = mediaList.stream() .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, contentsIdByMediaId)) .toList(); @@ -122,30 +124,115 @@ public PageResponse getOriginMediaTitle(Integer pa @Transactional(readOnly = true) public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { - // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 조회 ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - // 2. 에디터 - 숏폼 업로더 권한 체크 Long memberId = (Long) authentication.getPrincipal(); boolean isEditor = authentication.getAuthorities().stream() .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); Media media = shortForm.getMedia(); - if (isEditor && !media.getUploader().getId().equals(memberId)) + if (isEditor && !media.getUploader().getId().equals(memberId)) { throw new BusinessException(ErrorCode.FORBIDDEN); + } String uploaderNickname = media.getUploader().getNickname(); - // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 Optional originMedia = shortForm.findOriginMedia(); String originMediaTitle = null; - if (originMedia.isPresent()) + if (originMedia.isPresent()) { originMediaTitle = originMedia.get().getTitle(); + } - // 3. 태그 조회 - List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); // 숏폼은 원본 콘텐츠의 태그를 따라가지만, 자체 태그로 생성되어 있음을 상정 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMediaTitle, mediaTagList); } + + @Transactional + public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request) { + validateExclusiveTarget(request.seriesId(), request.contentsId()); + + Member uploader = uploadHelper.resolveUploader(); + Series series = resolveSeries(request.seriesId()); + Contents contents = resolveContents(request.contentsId()); + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); + + Media media = mediaRepository.save( + Media.builder() + .uploader(uploader) + .title(request.title()) + .description(request.description()) + .posterUrl("PENDING") + .thumbnailUrl("PENDING") + .bookmarkCount(0L) + .likesCount(0L) + .mediaType(MediaType.SHORT_FORM) + .publicStatus(request.publicStatus()) + .build() + ); + + ShortForm shortForm = shortFormRepository.save( + ShortForm.builder() + .media(media) + .series(series) + .contents(contents) + .duration(request.duration()) + .videoSize(request.videoSize()) + .originUrl("PENDING") + .masterPlaylistUrl("PENDING") + .build() + ); + + Long shortFormId = shortForm.getId(); + String posterObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); + String thumbnailObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); + String originObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); + String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; + + media.updateImageKeys( + s3PresignService.toObjectUrl(posterObjectKey), + s3PresignService.toObjectUrl(thumbnailObjectKey) + ); + shortForm.updateStorageKeys( + s3PresignService.toObjectUrl(originObjectKey), + s3PresignService.toObjectUrl(masterPlaylistObjectKey) + ); + + return backOfficeShortFormMapper.toShortFormUploadResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), + s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)), + s3PresignService.createPutPresignedUrl(originObjectKey, uploadHelper.resolveVideoContentType(sanitizedOriginFileName)) + ); + } + + private void validateExclusiveTarget(Long seriesId, Long contentsId) { + if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + + private Series resolveSeries(Long seriesId) { + if (seriesId == null) { + return null; + } + return seriesRepository.findById(seriesId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + + private Contents resolveContents(Long contentsId) { + if (contentsId == null) { + return null; + } + return contentsRepository.findById(contentsId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + } } + diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep b/apps/api-admin/src/main/java/com/ott/api_admin/upload/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java new file mode 100644 index 0000000..d252b34 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/ExtensionEnum.java @@ -0,0 +1,58 @@ +package com.ott.api_admin.upload.support; + +import java.util.Arrays; +import java.util.Locale; + +public enum ExtensionEnum { + JPG("jpg", "image/jpeg", Category.IMAGE), + JPEG("jpeg", "image/jpeg", Category.IMAGE), + PNG("png", "image/png", Category.IMAGE), + WEBP("webp", "image/webp", Category.IMAGE), + MP4("mp4", "video/mp4", Category.VIDEO), + MOV("mov", "video/quicktime", Category.VIDEO), + WEBM("webm", "video/webm", Category.VIDEO), + M4V("m4v", "video/x-m4v", Category.VIDEO); + + private final String extension; + private final String contentType; + private final Category category; + + ExtensionEnum(String extension, String contentType, Category category) { + this.extension = extension; + this.contentType = contentType; + this.category = category; + } + + public static String resolveImageContentType(String fileName) { + return resolveContentType(fileName, Category.IMAGE); + } + + public static String resolveVideoContentType(String fileName) { + return resolveContentType(fileName, Category.VIDEO); + } + + private static String resolveContentType(String fileName, Category expectedCategory) { + String extractedExtension = extractExtension(fileName); + + return Arrays.stream(values()) + .filter(candidate -> candidate.category == expectedCategory) + .filter(candidate -> candidate.extension.equals(extractedExtension)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported file extension: " + extractedExtension)) + .contentType; + } + + private static String extractExtension(String fileName) { + String trimmed = fileName.trim(); + int extensionDelimiterIndex = trimmed.lastIndexOf('.'); + if (extensionDelimiterIndex < 0 || extensionDelimiterIndex == trimmed.length() - 1) { + throw new IllegalArgumentException("File extension is missing"); + } + return trimmed.substring(extensionDelimiterIndex + 1).toLowerCase(Locale.ROOT); + } + + private enum Category { + IMAGE, + VIDEO + } +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java new file mode 100644 index 0000000..bb29bed --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -0,0 +1,80 @@ +package com.ott.api_admin.upload.support; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UploadHelper { + + private final MemberRepository memberRepository; + + public String buildObjectKey(String resourceRoot, Long resourceId, String assetType, String fileName) { + return resourceRoot + "/" + resourceId + "/" + assetType + "/" + fileName; + } + + public String resolveImageContentType(String fileName) { + try { + return ExtensionEnum.resolveImageContentType(fileName); + } catch (IllegalArgumentException ex) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + + public String resolveVideoContentType(String fileName) { + try { + return ExtensionEnum.resolveVideoContentType(fileName); + } catch (IllegalArgumentException ex) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + + public String sanitizeFileName(String fileName) { + String trimmed = fileName == null ? "" : fileName.trim(); + int extensionDelimiterIndex = trimmed.lastIndexOf('.'); + String baseName = extensionDelimiterIndex > 0 ? trimmed.substring(0, extensionDelimiterIndex) : trimmed; + String extensionPart = extensionDelimiterIndex > 0 ? trimmed.substring(extensionDelimiterIndex + 1) : ""; + + String sanitizedBaseName = baseName + .replace("/", "") + .replace("\\", "") + .replaceAll("[^0-9A-Za-z가-힣_-]", ""); + String sanitizedExtension = extensionPart.replaceAll("[^0-9A-Za-z]", "").toLowerCase(); + + if (sanitizedBaseName.isBlank()) { + sanitizedBaseName = "file"; + } + if (sanitizedExtension.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return sanitizedBaseName + "." + sanitizedExtension; + } + + public Member resolveUploader() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Long memberId; + try { + memberId = Long.valueOf(String.valueOf(principal)); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + return memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); + } +} diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index 800db85..5a3ef52 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') implementation project(':modules:common-web') implementation project(':modules:common-security') @@ -28,7 +28,7 @@ dependencies { * Flyway가 infra 모듈의 db/migration 리소스를 확실히 인식하도록 설정 */ tasks.named('processResources') { - from(project(':modules:infra').sourceSets.main.resources.srcDirs) { + from(project(':modules:infra-db').sourceSets.main.resources.srcDirs) { include 'db/migration/**' } } diff --git a/apps/transcoder/build.gradle b/apps/transcoder/build.gradle index 2b818b7..c3c0da2 100644 --- a/apps/transcoder/build.gradle +++ b/apps/transcoder/build.gradle @@ -2,8 +2,8 @@ apply plugin: 'org.springframework.boot' dependencies { implementation project(':modules:domain') - implementation project(':modules:infra') + implementation project(':modules:infra-db') implementation project(':modules:common-web') implementation 'org.springframework.boot:spring-boot-starter-amqp' -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index 0018ec2..f67b9a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ - services: + # ============ DB ============ mysql: image: mysql:8.0 container_name: ott-mysql @@ -10,7 +10,6 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_CHARACTER_SET_SERVER: utf8mb4 MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci - ports: - "3307:3306" volumes: @@ -21,7 +20,7 @@ services: timeout: 5s retries: 20 - # ============ Flyway 마이그레이션 담당 (먼저 기동) ============ + # ============ Flyway 마이그레이션 해당 (먼저 기동) ============ api-user: build: context: . @@ -34,6 +33,12 @@ services: SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PUBLIC_BASE_URL: ${AWS_S3_PUBLIC_BASE_URL} + AWS_S3_PRESIGN_EXPIRE_SECONDS: ${AWS_S3_PRESIGN_EXPIRE_SECONDS} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} FRONTEND_URL: ${FRONTEND_URL} @@ -41,16 +46,15 @@ services: mysql: condition: service_healthy healthcheck: - test: [ "CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1" ] + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] interval: 10s timeout: 5s retries: 12 start_period: 30s - - # ============ Flyway 완류 이후 빌드하는 앱 ============ + # ============ Flyway 완료 이후 빌드하는 앱 ============ api-admin: - build: # 이미지를 다운받는게 아니라 해당 경로에서 빌드 + build: context: . dockerfile: apps/api-admin/Dockerfile container_name: ott-api-admin @@ -61,12 +65,19 @@ services: SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} JWT_SECRET_BASE64: ${JWT_SECRET_BASE64} + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PUBLIC_BASE_URL: ${AWS_S3_PUBLIC_BASE_URL} + AWS_S3_PRESIGN_EXPIRE_SECONDS: ${AWS_S3_PRESIGN_EXPIRE_SECONDS} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} depends_on: mysql: condition: service_healthy api-user: condition: service_healthy + # ============ Transcoder 워커 ============ transcoder: build: context: . @@ -86,5 +97,3 @@ services: volumes: mysql-data: - - \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index 105653b..a1b8900 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -19,6 +19,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Objects; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -53,4 +55,9 @@ public class Contents extends BaseEntity { @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + + public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { + this.originUrl = originUrl; + this.masterPlaylistUrl = masterPlaylistUrl; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index 4ebf89a..6204067 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -21,6 +21,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Objects; + @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -62,4 +64,9 @@ public class Media extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "public_status", nullable = false) private PublicStatus publicStatus; + + public void updateImageKeys(String posterUrl, String thumbnailUrl) { + this.posterUrl = posterUrl; + this.thumbnailUrl = thumbnailUrl; + } } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index 2663489..e8cddfe 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -21,6 +21,7 @@ import lombok.NoArgsConstructor; import java.util.Optional; +import java.util.Objects; @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -58,6 +59,11 @@ public class ShortForm extends BaseEntity { @Column(name = "master_playlist_url", columnDefinition = "TEXT") private String masterPlaylistUrl; + public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { + this.originUrl = originUrl; + this.masterPlaylistUrl = masterPlaylistUrl; + } + public Optional findOriginMedia() { if (series != null) return Optional.of(series.getMedia()); if (contents != null) return Optional.of(contents.getMedia()); diff --git a/modules/infra/build.gradle b/modules/infra-db/build.gradle similarity index 76% rename from modules/infra/build.gradle rename to modules/infra-db/build.gradle index e42dc85..0109ae5 100644 --- a/modules/infra/build.gradle +++ b/modules/infra-db/build.gradle @@ -2,5 +2,4 @@ dependencies { implementation project(':modules:domain') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' - implementation 'software.amazon.awssdk:s3:2.21.0' -} \ No newline at end of file +} diff --git a/modules/infra/src/main/resources/db/migration/V1__init.sql b/modules/infra-db/src/main/resources/db/migration/V1__init.sql similarity index 100% rename from modules/infra/src/main/resources/db/migration/V1__init.sql rename to modules/infra-db/src/main/resources/db/migration/V1__init.sql diff --git a/modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql b/modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql similarity index 100% rename from modules/infra/src/main/resources/db/migration/V2__media_table_inheritance.sql rename to modules/infra-db/src/main/resources/db/migration/V2__media_table_inheritance.sql diff --git a/modules/infra-s3/build.gradle b/modules/infra-s3/build.gradle new file mode 100644 index 0000000..4ae4833 --- /dev/null +++ b/modules/infra-s3/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation 'org.springframework:spring-context' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation platform('software.amazon.awssdk:bom:2.42.0') + implementation 'software.amazon.awssdk:s3' +} diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java new file mode 100644 index 0000000..aac21c9 --- /dev/null +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/config/S3PresignerConfig.java @@ -0,0 +1,20 @@ +package com.ott.infra.s3.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3PresignerConfig { + + @Bean + public S3Presigner s3Presigner(@Value("${aws.region:ap-northeast-2}") String region) { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java new file mode 100644 index 0000000..03a771a --- /dev/null +++ b/modules/infra-s3/src/main/java/com/ott/infra/s3/service/S3PresignService.java @@ -0,0 +1,73 @@ +package com.ott.infra.s3.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Service +public class S3PresignService { + + private final S3Presigner s3Presigner; + private final String region; + private final String bucket; + private final String publicBaseUrl; + private final long expireSeconds; + + public S3PresignService( + S3Presigner s3Presigner, + @Value("${aws.region:ap-northeast-2}") String region, + @Value("${aws.s3.bucket:local-bucket}") String bucket, + @Value("${aws.s3.public-base-url:}") String publicBaseUrl, + @Value("${aws.s3.presign-expire-seconds:600}") long expireSeconds + ) { + this.s3Presigner = s3Presigner; + this.region = region; + this.bucket = bucket; + this.publicBaseUrl = publicBaseUrl; + this.expireSeconds = expireSeconds; + } + + public String createPutPresignedUrl(String objectKey, String contentType) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expireSeconds)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + return presignedRequest.url().toString(); + } catch (SdkException ex) { + throw new IllegalStateException("업로드 URL 생성에 실패했습니다.", ex); + } + } + + // objectKey를 실제 S3 객체 URL 형식으로 변환합니다. + // - 한글/공백/특수문자 깨짐 방지를 위해 URL 인코딩 + // - 공백은 '+' 대신 '%20'으로 정규화 + // - 경로 구분자는 유지하기 위해 '%2F'를 '/'로 복원 + public String toObjectUrl(String objectKey) { + String encodedKey = URLEncoder.encode(objectKey, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%2F", "/"); + + // public-base-url이 설정되어 있으면 우선 사용하고, 없으면 기존 S3 URL 규칙으로 fallback합니다. + String baseUrl = (publicBaseUrl == null || publicBaseUrl.isBlank()) + ? "https://" + bucket + ".s3." + region + ".amazonaws.com" + : publicBaseUrl.replaceAll("/+$", ""); + return baseUrl + "/" + encodedKey; + } +} diff --git a/modules/infra/src/main/java/com/ott/infra/db/.gitkeep b/modules/infra/src/main/java/com/ott/infra/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/infra/src/main/java/com/ott/infra/s3/.gitkeep b/modules/infra/src/main/java/com/ott/infra/s3/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/settings.gradle b/settings.gradle index 10f714a..c86a7f4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,7 +8,8 @@ include( // Modules ":modules:domain", - ":modules:infra", + ":modules:infra-db", + ":modules:infra-s3", ":modules:common-web", ":modules:common-security" -) \ No newline at end of file +) From 6b6db92cbbea93de303a2eef73338de4d2718238 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 15:41:16 +0900 Subject: [PATCH 113/257] =?UTF-8?q?[FEAT]:=20Valid=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_user/member/controller/MemberController.java | 2 +- .../ott/api_user/member/dto/request/UpdateMemberRequest.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 3481790..66a3be8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -28,7 +28,7 @@ public ResponseEntity> getMyPage(@Authentication @PatchMapping("/me") public ResponseEntity> updateMyInfo( @AuthenticationPrincipal Long memberId, - @RequestBody UpdateMemberRequest request + @Valid @RequestBody UpdateMemberRequest request ) { return ResponseEntity.ok(SuccessResponse.of(memberService.updateMyInfo(memberId, request))); diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java index 0ccce7a..c4f74df 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java @@ -1,6 +1,8 @@ package com.ott.api_user.member.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,6 +13,8 @@ @Schema(description = "내 정보 수정 요청 DTO") public class UpdateMemberRequest { + @NotNull + @NotEmpty @Schema(type = "String", example = "김마루1", description = "변경할 닉네임 / null인 경우 변경 x") private String nickname; From d2db8bf68454b0a55d2df61c356c45db9d987fec Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 15:49:49 +0900 Subject: [PATCH 114/257] =?UTF-8?q?[FEAT]:=20=EB=A9=B1=EB=93=B1=EC=84=B1?= =?UTF-8?q?=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/member/service/MemberService.java | 3 +++ .../preferred_tag/repository/PreferredTagRepository.java | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index d8926b0..998cdb5 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -91,6 +91,9 @@ public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { Member findMember = memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + //재 호출 시 중복 방지 코드 + preferredTagRepository.deleteAllByMember(findMember); + List tags = tagRepository.findAllByIdInAndStatus(request.getTagsId(), Status.ACTIVE); if (tags.size() != request.getTagsId().size()) { throw new BusinessException(ErrorCode.TAG_NOT_FOUND); diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java index 4787ae4..6db768e 100644 --- a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -27,8 +27,8 @@ public interface PreferredTagRepository extends JpaRepository findAllWithTagAndCategoryByMemberIdAndStatus(@Param("memberId") Long memberId, @Param("status") Status status); - // 선호 태그 삭제 - @Modifying + // 선호 태그 삭제, 영속성 컨텍스트 들어있는 내용 삭제 + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM PreferredTag pt WHERE pt.member = :member") void deleteAllByMember(@Param("member") Member member); } From 289b42a16c1c869d2ef12331b4747253d76a81f1 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:36:39 +0900 Subject: [PATCH 115/257] =?UTF-8?q?[FEAT]:=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4=ED=8A=B8(?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D)=20=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=20=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ContentsDetailResponse.java | 49 ++-- .../dto/response/ContentsListResponse.java | 3 +- .../dto/response/SeriesListResponse.java | 22 +- .../dto/OriginMediaTitleListResponse.java | 10 +- .../service/BackOfficeShortFormService.java | 231 +++++++-------- .../ott/api_user}/common/ContentSource.java | 13 +- .../common/dto/ContentListElement.java | 36 +++ .../content/controller/ContentApi.java | 40 ++- .../content/controller/ContentController.java | 17 +- .../content/service/ContentService.java | 124 +++++++-- .../series/dto/SeriesDetailResponse.java | 2 +- .../ott/common/web/exception/ErrorCode.java | 6 +- .../repository/MediaRepositoryCustom.java | 6 +- .../media/repository/MediaRepositoryImpl.java | 262 +++++++++--------- 14 files changed, 466 insertions(+), 355 deletions(-) rename {modules/domain/src/main/java/com/ott/domain => apps/api-user/src/main/java/com/ott/api_user}/common/ContentSource.java (59%) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java index 7c2e7ef..4b853b5 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java @@ -1,57 +1,42 @@ 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; +import com.ott.domain.common.PublicStatus; + @Schema(description = "콘텐츠 상세 조회 응답") public record ContentsDetailResponse( - @Schema(type = "Long", description = "콘텐츠 ID", example = "1") - Long contentsId, + @Schema(type = "Long", description = "콘텐츠 ID", example = "1") Long contentsId, - @Schema(type = "String", description = "포스터 URL", example = "https://cdn.example.com/poster.jpg") - String posterUrl, + @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 = "썸네일 URL", example = "https://cdn.example.com/thumb.jpg") String thumbnailUrl, - @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") - String title, + @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") String title, - @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") - String description, + @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") String description, - @Schema(type = "String", description = "출연진", example = "송강호, 이선균") - String actors, + @Schema(type = "String", description = "출연진", example = "송강호, 이선균") String actors, - @Schema(type = "String", description = "소속 시리즈 제목 (없으면 null)", example = "비밀의 숲") - String seriesTitle, + @Schema(type = "String", description = "소속 시리즈 제목 (없으면 null)", example = "비밀의 숲") String seriesTitle, - @Schema(type = "String", description = "업로더 닉네임", example = "관리자") - String uploaderNickname, + @Schema(type = "String", description = "업로더 닉네임", example = "관리자") String uploaderNickname, - @Schema(type = "Integer", description = "영상 길이(초)", example = "7200") - Integer duration, + @Schema(type = "Integer", description = "영상 길이(초)", example = "7200") Integer duration, - @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") - Integer videoSize, + @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") Integer videoSize, - @Schema(type = "String", description = "카테고리명", example = "드라마") - String categoryName, + @Schema(type = "String", description = "카테고리명", example = "드라마") String categoryName, - @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") - List tagNameList, + @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") List tagNameList, - @Schema(type = "String", description = "공개 여부", example = "PUBLIC") - PublicStatus publicStatus, + @Schema(type = "String", description = "공개 여부", example = "PUBLIC") PublicStatus publicStatus, - @Schema(type = "Long", description = "북마크 수", example = "150") - Long bookmarkCount, + @Schema(type = "Long", description = "북마크 수", example = "150") Long bookmarkCount, - @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") - LocalDate uploadedDate -) { + @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") LocalDate uploadedDate) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java index 3bed424..4382fbc 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -1,11 +1,12 @@ 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; +import com.ott.domain.common.PublicStatus; + @Schema(description = "콘텐츠 목록 조회 응답") public record ContentsListResponse( diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java index 444547a..7cc3280 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesListResponse.java @@ -1,29 +1,23 @@ package com.ott.api_admin.series.dto.response; -import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import com.ott.domain.common.PublicStatus; + @Schema(description = "시리즈 목록 조회 응답") public record SeriesListResponse( - @Schema(type = "Long", description = "미디어 ID (시리즈에서 참조)", example = "1") - Long mediaId, + @Schema(type = "Long", description = "미디어 ID (시리즈에서 참조)", example = "1") Long mediaId, - @Schema(type = "String", description = "썸네일 URL", example = "https://cdn.example.com/thumbnail.jpg") - String thumbnailUrl, + @Schema(type = "String", description = "썸네일 URL", example = "https://cdn.example.com/thumbnail.jpg") String thumbnailUrl, - @Schema(type = "String", description = "시리즈 제목", example = "비밀의 숲") - String title, + @Schema(type = "String", description = "시리즈 제목", example = "비밀의 숲") String title, - @Schema(type = "String", description = "카테고리명", example = "드라마") - String categoryName, + @Schema(type = "String", description = "카테고리명", example = "드라마") String categoryName, - @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") - List tagNameList, + @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") List tagNameList, - @Schema(type = "String", description = "공개 여부", example = "PUBLIC") - PublicStatus publicStatus -) { + @Schema(type = "String", description = "공개 여부", example = "PUBLIC") PublicStatus publicStatus) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java index e01bb3c..c69c0d3 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/OriginMediaTitleListResponse.java @@ -6,13 +6,9 @@ @Schema(description = "원본 콘텐츠 제목 목록 조회 응답") public record OriginMediaTitleListResponse( - @Schema(type = "Long", description = "원본 콘텐츠 ID", example = "1") - Long originId, + @Schema(type = "Long", description = "원본 콘텐츠 ID", example = "1") Long originId, - @Schema(type = "String", description = "원본 콘텐츠 제목", example = "비밀의 숲") - String title, + @Schema(type = "String", description = "원본 콘텐츠 제목", example = "비밀의 숲") String title, - @Schema(type = "String", description = "원본 콘텐츠 타입", example = "SERIES") - MediaType mediaType -) { + @Schema(type = "String", description = "원본 콘텐츠 타입", example = "SERIES") MediaType mediaType) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 32a8dc5..c6c2f2c 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -8,13 +8,13 @@ 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.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 com.ott.domain.member.domain.Role; +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.series.domain.Series; @@ -38,114 +38,121 @@ @RequiredArgsConstructor public class BackOfficeShortFormService { - private final BackOfficeShortFormMapper backOfficeShortFormMapper; - - private final MediaRepository mediaRepository; - private final MediaTagRepository mediaTagRepository; - private final SeriesRepository seriesRepository; - private final ContentsRepository contentsRepository; - private final ShortFormRepository shortFormRepository; - - @Transactional(readOnly = true) - public PageResponse getShortFormList( - Integer page, Integer size, String searchWord, PublicStatus publicStatus, Authentication authentication - ) { - Pageable pageable = PageRequest.of(page, size); - - // 1. 관리자/에디터 여부 확인 - Long memberId = (Long) authentication.getPrincipal(); - boolean isEditor = authentication.getAuthorities().stream() - .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); - Long uploaderId = null; - - // 2. 에디터인 경우 본인이 업로드한 숏폼만 조회 가능 - if (isEditor) - uploaderId = memberId; - - Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( - pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId - ); - - List responseList = mediaPage.getContent().stream() - .map(backOfficeShortFormMapper::toShortFormListResponse) - .toList(); - - PageInfo pageInfo = PageInfo.toPageInfo( - mediaPage.getNumber(), - mediaPage.getTotalPages(), - mediaPage.getSize() - ); - - return PageResponse.toPageResponse(pageInfo, responseList); - } - - @Transactional(readOnly = true) - public PageResponse getOriginMediaTitle(Integer page, Integer size, String searchWord) { - Pageable pageable = PageRequest.of(page, size); - - // 1. Media 페이징 조회 (Series + 단편 Contents / 에피소드 제외) - Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); - - List mediaList = mediaPage.getContent(); - - // 2. mediaId를 타입별로 분리 - List seriesMediaIdList = mediaList.stream() - .filter(m -> m.getMediaType() == MediaType.SERIES) - .map(Media::getId) - .toList(); - - List contentsMediaIdList = mediaList.stream() - .filter(m -> m.getMediaType() == MediaType.CONTENTS) - .map(Media::getId) - .toList(); - - // 3. 일괄 조회: mediaId → entityId 매핑 - Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() - .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); - - Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList).stream() - .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); - - // 4. 응답 매핑 - List responseList = mediaList.stream() - .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, contentsIdByMediaId)) - .toList(); - - PageInfo pageInfo = PageInfo.toPageInfo( - mediaPage.getNumber(), - mediaPage.getTotalPages(), - mediaPage.getSize() - ); - - return PageResponse.toPageResponse(pageInfo, responseList); - } - - @Transactional(readOnly = true) - public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { - // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 조회 - ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) - .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - - // 2. 에디터 - 숏폼 업로더 권한 체크 - Long memberId = (Long) authentication.getPrincipal(); - boolean isEditor = authentication.getAuthorities().stream() - .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); - - Media media = shortForm.getMedia(); - if (isEditor && !media.getUploader().getId().equals(memberId)) - throw new BusinessException(ErrorCode.FORBIDDEN); - - String uploaderNickname = media.getUploader().getNickname(); - - // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 - Optional originMedia = shortForm.findOriginMedia(); - String originMediaTitle = null; - if (originMedia.isPresent()) - originMediaTitle = originMedia.get().getTitle(); - - // 3. 태그 조회 - List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); // 숏폼은 원본 콘텐츠의 태그를 따라가지만, 자체 태그로 생성되어 있음을 상정 - - return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, originMediaTitle, mediaTagList); - } + private final BackOfficeShortFormMapper backOfficeShortFormMapper; + + private final MediaRepository mediaRepository; + private final MediaTagRepository mediaTagRepository; + private final SeriesRepository seriesRepository; + private final ContentsRepository contentsRepository; + private final ShortFormRepository shortFormRepository; + + @Transactional(readOnly = true) + public PageResponse getShortFormList( + Integer page, Integer size, String searchWord, PublicStatus publicStatus, + Authentication authentication) { + Pageable pageable = PageRequest.of(page, size); + + // 1. 관리자/에디터 여부 확인 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + Long uploaderId = null; + + // 2. 에디터인 경우 본인이 업로드한 숏폼만 조회 가능 + if (isEditor) + uploaderId = memberId; + + Page mediaPage = mediaRepository + .findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId( + pageable, MediaType.SHORT_FORM, searchWord, publicStatus, uploaderId); + + List responseList = mediaPage.getContent().stream() + .map(backOfficeShortFormMapper::toShortFormListResponse) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public PageResponse getOriginMediaTitle(Integer page, Integer size, + String searchWord) { + Pageable pageable = PageRequest.of(page, size); + + // 1. Media 페이징 조회 (Series + 단편 Contents / 에피소드 제외) + Page mediaPage = mediaRepository.findOriginMediaListBySearchWord(pageable, searchWord); + + List mediaList = mediaPage.getContent(); + + // 2. mediaId를 타입별로 분리 + List seriesMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.SERIES) + .map(Media::getId) + .toList(); + + List contentsMediaIdList = mediaList.stream() + .filter(m -> m.getMediaType() == MediaType.CONTENTS) + .map(Media::getId) + .toList(); + + // 3. 일괄 조회: mediaId → entityId 매핑 + Map seriesIdByMediaId = seriesRepository.findAllByMediaIdIn(seriesMediaIdList).stream() + .collect(Collectors.toMap(s -> s.getMedia().getId(), Series::getId)); + + Map contentsIdByMediaId = contentsRepository.findAllByMediaIdIn(contentsMediaIdList) + .stream() + .collect(Collectors.toMap(c -> c.getMedia().getId(), Contents::getId)); + + // 4. 응답 매핑 + List responseList = mediaList.stream() + .map(m -> backOfficeShortFormMapper.toOriginMediaTitleListResponse(m, seriesIdByMediaId, + contentsIdByMediaId)) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + mediaPage.getSize()); + + return PageResponse.toPageResponse(pageInfo, responseList); + } + + @Transactional(readOnly = true) + public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication authentication) { + // 1. ShortForm + Media + Uploader + ShortForm.series or ShortForm.contents 한 번에 + // 조회 + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // 2. 에디터 - 숏폼 업로더 권한 체크 + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + + Media media = shortForm.getMedia(); + if (isEditor && !media.getUploader().getId().equals(memberId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + + String uploaderNickname = media.getUploader().getNickname(); + + // 2. 원본 미디어(시리즈 or 콘텐츠) 추출 + Optional originMedia = shortForm.findOriginMedia(); + String originMediaTitle = null; + if (originMedia.isPresent()) + originMediaTitle = originMedia.get().getTitle(); + + // 3. 태그 조회 + List mediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(mediaId); // 숏폼은 원본 + // 콘텐츠의 태그를 + // 따라가지만, 자체 + // 태그로 생성되어 + // 있음을 상정 + + return backOfficeShortFormMapper.toShortFormDetailResponse(shortForm, media, uploaderNickname, + originMediaTitle, mediaTagList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/common/ContentSource.java b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java similarity index 59% rename from modules/domain/src/main/java/com/ott/domain/common/ContentSource.java rename to apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java index 2f212b6..66d078b 100644 --- a/modules/domain/src/main/java/com/ott/domain/common/ContentSource.java +++ b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java @@ -1,4 +1,4 @@ -package com.ott.domain.common; +package com.ott.api_user.common; import lombok.AllArgsConstructor; import lombok.Getter; @@ -6,17 +6,12 @@ @Getter @AllArgsConstructor public enum ContentSource { - // 기본(Default) 맥락 + TRENDING("TRENDING"), // 인기 차트 / 실시간 트렌딩 리스트에서 진입 시 BOOKMARK("BOOKMARK"), // 북마크/시청 중 목록에서 진입 시 - SEARCH("SEARCH"), // 검색 결과에서 진입 시 - - // 특정 진입점 맥락 - TRENDING("TRENDING"), // 인기 차트 / 실시간 트렌딩 리스트에서 진입 + HISTORY("HISTORY"), // 최근 시청 중인 콘텐츠에서 진입 시 TAG("TAG"), // 특정 태그(예: #스릴러) 클릭 시 RECOMMEND("RECOMMEND"), // "OO님이 좋아할 만한 리스트"에서 진입 시 - - // 시리즈 맥락 - SERIES("SERIES"); // 시리즈 상세의 에피소드 리스트에서 클릭하여 진입 시 + SEARCH("SEARCH"); // 검색 결과에서 진입 시 private final String value; } diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java b/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java index 27b8efe..56a2dc4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java +++ b/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java @@ -1,5 +1,41 @@ package com.ott.api_user.common.dto; +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "콘텐츠 리스트 공통 요소") // 홈화면 플레이리스트 요소들(인기차트 , 태그별 리스트, 최근 시청 리스트, 검색 결과 등등 ) public class ContentListElement { + @Schema(description = "미디어 ID", example = "1") + private Long id; + + @Schema(description = "미디어 타입", example = "SERIES") + private MediaType mediaType; + + @Schema(description = "미디어 제목", example = "비밀의 숲") + private String title; + + @Schema(description = "포스터 이미지 URL", example = "https://cdn.ott.com/posters/101.jpg") + private String posterUrl; + + @Schema(description = "가로형 썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + + public static ContentListElement from(Media media) { + return ContentListElement.builder() + .id(media.getId()) + .mediaType(media.getMediaType()) + .title(media.getTitle()) + .posterUrl(media.getPosterUrl()) + .thumbnailUrl(media.getThumbnailUrl()) + .build(); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java index 3bf78c2..5134561 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java @@ -5,11 +5,12 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; +import com.ott.api_user.common.ContentSource; +import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.content.dto.ContentDetailResponse; 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.ContentSource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -23,19 +24,30 @@ @Tag(name = "Contents", description = "콘텐츠(영상) 상세 및 재생 관련 API") public interface ContentApi { - @Operation(summary = "콘텐츠 상세 조회", description = "단편 영화/에피소드의 상세 정보를 조회합니다.(콘텐츠 상세 페이지)") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "콘텐츠 상세 조회 성공", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), - @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) - }) - @GetMapping("/{contentsId}") - ResponseEntity> getContentDetail( - @Parameter(description = "콘텐츠 ID", required = true, example = "1") @PathVariable("contentsId") Long contentsId, - @Parameter(hidden = true) Long memberId); + @Operation(summary = "콘텐츠 상세 조회", description = "단편 영화/에피소드의 상세 정보를 조회합니다.(콘텐츠 상세 페이지)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "콘텐츠 상세 조회 성공", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), + @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/{contentsId}") + ResponseEntity> getContentDetail( + @Parameter(description = "콘텐츠 ID", required = true, example = "1") @PathVariable("contentsId") Long contentsId, + @Parameter(hidden = true) Long memberId); - // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 + // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 + @Operation(summary = "재생 화면 맥락 기반 플레이리스트 조회", description = "재생 화면 하단/우측에 노출되는 맞춤형 추천 리스트를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "플레이리스트 조회 성공") + }) + @GetMapping("/{contentsId}/playlist") + ResponseEntity>> getContentPlayList( + @Parameter(description = "현재 재생 중인 콘텐츠 ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, + @Parameter(description = "진입 맥락 (TRENDING, HISTORY, TAG 등)", example = "TAG") @RequestParam(value = "source", required = false) ContentSource source, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer pageParam, + @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, + @Parameter(hidden = true) Long memberId); - // 댓글 조회 + // 댓글 조회 } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java index bfa3708..1eadb6e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java @@ -4,10 +4,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; 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; +import com.ott.api_user.common.ContentSource; +import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.content.dto.ContentDetailResponse; import com.ott.api_user.content.service.ContentService; +import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import lombok.RequiredArgsConstructor; @@ -27,7 +31,18 @@ public ResponseEntity> getContentDetail( SuccessResponse.of(contentService.getContentDetail(contentsId, memberId))); } - // 플레이 리스트 API + // 플레이 리스트 API (/contents/{contentsId}/playlist?source={SOURCE}) + @Override + public ResponseEntity>> getContentPlayList( + @PathVariable(value = "contentId") Long contentId, + @RequestParam(value = "source", required = false) ContentSource source, + @RequestParam(value = "page") Integer pageParam, + @RequestParam(value = "size") Integer sizeParam, + @AuthenticationPrincipal Long memberId) { + return ResponseEntity.ok( + SuccessResponse + .of(contentService.getContentPlayList(contentId, source, pageParam, sizeParam, memberId))); + } // 댓글 조회 API diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java index b0ab699..cd7f9f8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java @@ -1,12 +1,21 @@ package com.ott.api_user.content.service; +import static com.ott.domain.contents.domain.QContents.*; + import java.util.List; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; - +import com.ott.api_user.common.ContentSource; +import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.content.dto.ContentDetailResponse; 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.bookmark.repository.BookmarkRepository; import com.ott.domain.category.repository.CategoryRepository; import com.ott.domain.common.MediaType; @@ -15,10 +24,10 @@ import com.ott.domain.contents.domain.Contents; import com.ott.domain.contents.repository.ContentsRepository; import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.repository.MediaTagRepository; import com.ott.domain.playback.repository.PlaybackRepository; import com.ott.domain.tag.repository.TagRepository; - import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -26,36 +35,103 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class ContentService { - private final ContentsRepository contentsRepository; - // private final PlaybackRepository playbackRepository; + private final ContentsRepository contentsRepository; + // private final PlaybackRepository playbackRepository; + + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + private final TagRepository tagRepository; + private final CategoryRepository categoryRepository; + + // 재생 상세 + public ContentDetailResponse getContentDetail(Long contentsId, Long memberId) { + Contents contents = contentsRepository.findByIdWithMedia(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Long mediaId = contents.getMedia().getId(); + + List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); + List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); + + Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, + Status.ACTIVE); + Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); + + String masterPlaylistUrl = contents.getMasterPlaylistUrl(); + + MediaType mediaType = MediaType.CONTENTS; // 재생 화면이므로 무조건 CONTENTS로 고정 (시리즈 아님) + + Integer positionSec = 0; + + return ContentDetailResponse.of(mediaType, contents, tags, categories, isBookmarked, isLiked, + masterPlaylistUrl, + positionSec); + + } + + // 해당 콘텐츠를 어디서 진입했는지에 따라 + // 콘텐츠의 재생목록이 달라짐. + public PageResponse getContentPlayList(Long contentsId, ContentSource source, + int page, int size, Long memberId) { + + Contents currentContents = contentsRepository.findByIdWithMedia(contentsId, Status.ACTIVE, + PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + if (currentContents.getSeries() != null) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_FOR_SERIES_PLAYLIST); + } + + Pageable pageable = PageRequest.of(page, size); + ContentSource currentSource = (source != null) ? source : ContentSource.TRENDING; + Long mediaId = currentContents.getMedia().getId(); + + Page playListPage = switch (currentSource) { + // 진입점에 따른 재생 목록 노출 리스트 분기 로직 + case TRENDING -> getTrendingPlaylist(pageable, mediaId); // 1. 북마크 많은 순 + case HISTORY -> getHistoryPlaylist(memberId, pageable, mediaId); // 2. 최근 시청한 순 (신규) + case RECOMMEND -> getRecommendPlaylist(memberId, pageable, mediaId); // 3. OO 님이 좋아하실만한 리스트 + case TAG -> getTagPlaylist(mediaId, pageable); // 4. 같은 태그 가진 영상 + case BOOKMARK -> getBookmarkPlaylist(memberId, pageable, mediaId); // 5. 내 북마크 목록 + case SEARCH -> getRecommendPlaylist(memberId, pageable, mediaId); // 6. 검색 진입은 추천 리스트로 대체 + default -> getTrendingPlaylist(pageable, mediaId); // 기본값은 인기 차트 + }; - private final BookmarkRepository bookmarkRepository; - private final LikesRepository likesRepository; - private final TagRepository tagRepository; - private final CategoryRepository categoryRepository; + List contentList = playListPage.getContent().stream() + .map(ContentListElement::from).collect(Collectors.toList()); - // 재생 상세 - public ContentDetailResponse getContentDetail(Long contentsId, Long memberId) { - Contents contents = contentsRepository.findByIdWithMedia(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + PageInfo pageInfo = PageInfo.builder() + .currentPage(playListPage.getNumber()) + .totalPage(playListPage.getTotalPages()) + .pageSize(playListPage.getSize()) + .build(); - Long mediaId = contents.getMedia().getId(); + return PageResponse.toPageResponse(pageInfo, contentList); + } - List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); - List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); + // 현재는 switch 문을 활용해 직관적 분기 처리를 구현하였지만 + // 새로운 추천 로직이 추가될때마다 서비스코드가 길어질 것을 우려해서 + // Strategy Pattern 을 적용해 객체 지향적 코드로 리팩토링해야함! - Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, - Status.ACTIVE); - Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); + private Page getTrendingPlaylist(Pageable pageable, Long excludeMediaId) { + return Page.empty(); + } - String masterPlaylistUrl = contents.getMasterPlaylistUrl(); - MediaType mediaType = MediaType.CONTENTS; // 재생 화면이므로 무조건 CONTENTS로 고정 (시리즈 아님) + private Page getHistoryPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { + return Page.empty(); + } - Integer positionSec = 0; + private Page getRecommendPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { + return Page.empty(); + } - return ContentDetailResponse.of(mediaType, contents, tags, categories, isBookmarked, isLiked, masterPlaylistUrl, - positionSec); + private Page getTagPlaylist(Long targetMediaId, Pageable pageable) { + // 태그는 '기준 미디어(targetMediaId)'의 태그를 찾으면서, 동시에 해당 미디어를 결과에서 제외해야 함. + return Page.empty(); + } - } + private Page getBookmarkPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { + return Page.empty(); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java index ab1c3bc..f28fb8d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java @@ -27,7 +27,7 @@ public class SeriesDetailResponse { @Schema(description = "출연진", example = "송혜교, 이도현, 임지연") private String actors; - @Schema(description = "썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + @Schema(description = "가로형 썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") private String thumbnailUrl; @Schema(description = "카테고리", example = "드라마") diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index fb7c39a..fe0698d 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -45,10 +45,8 @@ public enum ErrorCode { CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), - INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다") - - - ; + INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), + INVALID_REQUEST_FOR_SERIES_PLAYLIST(HttpStatus.BAD_REQUEST, "B005", "해당 콘텐츠는 시리즈 전용 API를 사용해주세요"); private final HttpStatus status; private final String code; diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index 87c0069..be6bab8 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -10,9 +10,11 @@ public interface MediaRepositoryCustom { Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); - Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus); + Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, + String searchWord, PublicStatus publicStatus); - Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); + Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, + MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 4e5d643..6d2b249 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -22,139 +22,133 @@ @RequiredArgsConstructor public class MediaRepositoryImpl implements MediaRepositoryCustom { - private final JPAQueryFactory queryFactory; - - @Override - public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord) { - List mediaList = queryFactory - .selectFrom(media) - .where( - media.mediaType.eq(mediaType), - titleContains(searchWord) - ) - .orderBy(media.createdDate.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - JPAQuery countQuery = queryFactory - .select(media.count()) - .from(media) - .where( - media.mediaType.eq(mediaType), - titleContains(searchWord) - ); - - return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); - } - - @Override - public Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus) { - List mediaList = queryFactory - .selectFrom(media) - .where( - mediaTypeEq(mediaType), - titleContains(searchWord), - publicStatusEq(publicStatus) - ) - .orderBy(media.createdDate.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - JPAQuery countQuery = queryFactory - .select(media.count()) - .from(media) - .where( - mediaTypeEq(mediaType), - titleContains(searchWord), - publicStatusEq(publicStatus) - ); - - return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); - } - - @Override - public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId) { - List mediaList = queryFactory - .selectFrom(media) - .where( - mediaTypeEq(mediaType), - titleContains(searchWord), - publicStatusEq(publicStatus), - uploaderIdEq(uploaderId) - ) - .orderBy(media.createdDate.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - JPAQuery countQuery = queryFactory - .select(media.count()) - .from(media) - .where( - mediaTypeEq(mediaType), - titleContains(searchWord), - publicStatusEq(publicStatus), - uploaderIdEq(uploaderId) - ); - - return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); - } - - @Override - public Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord) { - BooleanExpression condition = media.mediaType.in(List.of(MediaType.SERIES, MediaType.CONTENTS)) - .and( - JPAExpressions.selectOne() - .from(contents) + private final JPAQueryFactory queryFactory; + + @Override + public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, + String searchWord) { + List mediaList = queryFactory + .selectFrom(media) + .where( + media.mediaType.eq(mediaType), + titleContains(searchWord)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + media.mediaType.eq(mediaType), + titleContains(searchWord)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, + String searchWord, PublicStatus publicStatus) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, + MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId) { + List mediaList = queryFactory + .selectFrom(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + mediaTypeEq(mediaType), + titleContains(searchWord), + publicStatusEq(publicStatus), + uploaderIdEq(uploaderId)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + @Override + public Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord) { + BooleanExpression condition = media.mediaType.in(List.of(MediaType.SERIES, MediaType.CONTENTS)) + .and( + JPAExpressions.selectOne() + .from(contents) + .where( + contents.media.id.eq(media.id), + contents.series.isNotNull()) + .notExists()); + + List mediaList = queryFactory + .selectFrom(media) .where( - contents.media.id.eq(media.id), - contents.series.isNotNull() - ) - .notExists() - ); - - List mediaList = queryFactory - .selectFrom(media) - .where( - condition, - titleContains(searchWord) - ) - .orderBy(media.createdDate.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - JPAQuery countQuery = queryFactory - .select(media.count()) - .from(media) - .where(condition, titleContains(searchWord)); - - return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); - } - - private BooleanExpression titleContains(String searchWord) { - if (StringUtils.hasText(searchWord)) - return media.title.contains(searchWord); - return null; - } - - private BooleanExpression mediaTypeEq(MediaType mediaType) { - if (mediaType != null) - return media.mediaType.eq(mediaType); - return null; - } - - private BooleanExpression publicStatusEq(PublicStatus publicStatus) { - if (publicStatus != null) - return media.publicStatus.eq(publicStatus); - return null; - } - - private BooleanExpression uploaderIdEq(Long uploaderId) { - if (uploaderId != null) - return media.uploader.id.eq(uploaderId); - return null; - } + condition, + titleContains(searchWord)) + .orderBy(media.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where(condition, titleContains(searchWord)); + + return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); + } + + private BooleanExpression titleContains(String searchWord) { + if (StringUtils.hasText(searchWord)) + return media.title.contains(searchWord); + return null; + } + + private BooleanExpression mediaTypeEq(MediaType mediaType) { + if (mediaType != null) + return media.mediaType.eq(mediaType); + return null; + } + + private BooleanExpression publicStatusEq(PublicStatus publicStatus) { + if (publicStatus != null) + return media.publicStatus.eq(publicStatus); + return null; + } + + private BooleanExpression uploaderIdEq(Long uploaderId) { + if (uploaderId != null) + return media.uploader.id.eq(uploaderId); + return null; + } } From 7afb0852531135234f480e109543af8a6db45910 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 16:37:04 +0900 Subject: [PATCH 116/257] =?UTF-8?q?[FIX]:=20BOM=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=9E=90=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_admin/upload/support/UploadHelper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java index bb29bed..638a609 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -1,4 +1,4 @@ -package com.ott.api_admin.upload.support; +package com.ott.api_admin.upload.support; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; From d4208b19d0d1e1de11828b73d1b8dccdaae36d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 16:54:32 +0900 Subject: [PATCH 117/257] =?UTF-8?q?[BUILD]:=20RabbitMQ=20docker=20compose?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 6 ++-- docker-compose.yml | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 699fe16..248c648 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -27,8 +27,8 @@ spring: format_sql: true rabbitmq: - host: ${RABBITMQ_HOST} - port: ${RABBITMQ_PORT} + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} username: ${RABBITMQ_USERNAME} password: ${RABBITMQ_PASSWORD} @@ -44,4 +44,4 @@ transcoder: storage: provider: ${STORAGE_PROVIDER} local: - output-dir: ${STORAGE_LOCAL_OUTPUT_DIR} + output-dir: ${STORAGE_OUTPUT_DIR} diff --git a/docker-compose.yml b/docker-compose.yml index f67b9a8..9bd9ec2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,22 @@ services: api-user: condition: service_healthy + # ============ Message Queue ============ + rabbitmq: + image: rabbitmq:3-management + container_name: ott-rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + healthcheck: + test: [ "CMD-SHELL", "rabbitmq-diagnostics -q ping" ] + interval: 10s + timeout: 5s + retries: 12 + # ============ Transcoder 워커 ============ transcoder: build: @@ -89,11 +105,26 @@ services: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + + RABBITMQ_HOST: ${RABBITMQ_HOST} + RABBITMQ_PORT: ${RABBITMQ_PORT} + RABBITMQ_USERNAME: ${RABBITMQ_USERNAME} + RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD} + + TRANSCODER_MESSAGING_PROVIDER: ${TRANSCODER_MESSAGING_PROVIDER} + TRANSCODER_FFMPEG_ENGINE: ${TRANSCODER_FFMPEG_ENGINE} + FFMPEG_PATH: ${FFMPEG_PATH} + TRANSCODER_TEMP_DIR: ${TRANSCODER_TEMP_DIR} + TRANSCODER_SEGMENT_DURATION: ${TRANSCODER_SEGMENT_DURATION} + STORAGE_PROVIDER: ${STORAGE_PROVIDER} + STORAGE_LOCAL_OUTPUT_DIR: ${STORAGE_OUTPUT_DIR} depends_on: mysql: condition: service_healthy api-user: condition: service_healthy + rabbitmq: + condition: service_healthy volumes: mysql-data: From b6d14a248070841c9196e4c6c7472a47978b9421 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 19:28:17 +0900 Subject: [PATCH 118/257] =?UTF-8?q?[FEAT]:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=95=84=EC=9D=B4=EB=94=94=201=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=20=EA=B0=92=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/category/controller/CategoryController.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java index a2f9cd3..0543c9c 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java @@ -4,8 +4,10 @@ import com.ott.api_user.category.service.CategoryService; import com.ott.api_user.tag.dto.response.TagResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -14,6 +16,7 @@ import java.util.List; @RestController +@Validated @RequestMapping("/categories") @RequiredArgsConstructor public class CategoryController implements CategoryApi { @@ -27,8 +30,8 @@ public ResponseEntity>> getCategories() { @GetMapping("{categoryId}/tags") public ResponseEntity>> getTagsByCategory( - @PathVariable Long categoryId + @Positive @PathVariable Long categoryId ) { return ResponseEntity.ok(SuccessResponse.of(categoryService.getTagsByCategory(categoryId))); } -} +} \ No newline at end of file From e9c3904f4058d23c62affa9bb69d10947a21ad67 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 25 Feb 2026 19:28:43 +0900 Subject: [PATCH 119/257] =?UTF-8?q?[FEAT]:=20PATCH=EC=8B=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=95=84=EC=9D=B4=EB=94=94=20=EA=B3=B5=EB=B0=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/member/dto/request/UpdateMemberRequest.java | 2 -- .../java/com/ott/api_user/member/service/MemberService.java | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java index c4f74df..1ee9c0b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/request/UpdateMemberRequest.java @@ -13,8 +13,6 @@ @Schema(description = "내 정보 수정 요청 DTO") public class UpdateMemberRequest { - @NotNull - @NotEmpty @Schema(type = "String", example = "김마루1", description = "변경할 닉네임 / null인 경우 변경 x") private String nickname; diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 998cdb5..f35df85 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -50,8 +50,11 @@ public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { Member findMember = memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - // 닉네임 변경 (null이면??) -> 논의 필요 + // 닉네임 변경 if (request.getNickname() != null) { + if (request.getNickname().isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "공백은 입력할 수 없습니다"); + } findMember.updateNickname(request.getNickname()); } From 444b990e9c5bb35efe581947e7b57d932756f88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 19:32:27 +0900 Subject: [PATCH 120/257] =?UTF-8?q?[FEAT]:=20ffmpeg=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../queue/rabbit/RabbitTranscodeListener.java | 7 ++++++- .../processbuilder/ProcessBuilderFfmpegExecutor.java | 12 +++++++++++- docker-compose.yml | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java index b4fb3dc..97e0cfa 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java @@ -30,6 +30,11 @@ public class RabbitTranscodeListener implements MessageListener { @RabbitListener(queues = RabbitConfig.QUEUE_NAME) public void listen(TranscodeMessage message) { log.info("트랜스코딩 요청 수신 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); - commandPipeline.execute(message.mediaId(), message.originUrl()); + try { + commandPipeline.execute(message.mediaId(), message.originUrl()); + } catch (Exception e) { + // 예외를 삼켜 requeue를 방지 / DLQ 구성 후 AmqpRejectAndDontRequeueException으로 교체 + log.error("트랜스코딩 처리 실패, 메시지 폐기 - mediaId: {}", message.mediaId(), e); + } } } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java index e037306..d3237b4 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -101,7 +101,17 @@ public Path execute(Path inputFile, Path outputDir, Resolution resolution) throw } } - int exitCode = process.waitFor(); +// int exitCode = process.waitFor(); +// if (exitCode != 0) { +// throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); +// } + + boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolution.getKey()); + } + int exitCode = process.exitValue(); if (exitCode != 0) { throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); } diff --git a/docker-compose.yml b/docker-compose.yml index 9bd9ec2..3cca0ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,7 +117,7 @@ services: TRANSCODER_TEMP_DIR: ${TRANSCODER_TEMP_DIR} TRANSCODER_SEGMENT_DURATION: ${TRANSCODER_SEGMENT_DURATION} STORAGE_PROVIDER: ${STORAGE_PROVIDER} - STORAGE_LOCAL_OUTPUT_DIR: ${STORAGE_OUTPUT_DIR} + STORAGE_OUTPUT_DIR: ${STORAGE_OUTPUT_DIR} depends_on: mysql: condition: service_healthy From e97e646d37856e7c50e8b7db418d3d026e256bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Wed, 25 Feb 2026 19:33:00 +0900 Subject: [PATCH 121/257] =?UTF-8?q?[DOCS]:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../processbuilder/ProcessBuilderFfmpegExecutor.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java index d3237b4..4ef1dc5 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -101,11 +101,6 @@ public Path execute(Path inputFile, Path outputDir, Resolution resolution) throw } } -// int exitCode = process.waitFor(); -// if (exitCode != 0) { -// throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); -// } - boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); if (!finished) { process.destroyForcibly(); From 788bae1795e314c02c1072a3fc249ffd673762d6 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:26:39 +0900 Subject: [PATCH 122/257] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=AA=BB=EC=B0=BE?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ContentsDetailResponse.java | 30 +++++------ .../dto/response/ContentsListResponse.java | 2 +- .../content/controller/ContentController.java | 51 ++++++++++--------- .../content/service/ContentService.java | 3 -- .../api_user/series/controller/SeriesApi.java | 39 +++++++++++--- .../series/service/SeriesService.java | 25 +++++++-- 6 files changed, 95 insertions(+), 55 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java index 4b853b5..461294e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsDetailResponse.java @@ -10,33 +10,33 @@ @Schema(description = "콘텐츠 상세 조회 응답") public record ContentsDetailResponse( - @Schema(type = "Long", description = "콘텐츠 ID", example = "1") Long contentsId, + @Schema(type = "Long", description = "콘텐츠 ID", example = "1") Long contentsId, - @Schema(type = "String", description = "포스터 URL", example = "https://cdn.example.com/poster.jpg") String posterUrl, + @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 = "썸네일 URL", example = "https://cdn.example.com/thumb.jpg") String thumbnailUrl, - @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") String title, + @Schema(type = "String", description = "콘텐츠 제목", example = "기생충") String title, - @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") String description, + @Schema(type = "String", description = "콘텐츠 설명", example = "봉준호 감독의 블랙코미디 스릴러") String description, - @Schema(type = "String", description = "출연진", example = "송강호, 이선균") String actors, + @Schema(type = "String", description = "출연진", example = "송강호, 이선균") String actors, - @Schema(type = "String", description = "소속 시리즈 제목 (없으면 null)", example = "비밀의 숲") String seriesTitle, + @Schema(type = "String", description = "소속 시리즈 제목 (없으면 null)", example = "비밀의 숲") String seriesTitle, - @Schema(type = "String", description = "업로더 닉네임", example = "관리자") String uploaderNickname, + @Schema(type = "String", description = "업로더 닉네임", example = "관리자") String uploaderNickname, - @Schema(type = "Integer", description = "영상 길이(초)", example = "7200") Integer duration, + @Schema(type = "Integer", description = "영상 길이(초)", example = "7200") Integer duration, - @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") Integer videoSize, + @Schema(type = "Integer", description = "영상 크기(KB)", example = "1048576") Integer videoSize, - @Schema(type = "String", description = "카테고리명", example = "드라마") String categoryName, + @Schema(type = "String", description = "카테고리명", example = "드라마") String categoryName, - @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") List tagNameList, + @Schema(type = "List", description = "태그 이름 목록", example = "[\"스릴러\", \"추리\"]") List tagNameList, - @Schema(type = "String", description = "공개 여부", example = "PUBLIC") PublicStatus publicStatus, + @Schema(type = "String", description = "공개 여부", example = "PUBLIC") PublicStatus publicStatus, - @Schema(type = "Long", description = "북마크 수", example = "150") Long bookmarkCount, + @Schema(type = "Long", description = "북마크 수", example = "150") Long bookmarkCount, - @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") LocalDate uploadedDate) { + @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") LocalDate uploadedDate) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java index 4382fbc..4a02179 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsListResponse.java @@ -19,4 +19,4 @@ public record ContentsListResponse( @Schema(type = "String", description = "공개 여부", example = "PUBLIC") PublicStatus publicStatus, @Schema(type = "LocalDate", description = "업로드일", example = "2026-01-15") LocalDate uploadedDate) { -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java index 1eadb6e..8ab8111 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java @@ -20,30 +20,31 @@ @RequiredArgsConstructor @RequestMapping("/contents") public class ContentController implements ContentApi { - private final ContentService contentService; - - @Override - public ResponseEntity> getContentDetail( - @PathVariable(value = "contentsId") Long contentsId, - @AuthenticationPrincipal Long memberId) { - - return ResponseEntity.ok( - SuccessResponse.of(contentService.getContentDetail(contentsId, memberId))); - } - - // 플레이 리스트 API (/contents/{contentsId}/playlist?source={SOURCE}) - @Override - public ResponseEntity>> getContentPlayList( - @PathVariable(value = "contentId") Long contentId, - @RequestParam(value = "source", required = false) ContentSource source, - @RequestParam(value = "page") Integer pageParam, - @RequestParam(value = "size") Integer sizeParam, - @AuthenticationPrincipal Long memberId) { - return ResponseEntity.ok( - SuccessResponse - .of(contentService.getContentPlayList(contentId, source, pageParam, sizeParam, memberId))); - } - - // 댓글 조회 API + private final ContentService contentService; + + @Override + public ResponseEntity> getContentDetail( + @PathVariable(value = "contentsId") Long contentsId, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok( + SuccessResponse.of(contentService.getContentDetail(contentsId, memberId))); + } + + // 플레이 리스트 API (/contents/{contentsId}/playlist?source={SOURCE}) + @Override + public ResponseEntity>> getContentPlayList( + @PathVariable(value = "contentsId") Long contentId, + @RequestParam(value = "source", required = false) ContentSource source, + @RequestParam(value = "page") Integer pageParam, + @RequestParam(value = "size") Integer sizeParam, + @AuthenticationPrincipal Long memberId) { + return ResponseEntity.ok( + SuccessResponse + .of(contentService.getContentPlayList(contentId, source, pageParam, + sizeParam, memberId))); + } + + // 댓글 조회 API } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java index cd7f9f8..c445570 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java @@ -1,10 +1,7 @@ package com.ott.api_user.content.service; -import static com.ott.domain.contents.domain.QContents.*; - import java.util.List; import java.util.stream.Collectors; - import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index 7a93eb2..e031d6d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -38,16 +38,39 @@ ResponseEntity> getSeriesDetail( @Operation(summary = "시리즈 콘텐츠 목록 조회", description = "특정 시리즈에 속한 콘텐츠(에피소드) 목록을 페이징하여 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "시리즈 콘텐츠 목록 조회 성공", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) + }), + + @ApiResponse(responseCode = "400", description = "요청 파라미터 오류 (page/size 누락 또는 형식 오류)", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), + + @ApiResponse(responseCode = "401", description = "인증 실패 (토큰 누락/만료/유효하지 않음)", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), + + @ApiResponse(responseCode = "403", description = "접근 권한 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), + @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), + + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }) }) + @GetMapping("/{seriesId}/contents") ResponseEntity>> getSeriesContents( - @Parameter(description = "시리즈 ID", required = true) @PathVariable("seriesId") Long seriesId, - @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(defaultValue = "0")) @RequestParam("page") Integer page, - @Parameter(description = "페이지 크기", schema = @Schema(defaultValue = "24")) @RequestParam("size") Integer size, - @Parameter(hidden = true) Long memberId // 토큰에서 추출 (스웨거에서는 숨김) - ); - // 이어보기 지점 추가 + + @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("seriesId") Long seriesId, + + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page, + + @Parameter(description = "페이지 크기", example = "24") @RequestParam(defaultValue = "24") Integer size, + + @Parameter(hidden = true) Long memberId); + // 추후 이어보기 지점 추가 } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index 6cdb0a1..01f2327 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -68,14 +68,33 @@ public PageResponse getSeriesContents(Long seriesId, int .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Pageable pageable = PageRequest.of(page, size); - + + System.out.println("🚨 1. DB 쿼리 실행 직전!"); // <--- 추가 + Page contentsPage = contentsRepository .findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc( seriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); + System.out.println("🚨 2. DB 조회 완료! (결과 개수: " + contentsPage.getContent().size() + ")"); // <--- 추가 - List contentsList = contentsPage.getContent().stream() - .map(SeriesContentsResponse::from).collect(Collectors.toList()); + // List contentsList = contentsPage.getContent().stream() + // .map(SeriesContentsResponse::from).collect(Collectors.toList()); + + + // ======== 여기서부터 교체 ======== + List contentsList = new java.util.ArrayList<>(); + try { + for (Contents content : contentsPage.getContent()) { + System.out.println("👉 변환 시도 중인 에피소드 ID: " + content.getId()); + contentsList.add(SeriesContentsResponse.from(content)); + System.out.println("✅ 에피소드 ID: " + content.getId() + " 변환 성공!"); + } + } catch (Exception e) { + System.out.println("💥💥💥 대참사 발생! 범인은 바로 아래 에러입니다 💥💥💥"); + e.printStackTrace(); // <--- 이게 에러의 진짜 원인을 콘솔에 뱉어줍니다! + } + // ======== 여기까지 교체 ======== + System.out.println("🚨 3. DTO 변환 및 리턴 직전!"); // <--- 추가 PageInfo pageInfo = PageInfo.builder() .currentPage(contentsPage.getNumber()) .totalPage(contentsPage.getTotalPages()) From 6f502ca1a1a83cd74c9be406645167cac161e099 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 08:38:16 +0900 Subject: [PATCH 123/257] =?UTF-8?q?[DELETE]:=20gitKeep=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_user/bookmark/controller/.gitkeep | 0 .../api-user/src/main/java/com/ott/api_user/bookmark/dto/.gitkeep | 0 .../src/main/java/com/ott/api_user/bookmark/service/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/service/.gitkeep diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/.gitkeep deleted file mode 100644 index e69de29..0000000 From 5b4c0db6e6a94198d22b28fcf95f9703fe494273 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 08:39:48 +0900 Subject: [PATCH 124/257] =?UTF-8?q?[FEAT]:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 36 ++++++++++ .../bookmark/dto/request/BookmarkRequest.java | 17 +++++ .../bookmark/service/BookmarkService.java | 66 +++++++++++++++++++ .../ott/common/web/exception/ErrorCode.java | 6 +- .../repository/BookmarkRepository.java | 4 ++ .../com/ott/domain/media/domain/Media.java | 12 ++++ 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java new file mode 100644 index 0000000..e35bfeb --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -0,0 +1,36 @@ +package com.ott.api_user.bookmark.controller; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.bookmark.dto.request.BookmarkRequest; +import com.ott.api_user.bookmark.service.BookmarkService; +import com.ott.common.web.response.SuccessResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/bookmarks") +public class BookmarkController implements BookmarkAPI { + + private final BookmarkService bookmarkService; + + // 북마크 수정 + @Override + public ResponseEntity> editBookmark( + @Valid @RequestBody BookmarkRequest request, + @AuthenticationPrincipal Long memberId) { + + bookmarkService.editBookmark(memberId, request.getMediaId()); + return ResponseEntity.ok(SuccessResponse.of(null)); + } + +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java new file mode 100644 index 0000000..40785f3 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java @@ -0,0 +1,17 @@ +package com.ott.api_user.bookmark.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "북마크 등록/취소 요청 DTO") +public class BookmarkRequest { + + @NotNull(message = "mediaId는 필수입니다.") + @Schema(description = "북마크할 미디어 ID", example = "1") + private Long mediaId; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java new file mode 100644 index 0000000..f782a6c --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -0,0 +1,66 @@ +package com.ott.api_user.bookmark.service; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.common.Status; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private final MemberRepository memberRepository; + private final MediaRepository mediaRepository; + + /** + * 북마크 수정 + * 해당 유저의 ACTIVE 북마크가 있을 경우 -> 상태 DELETE + 카운트 감소 + * 해당 유저의 media의 북마크가 없을 경우 -> insert 상태 ACTIVE + 카운트 증가 + * 해당 유저의 media의 북마크가 있는데 DELETE 일 경우 -> ACTIVE 변경 + 카운트 증가 + */ + public void editBookmark(Long memberId, Long mediaId) { + + Media findMedia = mediaRepository.findById(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); + + // 해당 유저가 해당 미디어에 대해서 북마크를 했는지 여부 체크 + bookmarkRepository.findByMemberIdAndMediaId(memberId, mediaId) + .ifPresentOrElse( + bookmark -> { + // 이미 해당 미디어에 대해서 북마크한 경우 -> DELETE 변경 이후 + 카운트 감소 + if (bookmark.getStatus() == Status.ACTIVE) { + bookmark.updateStatus(Status.DELETE); + findMedia.decreaseBookmarkCount(); + } else { + // 해당 미디어에 대해 북마크를 안한 경우 -> ACTIVE 변경 이후 + 카운트 증가 + bookmark.updateStatus(Status.ACTIVE); + findMedia.increaseBookmarkCount(); + } + }, + () -> { + // 행 자체가 없음 -> 신규임 -> insert이후 상태 ACTIVE + 카운트 증가 + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + bookmarkRepository.save(Bookmark.builder() + .member(findMember) + .media(findMedia) + .build()); // 상태 default가 ACTIVE임 + + findMedia.increaseBookmarkCount(); + } + ); + } +} diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index fb7c39a..c457e9f 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -45,8 +45,10 @@ public enum ErrorCode { CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), - INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다") - + INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), + MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "미디어를 찾을 수 없습니다"), + BOOKMARK_ALREADY_EXISTS(HttpStatus.CONFLICT, "B006", "이미 북마크한 미디어입니다"), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B007", "북마크를 찾을 수 없습니다") ; diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index 8ebf8c5..cd26523 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -5,6 +5,10 @@ import com.ott.domain.bookmark.domain.Bookmark; import com.ott.domain.common.Status; +import java.util.Optional; + public interface BookmarkRepository extends JpaRepository { boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + + Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index 6204067..9555f9b 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -69,4 +69,16 @@ public void updateImageKeys(String posterUrl, String thumbnailUrl) { this.posterUrl = posterUrl; this.thumbnailUrl = thumbnailUrl; } + + // 북마크 증가 메소드 + public void increaseBookmarkCount() { + this.bookmarkCount++; + } + + // 북마크 감소 메소드 + public void decreaseBookmarkCount() { + if (this.bookmarkCount > 0) { + this.bookmarkCount--; + } + } } From 798114a376c9b99b44c2a8a5a87fc42b7c37014b Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 10:38:03 +0900 Subject: [PATCH 125/257] =?UTF-8?q?[FEAT]:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 18 ++++++++-- .../dto/response/BookmarkMediaResponse.java | 33 ++++++++++++++++++ .../bookmark/service/BookmarkService.java | 34 +++++++++++++++++-- .../repository/BookmarkRepository.java | 7 ++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index e35bfeb..492d54d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -1,12 +1,13 @@ package com.ott.api_user.bookmark.controller; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; +import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.common.web.response.PageResponse; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.ott.api_user.bookmark.dto.request.BookmarkRequest; @@ -16,6 +17,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/bookmarks") @@ -33,4 +35,14 @@ public ResponseEntity> editBookmark( return ResponseEntity.ok(SuccessResponse.of(null)); } + // 북마크 리스트 조회 + @Override + public ResponseEntity>> getBookmarkMediaList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of( + bookmarkService.getBookmarkMediaList(memberId, page, size))); + } } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java new file mode 100644 index 0000000..075af44 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java @@ -0,0 +1,33 @@ +package com.ott.api_user.bookmark.dto.response; + +import com.ott.domain.bookmark.domain.Bookmark; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "북마크한 미디어 목록 응답 DTO") +public class BookmarkMediaResponse { + + @Schema(description = "미디어 ID", example = "1") + private Long mediaId; + + @Schema(description = "미디어 제목", example = "어서와요 김마루의 숲") + private String title; + + @Schema(description = "미디어 설명", example = "허거덩의 숲에서 힐링을 즐겨봐요~") + private String description; + + @Schema(description = "포스터 URL", example = "https://cdn.ott.com/posters/1.jpg") + private String posterUrl; + + public static BookmarkMediaResponse from(Bookmark bookmark) { + return BookmarkMediaResponse.builder() + .mediaId(bookmark.getMedia().getId()) + .title(bookmark.getMedia().getTitle()) + .description(bookmark.getMedia().getDescription()) + .posterUrl(bookmark.getMedia().getPosterUrl()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index f782a6c..aa096a3 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -1,7 +1,10 @@ package com.ott.api_user.bookmark.service; +import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; 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.bookmark.domain.Bookmark; import com.ott.domain.bookmark.repository.BookmarkRepository; import com.ott.domain.common.Status; @@ -9,12 +12,15 @@ import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.member.domain.Member; import com.ott.domain.member.repository.MemberRepository; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; 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; + @Service @RequiredArgsConstructor @Transactional @@ -63,4 +69,28 @@ public void editBookmark(Long memberId, Long mediaId) { } ); } + + @Transactional(readOnly = true) + public PageResponse getBookmarkMediaList(Long memberId, int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + + // 해당 유저의 ACTIVE 북마크 목록 페이징 조회 (fetch Join) + Page bookmarkPage = + bookmarkRepository.findByMemberIdAndStatus(memberId, Status.ACTIVE, pageable); + + // Bookmark -> DTO로 변환 + List dataList = bookmarkPage.getContent().stream() + .map(BookmarkMediaResponse::from) + .toList(); + + // pageInfo 생성 + PageInfo pageInfo = PageInfo.toPageInfo( + bookmarkPage.getNumber(), + bookmarkPage.getTotalPages(), + bookmarkPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, dataList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index cd26523..13ae14b 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -1,5 +1,8 @@ package com.ott.domain.bookmark.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import com.ott.domain.bookmark.domain.Bookmark; @@ -11,4 +14,8 @@ public interface BookmarkRepository extends JpaRepository { boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); + + @EntityGraph(attributePaths = {"media"}) + Page findByMemberIdAndStatus(Long memberId, Status status, Pageable pageable); + } From 15bf5cdb7a60b35a2bfc3986d6c70c91e6eb12d4 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 11:04:41 +0900 Subject: [PATCH 126/257] =?UTF-8?q?[FEAT]:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EC=BD=98=ED=85=90=EC=B8=A0=EB=A7=8C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookmark/dto/response/BookmarkMediaResponse.java | 2 +- .../ott/api_user/bookmark/service/BookmarkService.java | 10 ++++++++-- .../domain/bookmark/repository/BookmarkRepository.java | 7 +++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java index 075af44..1eae322 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java @@ -7,7 +7,7 @@ @Getter @Builder -@Schema(description = "북마크한 미디어 목록 응답 DTO") +@Schema(description = "북마크한 콘텐츠 목록 응답 DTO") public class BookmarkMediaResponse { @Schema(description = "미디어 ID", example = "1") diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index aa096a3..03587b6 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -7,6 +7,7 @@ import com.ott.common.web.response.PageResponse; import com.ott.domain.bookmark.domain.Bookmark; import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.common.MediaType; import com.ott.domain.common.Status; import com.ott.domain.media.domain.Media; import com.ott.domain.media.repository.MediaRepository; @@ -75,9 +76,14 @@ public PageResponse getBookmarkMediaList(Long memberId, i Pageable pageable = PageRequest.of(page, size); - // 해당 유저의 ACTIVE 북마크 목록 페이징 조회 (fetch Join) + // 해당 유저의 ACTIVE인 북마크한 콘텐츠 OR 시리즈 타입 목록 페이징 조회 (fetch Join) Page bookmarkPage = - bookmarkRepository.findByMemberIdAndStatus(memberId, Status.ACTIVE, pageable); + bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaTypeIn( + memberId, + Status.ACTIVE, + List.of(MediaType.CONTENTS, MediaType.SERIES), + pageable + ); // Bookmark -> DTO로 변환 List dataList = bookmarkPage.getContent().stream() diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index 13ae14b..adac1f6 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -1,5 +1,6 @@ package com.ott.domain.bookmark.repository; +import com.ott.domain.common.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; @@ -8,6 +9,7 @@ import com.ott.domain.bookmark.domain.Bookmark; import com.ott.domain.common.Status; +import java.util.List; import java.util.Optional; public interface BookmarkRepository extends JpaRepository { @@ -15,7 +17,8 @@ public interface BookmarkRepository extends JpaRepository { Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); - @EntityGraph(attributePaths = {"media"}) - Page findByMemberIdAndStatus(Long memberId, Status status, Pageable pageable); + // 콘텐츠 목록 조회 (CONTENTS, SERIES) + Page findByMemberIdAndStatusAndMedia_MediaTypeIn( + Long memberId, Status status, List mediaTypes, Pageable pageable); } From ab43183d87cb9e3ef3ec1afb9dcc763f41067738 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 11:13:24 +0900 Subject: [PATCH 127/257] =?UTF-8?q?[OT-125][FEATURE]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20API=20=ED=83=9C=EA=B7=B8/=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=88=8F?= =?UTF-8?q?=ED=8F=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ContentsUploadRequest.java | 11 +++ .../service/BackOfficeContentsService.java | 12 ++- .../dto/request/SeriesUploadRequest.java | 11 +++ .../service/BackOfficeSeriesService.java | 6 +- .../dto/request/ShortFormUploadRequest.java | 2 +- .../service/BackOfficeShortFormService.java | 38 ++++++++- .../upload/support/MediaTagLinker.java | 77 +++++++++++++++++++ .../repository/CategoryRepository.java | 2 + .../domain/tag/repository/TagRepository.java | 4 +- 9 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index 126fcff..c74faf1 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -3,9 +3,12 @@ import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; + @Schema(type = "Object", description = "콘텐츠 업로드 요청") public record ContentsUploadRequest( @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") @@ -27,6 +30,14 @@ public record ContentsUploadRequest( @NotNull PublicStatus publicStatus, + @Schema(type = "String", description = "카테고리명", example = "드라마") + @NotBlank + String categoryName, + + @Schema(type = "List", description = "태그명 목록", example = "[\"가족\", \"코미디\"]") + @NotEmpty + List<@NotBlank String> tagNameList, + @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") @PositiveOrZero Integer duration, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index 5a9d077..a97d480 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -5,6 +5,7 @@ import com.ott.api_admin.content.dto.response.ContentsListResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; +import com.ott.api_admin.upload.support.MediaTagLinker; import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -43,13 +44,19 @@ public class BackOfficeContentsService { private final SeriesRepository seriesRepository; private final S3PresignService s3PresignService; private final UploadHelper uploadHelper; + private final MediaTagLinker mediaTagLinker; @Transactional(readOnly = true) public PageResponse getContents(int page, int size, String searchWord, PublicStatus publicStatus) { Pageable pageable = PageRequest.of(page, size); // 미디어 중 콘텐츠 대상 페이징 - Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatus(pageable, MediaType.CONTENTS, searchWord, publicStatus); + Page mediaPage = mediaRepository.findMediaListByMediaTypeAndSearchWordAndPublicStatus( + pageable, + MediaType.CONTENTS, + searchWord, + publicStatus + ); List responseList = mediaPage.getContent().stream() .map(backOfficeContentsMapper::toContentsListResponse) @@ -65,7 +72,6 @@ public PageResponse getContents(int page, int size, String @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)); @@ -143,6 +149,8 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request s3PresignService.toObjectUrl(masterPlaylistObjectKey) ); + mediaTagLinker.linkTags(media, request.categoryName(), request.tagNameList()); + return backOfficeContentsMapper.toContentsUploadResponse( contentsId, posterObjectKey, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java index 065aedd..9dc53c9 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -3,8 +3,11 @@ import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import java.util.List; + @Schema(type = "Object", description = "시리즈 업로드 요청") public record SeriesUploadRequest( @Schema(type = "String", description = "시리즈 제목", example = "응답하라 1988") @@ -23,6 +26,14 @@ public record SeriesUploadRequest( @NotNull PublicStatus publicStatus, + @Schema(type = "String", description = "카테고리명", example = "드라마") + @NotBlank + String categoryName, + + @Schema(type = "List", description = "태그명 목록", example = "[\"가족\", \"코미디\"]") + @NotEmpty + List<@NotBlank String> tagNameList, + @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") @NotBlank String posterFileName, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index cad6114..118e05e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -6,6 +6,7 @@ import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; +import com.ott.api_admin.upload.support.MediaTagLinker; import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -28,9 +29,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Collections; +import java.util.stream.Collectors; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; @RequiredArgsConstructor @Service @@ -43,6 +44,7 @@ public class BackOfficeSeriesService { private final SeriesRepository seriesRepository; private final S3PresignService s3PresignService; private final UploadHelper uploadHelper; + private final MediaTagLinker mediaTagLinker; @Transactional(readOnly = true) public PageResponse getSeries(int page, int size, String searchWord) { @@ -139,6 +141,7 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { s3PresignService.toObjectUrl(posterObjectKey), s3PresignService.toObjectUrl(thumbnailObjectKey) ); + mediaTagLinker.linkTags(media, request.categoryName(), request.tagNameList()); return backOfficeSeriesMapper.toSeriesUploadResponse( seriesId, @@ -149,4 +152,3 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { ); } } - diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index 3486ffb..d42839a 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -46,4 +46,4 @@ public record ShortFormUploadRequest( @NotBlank String originFileName ) { -} +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index aa52827..82e94e9 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,10 +1,10 @@ package com.ott.api_admin.shortform.service; +import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; -import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; import com.ott.api_admin.upload.support.UploadHelper; import com.ott.common.web.exception.BusinessException; @@ -201,6 +201,9 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ s3PresignService.toObjectUrl(masterPlaylistObjectKey) ); + Long originMediaId = resolveOriginMediaId(series, contents); + inheritOriginMediaTags(media, originMediaId); + return backOfficeShortFormMapper.toShortFormUploadResponse( shortFormId, posterObjectKey, @@ -231,8 +234,37 @@ private Contents resolveContents(Long contentsId) { if (contentsId == null) { return null; } - return contentsRepository.findById(contentsId) + Contents contents = contentsRepository.findById(contentsId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + //시리즈에 속한 콘텐츠 제외 + if(contents.getSeries()!=null){ + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return contents; } -} + private Long resolveOriginMediaId(Series series, Contents contents) { + if (series != null) { + return series.getMedia().getId(); + } + if (contents != null) { + return contents.getMedia().getId(); + } + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + private void inheritOriginMediaTags(Media targetMedia, Long originMediaId) { + List originMediaTagList = mediaTagRepository.findWithTagAndCategoryByMediaId(originMediaId); + if (originMediaTagList.isEmpty()) { + return; + } + + List targetMediaTagList = originMediaTagList.stream() + .map(originMediaTag -> MediaTag.builder() + .media(targetMedia) + .tag(originMediaTag.getTag()) + .build()) + .toList(); + mediaTagRepository.saveAll(targetMediaTagList); + } +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java new file mode 100644 index 0000000..32932bf --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java @@ -0,0 +1,77 @@ +package com.ott.api_admin.upload.support; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.category.domain.Category; +import com.ott.domain.category.repository.CategoryRepository; +import com.ott.domain.common.Status; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media_tag.domain.MediaTag; +import com.ott.domain.media_tag.repository.MediaTagRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class MediaTagLinker { + + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final MediaTagRepository mediaTagRepository; + + public void linkTags(Media media, String categoryName, List tagNameList) { + String normalizedCategoryName = normalizeName(categoryName); + Set normalizedTagNameSet = normalizeTagNames(tagNameList); + + Category category = categoryRepository.findByNameAndStatus(normalizedCategoryName, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT)); + + List tagList = tagRepository.findAllByCategoryIdAndNameInAndStatus( + category.getId(), + normalizedTagNameSet, + Status.ACTIVE + ); + + //빠른 점검: 기존 태그 갯수와 db조회 갯수 비교 + //다르다 -> db에 없는 태그가 있음. + if (tagList.size() != normalizedTagNameSet.size()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + List mediaTagList = tagList.stream() + .map(tag -> MediaTag.builder() + .media(media) + .tag(tag) + .build()) + .toList(); + mediaTagRepository.saveAll(mediaTagList); + } + + private String normalizeName(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + return value.trim(); + } + + private Set normalizeTagNames(List tagNameList) { + if (tagNameList == null || tagNameList.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + Set normalizedTagNameSet = new LinkedHashSet<>(); + for (String tagName : tagNameList) { + String normalizedTagName = normalizeName(tagName); + if (!normalizedTagNameSet.add(normalizedTagName)) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + } + return normalizedTagNameSet; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java index fc5eb5b..895d4f6 100644 --- a/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/category/repository/CategoryRepository.java @@ -11,6 +11,8 @@ public interface CategoryRepository extends JpaRepository { + Optional findByNameAndStatus(String name, Status status); + @Query(""" SELECT DISTINCT c.name FROM MediaTag mt diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index e6b12ca..2208a12 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -1,7 +1,7 @@ package com.ott.domain.tag.repository; -import java.util.Collection; import java.util.List; +import java.util.Set; import com.ott.domain.category.domain.Category; import org.springframework.data.jpa.repository.JpaRepository; @@ -13,6 +13,8 @@ public interface TagRepository extends JpaRepository { + List findAllByCategoryIdAndNameInAndStatus(Long categoryId, Set nameList, Status status); + // 시리즈/콘텐츠에 연결된 태그 조회 @Query(""" SELECT DISTINCT t.name From 5df2a4359c2c12ccdaccabaa102f7b215317f8d0 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 11:24:18 +0900 Subject: [PATCH 128/257] =?UTF-8?q?[FEAT]:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EC=88=8F=ED=8F=BC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 14 +++++++- .../response/BookmarkShortFormResponse.java | 33 +++++++++++++++++++ .../bookmark/service/BookmarkService.java | 32 ++++++++++++++++++ .../repository/BookmarkRepository.java | 8 ++++- 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index 492d54d..636401a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -1,6 +1,7 @@ package com.ott.api_user.bookmark.controller; import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; import com.ott.common.web.response.PageResponse; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -35,7 +36,7 @@ public ResponseEntity> editBookmark( return ResponseEntity.ok(SuccessResponse.of(null)); } - // 북마크 리스트 조회 + // 북마크한 콘텐츠 or 시리즈 리스트 조회 @Override public ResponseEntity>> getBookmarkMediaList( @RequestParam(defaultValue = "0") int page, @@ -45,4 +46,15 @@ public ResponseEntity>> getB return ResponseEntity.ok(SuccessResponse.of( bookmarkService.getBookmarkMediaList(memberId, page, size))); } + + // 북마크한 숏폼 리스트 조회 + @Override + public ResponseEntity>> getBookmarkShortFormList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of( + bookmarkService.getBookmarkShortFormList(memberId, page, size))); + } } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java new file mode 100644 index 0000000..d143a1d --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java @@ -0,0 +1,33 @@ +package com.ott.api_user.bookmark.dto.response; + +import com.ott.domain.bookmark.domain.Bookmark; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "북마크 숏폼 목록 응답 DTO") +public class BookmarkShortFormResponse { + + @Schema(description = "미디어 ID", example = "1") + private Long mediaId; + + @Schema(description = "미디어 제목", example = "김마루의 숏폼 EP 01") + private String title; + + @Schema(description = "미디어 설명", example = "오늘은 북마크 API를 작성했다. 참 재미있었다!") + private String description; + + @Schema(description = "썸네일 URL", example = "https://cdn.ott.com/thumbnails/1.jpg") + private String thumbnailUrl; + + public static BookmarkShortFormResponse from(Bookmark bookmark) { + return BookmarkShortFormResponse.builder() + .mediaId(bookmark.getMedia().getId()) + .title(bookmark.getMedia().getTitle()) + .description(bookmark.getMedia().getDescription()) + .thumbnailUrl(bookmark.getMedia().getThumbnailUrl()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index 03587b6..f213f08 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -1,6 +1,7 @@ package com.ott.api_user.bookmark.service; import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -71,6 +72,7 @@ public void editBookmark(Long memberId, Long mediaId) { ); } + // 북마크 리스트 조회 @Transactional(readOnly = true) public PageResponse getBookmarkMediaList(Long memberId, int page, int size) { @@ -99,4 +101,34 @@ public PageResponse getBookmarkMediaList(Long memberId, i return PageResponse.toPageResponse(pageInfo, dataList); } + + + // 숏폼 리스트 조회 + @Transactional(readOnly = true) + public PageResponse getBookmarkShortFormList(Long memberId, int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + + // SHORT_FORM 타입만 조회 + Page bookmarkPage = bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaType( + memberId, + Status.ACTIVE, + MediaType.SHORT_FORM, + pageable + ); + + // DTO 변환 + List dataList = bookmarkPage.getContent().stream() + .map(BookmarkShortFormResponse::from) + .toList(); + + // pageInfo 생성 + PageInfo pageInfo = PageInfo.toPageInfo( + bookmarkPage.getNumber(), + bookmarkPage.getTotalPages(), + bookmarkPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, dataList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index adac1f6..de11a43 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -17,8 +17,14 @@ public interface BookmarkRepository extends JpaRepository { Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); - // 콘텐츠 목록 조회 (CONTENTS, SERIES) + // 콘텐츠 북마크 목록 조회 (CONTENTS, SERIES) + @EntityGraph(attributePaths = {"media"}) Page findByMemberIdAndStatusAndMedia_MediaTypeIn( Long memberId, Status status, List mediaTypes, Pageable pageable); + // 숏폼 북마크 목록 (SHORT_FORM) + @EntityGraph(attributePaths = {"media"}) + Page findByMemberIdAndStatusAndMedia_MediaType( + Long memberId, Status status, MediaType mediaType, Pageable pageable); + } From acff60103445d8171e1213e558f999082310ed7d Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 11:24:44 +0900 Subject: [PATCH 129/257] =?UTF-8?q?[FEAT]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20API=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookmark/controller/BookmarkAPI.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java new file mode 100644 index 0000000..fb65318 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -0,0 +1,75 @@ +package com.ott.api_user.bookmark.controller; + +import com.ott.api_user.bookmark.dto.request.BookmarkRequest; +import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; +import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +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 jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Bookmark API", description = "북마크 관련 API입니다.") +public interface BookmarkAPI { + @Operation(summary = "북마크 편집", description = "미디어에 대한 북마크를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "북마크 수정 성공"), + @ApiResponse(responseCode = "404", description = "미디어 또는 사용자를 찾을 수 없음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + }) + @PostMapping + ResponseEntity> editBookmark( + @Valid @RequestBody BookmarkRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + + // 북마크 콘텐츠 or 시리즈 목록 조회 + @Operation(summary = "북마크 콘텐츠 목록 조회", description = "유저가 북마크한 콘텐츠 목록을 페이징으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "북마크 목록 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PageResponse.class))) + }) + @GetMapping("/me/contents") + ResponseEntity>> getBookmarkMediaList( + @Parameter(description = "페이지 번호 (0부터 시작)", required = true) @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지 크기", required = true) @RequestParam(defaultValue = "10") @Min(0) @Max(100) int size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + + // 북마크 숏폼 목록 조회 + @Operation(summary = "북마크 숏폼 목록 조회", description = "유저가 북마크한 숏폼 목록을 페이징으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "북마크 숏폼 목록 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PageResponse.class))) + }) + @GetMapping("/me/short-form") + ResponseEntity>> getBookmarkShortFormList( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "10") @Min(0) @Max(100) int size, + @Parameter(hidden = true) Long memberId + ); + + +} + From f7cfbdeb20ee6e168961a3d06ac392b050e4dde9 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 13:14:52 +0900 Subject: [PATCH 130/257] =?UTF-8?q?[OT-125][FIX]:=20MediaTagLinker=20media?= =?UTF-8?q?=20null=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_admin/upload/support/MediaTagLinker.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java index 32932bf..6858209 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java @@ -26,6 +26,9 @@ public class MediaTagLinker { private final MediaTagRepository mediaTagRepository; public void linkTags(Media media, String categoryName, List tagNameList) { + if (media == null) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } String normalizedCategoryName = normalizeName(categoryName); Set normalizedTagNameSet = normalizeTagNames(tagNameList); From 2e14e39130b950c4796eca95b6e11508364cf06e Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 13:22:25 +0900 Subject: [PATCH 131/257] =?UTF-8?q?[OT-125][FIX]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20INVALID=5FINPUT=20=EC=98=88=EC=99=B8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BackOfficeShortFormService.java | 8 ++++---- .../ott/api_admin/upload/support/MediaTagLinker.java | 12 ++++++------ .../ott/api_admin/upload/support/UploadHelper.java | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 82e94e9..b9590b3 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -218,7 +218,7 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ private void validateExclusiveTarget(Long seriesId, Long contentsId) { if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "seriesId와 contentsId 중 하나만 제공해야 합니다."); } } @@ -238,7 +238,7 @@ private Contents resolveContents(Long contentsId) { .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); //시리즈에 속한 콘텐츠 제외 if(contents.getSeries()!=null){ - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "시리즈에 속한 콘텐츠는 숏폼 원본으로 선택할 수 없습니다."); } return contents; } @@ -250,7 +250,7 @@ private Long resolveOriginMediaId(Series series, Contents contents) { if (contents != null) { return contents.getMedia().getId(); } - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "숏폼 원본 미디어를 찾을 수 없습니다."); } private void inheritOriginMediaTags(Media targetMedia, Long originMediaId) { @@ -267,4 +267,4 @@ private void inheritOriginMediaTags(Media targetMedia, Long originMediaId) { .toList(); mediaTagRepository.saveAll(targetMediaTagList); } -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java index 6858209..09ba538 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java @@ -27,13 +27,13 @@ public class MediaTagLinker { public void linkTags(Media media, String categoryName, List tagNameList) { if (media == null) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "태그를 연결할 미디어 정보가 없습니다."); } String normalizedCategoryName = normalizeName(categoryName); Set normalizedTagNameSet = normalizeTagNames(tagNameList); Category category = categoryRepository.findByNameAndStatus(normalizedCategoryName, Status.ACTIVE) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT)); + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT, "유효한 카테고리를 찾을 수 없습니다.")); List tagList = tagRepository.findAllByCategoryIdAndNameInAndStatus( category.getId(), @@ -44,7 +44,7 @@ public void linkTags(Media media, String categoryName, List tagNameList) //빠른 점검: 기존 태그 갯수와 db조회 갯수 비교 //다르다 -> db에 없는 태그가 있음. if (tagList.size() != normalizedTagNameSet.size()) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "카테고리에 맞지 않거나 존재하지 않는 태그가 포함되어 있습니다."); } List mediaTagList = tagList.stream() @@ -58,21 +58,21 @@ public void linkTags(Media media, String categoryName, List tagNameList) private String normalizeName(String value) { if (value == null || value.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "카테고리/태그 이름은 비어 있을 수 없습니다."); } return value.trim(); } private Set normalizeTagNames(List tagNameList) { if (tagNameList == null || tagNameList.isEmpty()) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "태그 목록은 최소 1개 이상 필요합니다."); } Set normalizedTagNameSet = new LinkedHashSet<>(); for (String tagName : tagNameList) { String normalizedTagName = normalizeName(tagName); if (!normalizedTagNameSet.add(normalizedTagName)) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "태그 목록에 중복된 값이 있습니다."); } } return normalizedTagNameSet; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java index 638a609..c1f92e3 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -23,7 +23,7 @@ public String resolveImageContentType(String fileName) { try { return ExtensionEnum.resolveImageContentType(fileName); } catch (IllegalArgumentException ex) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "지원하지 않는 이미지 확장자입니다."); } } @@ -31,7 +31,7 @@ public String resolveVideoContentType(String fileName) { try { return ExtensionEnum.resolveVideoContentType(fileName); } catch (IllegalArgumentException ex) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "지원하지 않는 동영상 확장자입니다."); } } @@ -51,7 +51,7 @@ public String sanitizeFileName(String fileName) { sanitizedBaseName = "file"; } if (sanitizedExtension.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT); + throw new BusinessException(ErrorCode.INVALID_INPUT, "파일 확장자가 올바르지 않습니다."); } return sanitizedBaseName + "." + sanitizedExtension; } From a1808b3a8e6123bea6cb3f1d6b24e7ba7db54d43 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 13:29:13 +0900 Subject: [PATCH 132/257] =?UTF-8?q?[FEAT]:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/bookmark/controller/BookmarkAPI.java | 2 +- .../bookmark/controller/BookmarkController.java | 11 +++++++---- .../bookmark/dto/request/BookmarkRequest.java | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java index fb65318..d6e2981 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -10,7 +10,6 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,6 +21,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "Bookmark API", description = "북마크 관련 API입니다.") public interface BookmarkAPI { diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index 636401a..819241f 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -3,6 +3,9 @@ import com.ott.api_user.bookmark.dto.response.BookmarkMediaResponse; import com.ott.api_user.bookmark.dto.response.BookmarkShortFormResponse; import com.ott.common.web.response.PageResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -39,8 +42,8 @@ public ResponseEntity> editBookmark( // 북마크한 콘텐츠 or 시리즈 리스트 조회 @Override public ResponseEntity>> getBookmarkMediaList( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, + @PositiveOrZero @RequestParam(defaultValue = "0") int page, + @Min(1) @Max(100) @RequestParam(defaultValue = "10") int size, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of( @@ -50,8 +53,8 @@ public ResponseEntity>> getB // 북마크한 숏폼 리스트 조회 @Override public ResponseEntity>> getBookmarkShortFormList( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, + @PositiveOrZero @RequestParam(defaultValue = "0") int page, + @Min(1) @Max(100) @RequestParam(defaultValue = "10") int size, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of( diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java index 40785f3..0599177 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.Getter; import lombok.NoArgsConstructor; @@ -12,6 +13,7 @@ public class BookmarkRequest { @NotNull(message = "mediaId는 필수입니다.") + @Positive(message = "mediaId는 1 이상이어야 합니다.") @Schema(description = "북마크할 미디어 ID", example = "1") private Long mediaId; } From 68080b001ad508e2defb106d577e0f7d7b25d00f Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 13:29:25 +0900 Subject: [PATCH 133/257] =?UTF-8?q?[FEAT]:=20=EC=B5=9C=EC=8B=A0=EC=88=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_user/bookmark/service/BookmarkService.java | 4 ++-- .../ott/domain/bookmark/repository/BookmarkRepository.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index f213f08..862fb4d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -80,7 +80,7 @@ public PageResponse getBookmarkMediaList(Long memberId, i // 해당 유저의 ACTIVE인 북마크한 콘텐츠 OR 시리즈 타입 목록 페이징 조회 (fetch Join) Page bookmarkPage = - bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaTypeIn( + bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaTypeInOrderByCreatedDateDesc( memberId, Status.ACTIVE, List.of(MediaType.CONTENTS, MediaType.SERIES), @@ -110,7 +110,7 @@ public PageResponse getBookmarkShortFormList(Long mem Pageable pageable = PageRequest.of(page, size); // SHORT_FORM 타입만 조회 - Page bookmarkPage = bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaType( + Page bookmarkPage = bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( memberId, Status.ACTIVE, MediaType.SHORT_FORM, diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index de11a43..9765c39 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -19,12 +19,12 @@ public interface BookmarkRepository extends JpaRepository { // 콘텐츠 북마크 목록 조회 (CONTENTS, SERIES) @EntityGraph(attributePaths = {"media"}) - Page findByMemberIdAndStatusAndMedia_MediaTypeIn( + Page findByMemberIdAndStatusAndMedia_MediaTypeInOrderByCreatedDateDesc( Long memberId, Status status, List mediaTypes, Pageable pageable); // 숏폼 북마크 목록 (SHORT_FORM) @EntityGraph(attributePaths = {"media"}) - Page findByMemberIdAndStatusAndMedia_MediaType( + Page findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( Long memberId, Status status, MediaType mediaType, Pageable pageable); } From efa35feb2546f4d5d810032a2fdfe33e21654c41 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 13:37:26 +0900 Subject: [PATCH 134/257] =?UTF-8?q?[FIX]:=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=83=80=EC=9E=85=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_user/bookmark/controller/BookmarkAPI.java | 8 ++++---- .../api_user/bookmark/controller/BookmarkController.java | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java index d6e2981..5dd100d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -49,8 +49,8 @@ ResponseEntity> editBookmark( }) @GetMapping("/me/contents") ResponseEntity>> getBookmarkMediaList( - @Parameter(description = "페이지 번호 (0부터 시작)", required = true) @RequestParam(defaultValue = "0") @Min(0) int page, - @Parameter(description = "페이지 크기", required = true) @RequestParam(defaultValue = "10") @Min(0) @Max(100) int size, + @Parameter(description = "페이지 번호 (0부터 시작)", required = true) @RequestParam(defaultValue = "0") @Min(0) Integer page, + @Parameter(description = "페이지 크기", required = true) @RequestParam(defaultValue = "10") @Min(0) @Max(100) Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -65,8 +65,8 @@ ResponseEntity>> getBookmark }) @GetMapping("/me/short-form") ResponseEntity>> getBookmarkShortFormList( - @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") @Min(0) int page, - @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "10") @Min(0) @Max(100) int size, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") @Min(0) Integer page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "10") @Min(0) @Max(100) Integer size, @Parameter(hidden = true) Long memberId ); diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index 819241f..228a25a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -42,8 +42,8 @@ public ResponseEntity> editBookmark( // 북마크한 콘텐츠 or 시리즈 리스트 조회 @Override public ResponseEntity>> getBookmarkMediaList( - @PositiveOrZero @RequestParam(defaultValue = "0") int page, - @Min(1) @Max(100) @RequestParam(defaultValue = "10") int size, + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page, + @Min(1) @Max(100) @RequestParam(defaultValue = "10") Integer size, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of( @@ -53,8 +53,8 @@ public ResponseEntity>> getB // 북마크한 숏폼 리스트 조회 @Override public ResponseEntity>> getBookmarkShortFormList( - @PositiveOrZero @RequestParam(defaultValue = "0") int page, - @Min(1) @Max(100) @RequestParam(defaultValue = "10") int size, + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page, + @Min(1) @Max(100) @RequestParam(defaultValue = "10") Integer size, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of( From dfcaf807bb84c0aecbdcc568b884809199c29801 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 13:38:03 +0900 Subject: [PATCH 135/257] =?UTF-8?q?[FEAT]:=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20type=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/bookmark/dto/request/BookmarkRequest.java | 3 +-- .../bookmark/dto/response/BookmarkMediaResponse.java | 8 ++++---- .../bookmark/dto/response/BookmarkShortFormResponse.java | 8 ++++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java index 0599177..0bd758a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/request/BookmarkRequest.java @@ -1,7 +1,6 @@ package com.ott.api_user.bookmark.dto.request; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import lombok.Getter; @@ -14,6 +13,6 @@ public class BookmarkRequest { @NotNull(message = "mediaId는 필수입니다.") @Positive(message = "mediaId는 1 이상이어야 합니다.") - @Schema(description = "북마크할 미디어 ID", example = "1") + @Schema(type ="Long", description = "북마크할 미디어 ID", example = "1") private Long mediaId; } diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java index 1eae322..20dce3b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java @@ -10,16 +10,16 @@ @Schema(description = "북마크한 콘텐츠 목록 응답 DTO") public class BookmarkMediaResponse { - @Schema(description = "미디어 ID", example = "1") + @Schema(type ="Long", description = "미디어 ID", example = "1") private Long mediaId; - @Schema(description = "미디어 제목", example = "어서와요 김마루의 숲") + @Schema(type ="String", description = "미디어 제목", example = "어서와요 김마루의 숲") private String title; - @Schema(description = "미디어 설명", example = "허거덩의 숲에서 힐링을 즐겨봐요~") + @Schema(type ="String", description = "미디어 설명", example = "허거덩의 숲에서 힐링을 즐겨봐요~") private String description; - @Schema(description = "포스터 URL", example = "https://cdn.ott.com/posters/1.jpg") + @Schema(type ="String", description = "포스터 URL", example = "https://cdn.ott.com/posters/1.jpg") private String posterUrl; public static BookmarkMediaResponse from(Bookmark bookmark) { diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java index d143a1d..725b747 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkShortFormResponse.java @@ -10,16 +10,16 @@ @Schema(description = "북마크 숏폼 목록 응답 DTO") public class BookmarkShortFormResponse { - @Schema(description = "미디어 ID", example = "1") + @Schema(type ="Long", description = "미디어 ID", example = "1") private Long mediaId; - @Schema(description = "미디어 제목", example = "김마루의 숏폼 EP 01") + @Schema(type ="String", description = "미디어 제목", example = "김마루의 숏폼 EP 01") private String title; - @Schema(description = "미디어 설명", example = "오늘은 북마크 API를 작성했다. 참 재미있었다!") + @Schema(type ="String", description = "미디어 설명", example = "오늘은 북마크 API를 작성했다. 참 재미있었다!") private String description; - @Schema(description = "썸네일 URL", example = "https://cdn.ott.com/thumbnails/1.jpg") + @Schema(type ="String", description = "썸네일 URL", example = "https://cdn.ott.com/thumbnails/1.jpg") private String thumbnailUrl; public static BookmarkShortFormResponse from(Bookmark bookmark) { From 70cddf6ddbad6c457f6ceb9c6a412c36e05763a3 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 13:38:31 +0900 Subject: [PATCH 136/257] =?UTF-8?q?[FEAT]:=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=8B=A4=EB=A5=B8=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=20=EB=B3=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/bookmark/service/BookmarkService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index 862fb4d..40da01a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -25,7 +25,6 @@ @Service @RequiredArgsConstructor -@Transactional public class BookmarkService { private final BookmarkRepository bookmarkRepository; @@ -38,6 +37,7 @@ public class BookmarkService { * 해당 유저의 media의 북마크가 없을 경우 -> insert 상태 ACTIVE + 카운트 증가 * 해당 유저의 media의 북마크가 있는데 DELETE 일 경우 -> ACTIVE 변경 + 카운트 증가 */ + @Transactional public void editBookmark(Long memberId, Long mediaId) { Media findMedia = mediaRepository.findById(mediaId) From 7e149ccb0db20a592bbe1c2e1207e7e42cc187e6 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 13:54:43 +0900 Subject: [PATCH 137/257] =?UTF-8?q?[FIX]:=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/bookmark/controller/BookmarkController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index 228a25a..50c39b6 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -42,8 +42,8 @@ public ResponseEntity> editBookmark( // 북마크한 콘텐츠 or 시리즈 리스트 조회 @Override public ResponseEntity>> getBookmarkMediaList( - @PositiveOrZero @RequestParam(defaultValue = "0") Integer page, - @Min(1) @Max(100) @RequestParam(defaultValue = "10") Integer size, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of( @@ -53,8 +53,8 @@ public ResponseEntity>> getB // 북마크한 숏폼 리스트 조회 @Override public ResponseEntity>> getBookmarkShortFormList( - @PositiveOrZero @RequestParam(defaultValue = "0") Integer page, - @Min(1) @Max(100) @RequestParam(defaultValue = "10") Integer size, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of( From ce8efa2f61006c2e3445682777ccf3af1ab36a4d Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 15:41:38 +0900 Subject: [PATCH 138/257] =?UTF-8?q?[OT-125][FEATURE]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC/=ED=83=9C=EA=B7=B8=20ID=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ContentsUploadRequest.java | 12 ++-- .../service/BackOfficeContentsService.java | 2 +- .../dto/request/SeriesUploadRequest.java | 12 ++-- .../service/BackOfficeSeriesService.java | 2 +- .../upload/support/MediaTagLinker.java | 60 +++++++------------ .../ott/common/web/exception/ErrorCode.java | 12 +++- 6 files changed, 47 insertions(+), 53 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index c74faf1..92d0763 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import java.util.List; @@ -30,13 +31,14 @@ public record ContentsUploadRequest( @NotNull PublicStatus publicStatus, - @Schema(type = "String", description = "카테고리명", example = "드라마") - @NotBlank - String categoryName, + @Schema(type = "Long", description = "카테고리 ID", example = "1") + @NotNull + @Positive + Long categoryId, - @Schema(type = "List", description = "태그명 목록", example = "[\"가족\", \"코미디\"]") + @Schema(type = "List", description = "태그 ID 목록", example = "[1, 2]") @NotEmpty - List<@NotBlank String> tagNameList, + List<@NotNull @Positive Long> tagIdList, @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") @PositiveOrZero diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index a97d480..3145580 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -149,7 +149,7 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request s3PresignService.toObjectUrl(masterPlaylistObjectKey) ); - mediaTagLinker.linkTags(media, request.categoryName(), request.tagNameList()); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); return backOfficeContentsMapper.toContentsUploadResponse( contentsId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java index 9dc53c9..8e582da 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.util.List; @@ -26,13 +27,14 @@ public record SeriesUploadRequest( @NotNull PublicStatus publicStatus, - @Schema(type = "String", description = "카테고리명", example = "드라마") - @NotBlank - String categoryName, + @Schema(type = "Long", description = "카테고리 ID", example = "1") + @NotNull + @Positive + Long categoryId, - @Schema(type = "List", description = "태그명 목록", example = "[\"가족\", \"코미디\"]") + @Schema(type = "List", description = "태그 ID 목록", example = "[1, 2]") @NotEmpty - List<@NotBlank String> tagNameList, + List<@NotNull @Positive Long> tagIdList, @Schema(type = "String", description = "포스터 원본 파일명", example = "poster.jpg") @NotBlank diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 118e05e..2499ac2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -141,7 +141,7 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { s3PresignService.toObjectUrl(posterObjectKey), s3PresignService.toObjectUrl(thumbnailObjectKey) ); - mediaTagLinker.linkTags(media, request.categoryName(), request.tagNameList()); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); return backOfficeSeriesMapper.toSeriesUploadResponse( seriesId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java index 09ba538..3d00146 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/MediaTagLinker.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,26 +26,28 @@ public class MediaTagLinker { private final TagRepository tagRepository; private final MediaTagRepository mediaTagRepository; - public void linkTags(Media media, String categoryName, List tagNameList) { - if (media == null) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "태그를 연결할 미디어 정보가 없습니다."); - } - String normalizedCategoryName = normalizeName(categoryName); - Set normalizedTagNameSet = normalizeTagNames(tagNameList); + public void linkTags(Media media, Long categoryId, List tagIdList) { + + Category category = categoryRepository.findByIdAndStatus(categoryId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TAG_CATEGORY)); - Category category = categoryRepository.findByNameAndStatus(normalizedCategoryName, Status.ACTIVE) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT, "유효한 카테고리를 찾을 수 없습니다.")); + Set uniqueTagIdSet = new LinkedHashSet<>(); + for (Long tagId : tagIdList) { + if (!uniqueTagIdSet.add(tagId)) { + throw new BusinessException(ErrorCode.DUPLICATE_TAG_IN_LIST); + } + } - List tagList = tagRepository.findAllByCategoryIdAndNameInAndStatus( - category.getId(), - normalizedTagNameSet, - Status.ACTIVE - ); + List tagList = tagRepository.findAllByIdInAndStatus(new ArrayList<>(uniqueTagIdSet), Status.ACTIVE); - //빠른 점검: 기존 태그 갯수와 db조회 갯수 비교 - //다르다 -> db에 없는 태그가 있음. - if (tagList.size() != normalizedTagNameSet.size()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "카테고리에 맞지 않거나 존재하지 않는 태그가 포함되어 있습니다."); + // 카테고리에 맞지 않거나 존재하지 않는 태그가 포함 확인. + if (tagList.size() != uniqueTagIdSet.size()) { + throw new BusinessException(ErrorCode.INVALID_TAG_SELECTION); + } + boolean hasInvalidCategoryTag = tagList.stream() + .anyMatch(tag -> !tag.getCategory().getId().equals(category.getId())); + if (hasInvalidCategoryTag) { + throw new BusinessException(ErrorCode.INVALID_TAG_SELECTION); } List mediaTagList = tagList.stream() @@ -53,28 +56,7 @@ public void linkTags(Media media, String categoryName, List tagNameList) .tag(tag) .build()) .toList(); - mediaTagRepository.saveAll(mediaTagList); - } - private String normalizeName(String value) { - if (value == null || value.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "카테고리/태그 이름은 비어 있을 수 없습니다."); - } - return value.trim(); - } - - private Set normalizeTagNames(List tagNameList) { - if (tagNameList == null || tagNameList.isEmpty()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "태그 목록은 최소 1개 이상 필요합니다."); - } - - Set normalizedTagNameSet = new LinkedHashSet<>(); - for (String tagName : tagNameList) { - String normalizedTagName = normalizeName(tagName); - if (!normalizedTagNameSet.add(normalizedTagName)) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "태그 목록에 중복된 값이 있습니다."); - } - } - return normalizedTagNameSet; + mediaTagRepository.saveAll(mediaTagList); } } diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 5256647..3e3e562 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -47,10 +47,18 @@ public enum ErrorCode { SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), - TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다.") - + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), + UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B007", "지원하지 않는 이미지 확장자입니다."), + UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B008", "지원하지 않는 동영상 확장자입니다."), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B009", "파일 확장자가 올바르지 않습니다."), + INVALID_TAG_CATEGORY(HttpStatus.NOT_FOUND, "B011", "유효한 카테고리를 찾을 수 없습니다."), + INVALID_TAG_SELECTION(HttpStatus.BAD_REQUEST, "B012", "카테고리에 맞지 않거나 존재하지 않는 태그가 포함되어 있습니다."), + DUPLICATE_TAG_IN_LIST(HttpStatus.BAD_REQUEST, "B015", "태그 목록에 중복된 값이 있습니다."), + INVALID_SHORTFORM_TARGET(HttpStatus.BAD_REQUEST, "B016", "seriesId와 contentsId 중 하나만 제공해야 합니다."), + INVALID_SHORTFORM_CONTENTS_TARGET(HttpStatus.BAD_REQUEST, "B017", "시리즈에 속한 콘텐츠는 숏폼 원본으로 선택할 수 없습니다."), + SHORTFORM_ORIGIN_MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B018", "숏폼 원본 미디어를 찾을 수 없습니다.") ; private final HttpStatus status; From 96ea7aff26e53204cdc2218b5ae0125ef288e42a Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 15:42:04 +0900 Subject: [PATCH 139/257] =?UTF-8?q?[OT-125][FIX]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=99=95=EC=9E=A5=EC=9E=90/=EC=88=8F=ED=8F=BC=20?= =?UTF-8?q?=ED=83=80=EA=B2=9F=20=EC=98=88=EC=99=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shortform/service/BackOfficeShortFormService.java | 10 +++++----- .../com/ott/api_admin/upload/support/UploadHelper.java | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index b9590b3..1363587 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -218,7 +218,7 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ private void validateExclusiveTarget(Long seriesId, Long contentsId) { if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "seriesId와 contentsId 중 하나만 제공해야 합니다."); + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); } } @@ -236,9 +236,9 @@ private Contents resolveContents(Long contentsId) { } Contents contents = contentsRepository.findById(contentsId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - //시리즈에 속한 콘텐츠 제외 - if(contents.getSeries()!=null){ - throw new BusinessException(ErrorCode.INVALID_INPUT, "시리즈에 속한 콘텐츠는 숏폼 원본으로 선택할 수 없습니다."); + // 시리즈에 속한 콘텐츠는 숏폼 원본으로 허용하지 않습니다. + if(contents.getSeries() != null){ + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_CONTENTS_TARGET); } return contents; } @@ -250,7 +250,7 @@ private Long resolveOriginMediaId(Series series, Contents contents) { if (contents != null) { return contents.getMedia().getId(); } - throw new BusinessException(ErrorCode.INVALID_INPUT, "숏폼 원본 미디어를 찾을 수 없습니다."); + throw new BusinessException(ErrorCode.SHORTFORM_ORIGIN_MEDIA_NOT_FOUND); } private void inheritOriginMediaTags(Media targetMedia, Long originMediaId) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java index c1f92e3..48d967e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -23,7 +23,7 @@ public String resolveImageContentType(String fileName) { try { return ExtensionEnum.resolveImageContentType(fileName); } catch (IllegalArgumentException ex) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "지원하지 않는 이미지 확장자입니다."); + throw new BusinessException(ErrorCode.UNSUPPORTED_IMAGE_EXTENSION); } } @@ -31,7 +31,7 @@ public String resolveVideoContentType(String fileName) { try { return ExtensionEnum.resolveVideoContentType(fileName); } catch (IllegalArgumentException ex) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "지원하지 않는 동영상 확장자입니다."); + throw new BusinessException(ErrorCode.UNSUPPORTED_VIDEO_EXTENSION); } } @@ -51,7 +51,7 @@ public String sanitizeFileName(String fileName) { sanitizedBaseName = "file"; } if (sanitizedExtension.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "파일 확장자가 올바르지 않습니다."); + throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION); } return sanitizedBaseName + "." + sanitizedExtension; } From eec0e7de4a087102f3e12332e1bf2542d57d2e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 26 Feb 2026 16:12:47 +0900 Subject: [PATCH 140/257] =?UTF-8?q?[CHORE]:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/{transcode => ffmpeg}/FfmpegExecutor.java | 2 +- .../processbuilder/ProcessBuilderFfmpegExecutor.java | 4 ++-- .../com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java | 2 +- .../java/com/ott/transcoder/{ffmpeg => transcode}/.gitkeep | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename apps/transcoder/src/main/java/com/ott/transcoder/{transcode => ffmpeg}/FfmpegExecutor.java (96%) rename apps/transcoder/src/main/java/com/ott/transcoder/{transcode => ffmpeg}/processbuilder/ProcessBuilderFfmpegExecutor.java (97%) rename apps/transcoder/src/main/java/com/ott/transcoder/{ffmpeg => transcode}/.gitkeep (100%) diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java similarity index 96% rename from apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java index 1178e76..f1c9c46 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java @@ -1,4 +1,4 @@ -package com.ott.transcoder.transcode; +package com.ott.transcoder.ffmpeg; import com.ott.domain.video_profile.domain.Resolution; diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java similarity index 97% rename from apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java index 4ef1dc5..1995ddf 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -1,7 +1,7 @@ -package com.ott.transcoder.transcode.processbuilder; +package com.ott.transcoder.ffmpeg.processbuilder; import com.ott.domain.video_profile.domain.Resolution; -import com.ott.transcoder.transcode.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.FfmpegExecutor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java index 704155a..13988c5 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java @@ -3,7 +3,7 @@ import com.ott.domain.video_profile.domain.Resolution; import com.ott.transcoder.pipeline.CommandPipeline; import com.ott.transcoder.storage.VideoStorage; -import com.ott.transcoder.transcode.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.FfmpegExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep similarity index 100% rename from apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep From 4806d5d7b37ce698bb4bde9740227e259ea843dd Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 16:33:36 +0900 Subject: [PATCH 141/257] =?UTF-8?q?[DELETE]:=20gitkeep=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_user/likes/{controller => dto/response}/.gitkeep | 0 .../src/main/java/com/ott/api_user/likes/service/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename apps/api-user/src/main/java/com/ott/api_user/likes/{controller => dto/response}/.gitkeep (100%) delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/likes/service/.gitkeep diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/likes/dto/response/.gitkeep similarity index 100% rename from apps/api-user/src/main/java/com/ott/api_user/likes/controller/.gitkeep rename to apps/api-user/src/main/java/com/ott/api_user/likes/dto/response/.gitkeep diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/likes/service/.gitkeep deleted file mode 100644 index e69de29..0000000 From 428595a7eacd1b16f6c69ac9e508ddcdb4547eaa Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 16:34:38 +0900 Subject: [PATCH 142/257] =?UTF-8?q?[FEAT]:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/common/web/exception/ErrorCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 5256647..6906419 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -47,7 +47,8 @@ public enum ErrorCode { SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), - TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다.") + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), + MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B07", "미디어를 찾을 수 없습니다.") From 1c9177632123560037f3a02c91ef4ded2c8e214a Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 16:35:14 +0900 Subject: [PATCH 143/257] =?UTF-8?q?[FEAT]:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../likes/controller/LikesController.java | 29 ++++++ .../likes/dto/request/LikesRequest.java | 16 ++++ .../api_user/likes/service/LikesService.java | 93 +++++++++++++++++++ .../repository/ContentsRepository.java | 6 ++ .../likes/repository/LikesRepository.java | 5 + .../com/ott/domain/media/domain/Media.java | 10 ++ 6 files changed, 159 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java new file mode 100644 index 0000000..2450997 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java @@ -0,0 +1,29 @@ +package com.ott.api_user.likes.controller; + +import com.ott.api_user.likes.dto.request.LikesRequest; +import com.ott.api_user.likes.service.LikesService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/likes") +public class LikesController implements LikesAPI { + + private final LikesService likesService; + + @Override + public ResponseEntity> editLikes( + @Valid @RequestBody LikesRequest request, + @AuthenticationPrincipal Long memberId) { + + likesService.editLikes(memberId, request.getMediaId()); + return ResponseEntity.ok(SuccessResponse.of(null)); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java b/apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java new file mode 100644 index 0000000..6bcae40 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/dto/request/LikesRequest.java @@ -0,0 +1,16 @@ +package com.ott.api_user.likes.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "좋아요 요청 DTO") +public class LikesRequest { + + @NotNull(message = "mediaId는 필수입니다.") + @Schema(type = "Long", description = "좋아요 할 미디어 ID", example = "1") + private Long mediaId; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java new file mode 100644 index 0000000..9a93f9e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java @@ -0,0 +1,93 @@ +package com.ott.api_user.likes.service; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.Status; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.likes.domain.Likes; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.short_form.repository.ShortFormRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikesService { + + private final LikesRepository likesRepository; + private final MemberRepository memberRepository; + private final MediaRepository mediaRepository; + private final ContentsRepository contentsRepository; + private final ShortFormRepository shortFormRepository; + + + /** + * 좋아요 버튼 + * CONTENTS → 시리즈 에피소드면 부모 Series.media로 처리 + * SHORT_FORM → 시리즈 소속 숏폼이면 부모 Series.media로 처리 + * SERIES → 그대로 처리 + */ + @Transactional + public void editLikes(Long memberId, Long mediaId) { + + Media findMedia = mediaRepository.findById(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUNT)); + + // 실제 좋아요 처리할 미디어 결정 + Media targetMedia = resolveTargetMedia(findMedia); + + likesRepository.findByMemberIdAndMediaIdAndStatus(memberId, targetMedia.getId(), Status.ACTIVE) + .ifPresentOrElse( + likes -> { + // 한번 더 상태 검증 + if (likes.getStatus() == Status.ACTIVE) { + // 기록이 있을 경우 ACTIVE → DELETE + 카운트 감소 + likes.updateStatus(Status.DELETE); + targetMedia.decreaseLikesCount(); + } else { + // 기록이 없을 경우 DELETE → ACTIVE + 카운트 증가 + likes.updateStatus(Status.ACTIVE); + targetMedia.increaseLikesCount(); + } + }, + () -> { + // 신규 좋아요일 경우 insert + 카운트 증가 + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // insert + likesRepository.save(Likes.builder() + .member(findMember) + .media(targetMedia) + .build()); + + // 카운트 증가 + targetMedia.increaseLikesCount(); + } + ); + } + + + /** + * mediaType에 따라 실제 좋아요 처리할 타겟 Media 반환 + * CONTENTS → series 소속이면 series.media 반환 + * CONTENTS → series 소속이 아나면 자기 자신 media 반환 + * SHORT_FORM → 자기 자신 media 반환 + * SERIES → 자기 자신 series 반환 + */ + private Media resolveTargetMedia(Media media) { + return switch (media.getMediaType()) { + case CONTENTS -> contentsRepository.findByMediaId(media.getId()) + .filter(contents -> contents.getSeries() != null) // 시리즈 에피소드인지 확인 + .map(contents -> contents.getSeries().getMedia()) // 부모 Series.media로 교체 + .orElse(media); // 단편이면 그대로 + + case SERIES, SHORT_FORM -> media; // 시리즈 자체 or 숏폼은 항상 그대로 + }; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index a038aba..9ef3bdf 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -33,9 +33,15 @@ import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; +import java.util.Optional; + public interface ContentsRepository extends JpaRepository, ContentsRepositoryCustom { @EntityGraph(attributePaths = { "media" }) Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long seriesId, Status status, PublicStatus publicStatus, Pageable pageable); + + // 좋아요 처리 시 series 소속 여부 확인용 + @EntityGraph(attributePaths = {"series", "series.media"}) + Optional findByMediaId(Long mediaId); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index fac0859..6b76047 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -5,6 +5,11 @@ import com.ott.domain.common.Status; import com.ott.domain.likes.domain.Likes; +import java.util.Optional; + public interface LikesRepository extends JpaRepository { boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + + + Optional findByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index 6204067..40c6a17 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -69,4 +69,14 @@ public void updateImageKeys(String posterUrl, String thumbnailUrl) { this.posterUrl = posterUrl; this.thumbnailUrl = thumbnailUrl; } + + public void increaseLikesCount() { + this.likesCount++; + } + + public void decreaseLikesCount() { + if (this.likesCount > 0) { + this.likesCount--; + } + } } From f735c5e753964fcf09a677d7f561e15c1cac9917 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 16:35:39 +0900 Subject: [PATCH 144/257] =?UTF-8?q?[FEAT]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/likes/controller/LikesAPI.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java new file mode 100644 index 0000000..c3163d5 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java @@ -0,0 +1,33 @@ +package com.ott.api_user.likes.controller; + +import com.ott.api_user.likes.dto.request.LikesRequest; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "좋아요 API", description = "좋아요 관련 API입니다.") +public interface LikesAPI { + + @Operation(summary = "좋아요 API", description = "좋아요 상태를 변경합니다. 등록/취소 모두 이 API를 사용합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "좋아요 성공"), + @ApiResponse(responseCode = "404", description = "미디어 또는 사용자를 찾을 수 없음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + ResponseEntity> editLikes( + @Valid @RequestBody LikesRequest request, + @Parameter(hidden = true) Long memberId + ); +} \ No newline at end of file From 72125fe85d07ddafd5b0e5b56d3ba02dcd20d14b Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 16:19:05 +0900 Subject: [PATCH 145/257] =?UTF-8?q?[OT-134][CHORE]:=20GitHub=20Actions=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20GitHub=20Secret?= =?UTF-8?q?s=20=EC=B0=B8=EC=A1=B0=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 37 ++++++++++--------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml index c6a5a0f..576503b 100644 --- a/.github/workflows/deploy-ec2-docker.yml +++ b/.github/workflows/deploy-ec2-docker.yml @@ -85,29 +85,21 @@ jobs: IMAGE_TAG_INPUT: ${{ github.event.inputs.image_tag }} PROJECT_NAME: oplust DB_NAME: oplust - RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }} - DB_USERNAME: ${{ secrets.DB_USERNAME }} - DB_PASSWORD: ${{ secrets.DB_PASSWORD }} - API_USER_ENV: ${{ secrets.API_USER_ENV }} - API_ADMIN_ENV: ${{ secrets.API_ADMIN_ENV }} - TRANSCODER_ENV: ${{ secrets.TRANSCODER_ENV }} + SSM_RDS_ENDPOINT_PARAM: /oplust/common/rds-endpoint + SSM_DB_USERNAME_PARAM: /oplust/common/db-username + SSM_DB_PASSWORD_PARAM: /oplust/common/db-password run: | set -euo pipefail IMAGE_TAG="${IMAGE_TAG_INPUT:-${GITHUB_SHA}}" - if [ -z "${RDS_ENDPOINT}" ] || [ -z "${DB_USERNAME}" ] || [ -z "${DB_PASSWORD}" ]; then - echo "RDS_ENDPOINT, DB_USERNAME, DB_PASSWORD secrets are required" >&2 - exit 1 - fi - deploy_service() { local target_tag="$1" local image_uri="$2" local container_name="$3" local env_file="$4" local port="$5" - local env_payload="$6" + local service_env_param="$6" local instance_id instance_id=$(aws ec2 describe-instances \ @@ -121,12 +113,6 @@ jobs: exit 1 fi - local full_env_payload - full_env_payload=$(printf 'SPRING_DATASOURCE_URL=jdbc:mysql://%s:3306/%s\nSPRING_DATASOURCE_USERNAME=%s\nSPRING_DATASOURCE_PASSWORD=%s\n%s' "${RDS_ENDPOINT}" "${DB_NAME}" "${DB_USERNAME}" "${DB_PASSWORD}" "${env_payload}") - - local env_payload_b64 - env_payload_b64="$(printf '%s' "$full_env_payload" | base64 -w0)" - local run_cmd if [ -n "$port" ]; then run_cmd="sudo docker run -d --name ${container_name} --restart unless-stopped -p ${port}:${port} --env-file ${env_file} ${image_uri}" @@ -143,7 +129,14 @@ jobs: --parameters commands="[ \"set -e\", \"sudo mkdir -p /etc/oplust\", - \"echo '${env_payload_b64}' | base64 -d | sudo tee ${env_file} >/dev/null\", + \"DB_HOST=\\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_RDS_ENDPOINT_PARAM' --query 'Parameter.Value' --output text)\", + \"DB_USER=\\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_USERNAME_PARAM' --query 'Parameter.Value' --output text)\", + \"DB_PASS=\\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_PASSWORD_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", + \"SERVICE_ENV=\\$(aws ssm get-parameter --region $AWS_REGION --name '${service_env_param}' --with-decryption --query 'Parameter.Value' --output text)\", + \"echo \\\"SPRING_DATASOURCE_URL=jdbc:mysql://\\$DB_HOST:3306/${DB_NAME}\\\" | sudo tee ${env_file} >/dev/null\", + \"echo \\\"SPRING_DATASOURCE_USERNAME=\\$DB_USER\\\" | sudo tee -a ${env_file} >/dev/null\", + \"echo \\\"SPRING_DATASOURCE_PASSWORD=\\$DB_PASS\\\" | sudo tee -a ${env_file} >/dev/null\", + \"printf '%s\\n' \\\"\\$SERVICE_ENV\\\" | sudo tee -a ${env_file} >/dev/null\", \"sudo chmod 600 ${env_file}\", \"aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY\", \"sudo docker pull ${image_uri}\", @@ -189,6 +182,6 @@ jobs: exit 1 } - deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "${API_USER_ENV}" - deploy_service "${PROJECT_NAME}-admin-ec2" "${ECR_REGISTRY}/oplust-api-admin:${IMAGE_TAG}" "oplust-api-admin" "/etc/oplust/api-admin.env" "8081" "${API_ADMIN_ENV}" - deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "${TRANSCODER_ENV}" + deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "/oplust/api-user/env" + deploy_service "${PROJECT_NAME}-admin-ec2" "${ECR_REGISTRY}/oplust-api-admin:${IMAGE_TAG}" "oplust-api-admin" "/etc/oplust/api-admin.env" "8081" "/oplust/api-admin/env" + deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "/oplust/transcoder/env" \ No newline at end of file From e8f2e44ccdba365c18fbe86bdc8543db786a2b28 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 16:19:17 +0900 Subject: [PATCH 146/257] =?UTF-8?q?[OT-134][FIX]:=20Swagger=20=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_admin/config/SecurityConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 886dff2..275df11 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -37,13 +37,13 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .accessDeniedHandler(jwtAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/actuator/health/**", + "/admin/actuator/health/**", "/actuator/info", "/admin/login", "/admin/reissue", - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**" + "/admin/swagger-ui/**", + "/admin/v3/api-docs/**", + "/admin/swagger-resources/**" ).permitAll() .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") From 302c0658a972812a706693399d1033a550aa2cf8 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 16:33:56 +0900 Subject: [PATCH 147/257] =?UTF-8?q?[OT-134][FIX]:=20Admin=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20back-office=20prefix=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthController.java | 2 +- .../com/ott/api_admin/config/SecurityConfig.java | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java index 20158d4..2a47cd1 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/admin") +@RequestMapping("/back-office/admin") @RequiredArgsConstructor public class AdminAuthController implements AdminAuthApi { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 275df11..f2b352d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -37,13 +37,13 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .accessDeniedHandler(jwtAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/admin/actuator/health/**", - "/actuator/info", - "/admin/login", - "/admin/reissue", - "/admin/swagger-ui/**", - "/admin/v3/api-docs/**", - "/admin/swagger-resources/**" + "/back-office/actuator/health/**", + "/back-office/actuator/info", + "/back-office/admin/login", + "/back-office/admin/reissue", + "/back-office/swagger-ui/**", + "/back-office/v3/api-docs/**", + "/back-office/swagger-resources/**" ).permitAll() .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") From a26c3162e808258aae0096fa999d1d55254a8c9c Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 16:39:05 +0900 Subject: [PATCH 148/257] =?UTF-8?q?[OT-134][FIX]:=20SSM=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20=EB=AA=85=EB=A0=B9=20=EC=9D=B4=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=A5=BC=20EC2=20=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml index 576503b..7c66264 100644 --- a/.github/workflows/deploy-ec2-docker.yml +++ b/.github/workflows/deploy-ec2-docker.yml @@ -129,14 +129,14 @@ jobs: --parameters commands="[ \"set -e\", \"sudo mkdir -p /etc/oplust\", - \"DB_HOST=\\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_RDS_ENDPOINT_PARAM' --query 'Parameter.Value' --output text)\", - \"DB_USER=\\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_USERNAME_PARAM' --query 'Parameter.Value' --output text)\", - \"DB_PASS=\\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_PASSWORD_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", - \"SERVICE_ENV=\\$(aws ssm get-parameter --region $AWS_REGION --name '${service_env_param}' --with-decryption --query 'Parameter.Value' --output text)\", - \"echo \\\"SPRING_DATASOURCE_URL=jdbc:mysql://\\$DB_HOST:3306/${DB_NAME}\\\" | sudo tee ${env_file} >/dev/null\", - \"echo \\\"SPRING_DATASOURCE_USERNAME=\\$DB_USER\\\" | sudo tee -a ${env_file} >/dev/null\", - \"echo \\\"SPRING_DATASOURCE_PASSWORD=\\$DB_PASS\\\" | sudo tee -a ${env_file} >/dev/null\", - \"printf '%s\\n' \\\"\\$SERVICE_ENV\\\" | sudo tee -a ${env_file} >/dev/null\", + \"DB_HOST=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_RDS_ENDPOINT_PARAM' --query 'Parameter.Value' --output text)\", + \"DB_USER=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_USERNAME_PARAM' --query 'Parameter.Value' --output text)\", + \"DB_PASS=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_PASSWORD_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", + \"SERVICE_ENV=\$(aws ssm get-parameter --region $AWS_REGION --name '${service_env_param}' --with-decryption --query 'Parameter.Value' --output text)\", + \"echo \\\"SPRING_DATASOURCE_URL=jdbc:mysql://\$DB_HOST:3306/${DB_NAME}\\\" | sudo tee ${env_file} >/dev/null\", + \"echo \\\"SPRING_DATASOURCE_USERNAME=\$DB_USER\\\" | sudo tee -a ${env_file} >/dev/null\", + \"echo \\\"SPRING_DATASOURCE_PASSWORD=\$DB_PASS\\\" | sudo tee -a ${env_file} >/dev/null\", + \"printf '%s\\n' \\\"\$SERVICE_ENV\\\" | sudo tee -a ${env_file} >/dev/null\", \"sudo chmod 600 ${env_file}\", \"aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY\", \"sudo docker pull ${image_uri}\", @@ -184,4 +184,4 @@ jobs: deploy_service "${PROJECT_NAME}-user-ec2" "${ECR_REGISTRY}/oplust-api-user:${IMAGE_TAG}" "oplust-api-user" "/etc/oplust/api-user.env" "8080" "/oplust/api-user/env" deploy_service "${PROJECT_NAME}-admin-ec2" "${ECR_REGISTRY}/oplust-api-admin:${IMAGE_TAG}" "oplust-api-admin" "/etc/oplust/api-admin.env" "8081" "/oplust/api-admin/env" - deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "/oplust/transcoder/env" \ No newline at end of file + deploy_service "${PROJECT_NAME}-worker-ec2" "${ECR_REGISTRY}/oplust-transcoder:${IMAGE_TAG}" "oplust-transcoder" "/etc/oplust/transcoder.env" "" "/oplust/transcoder/env" From 9f38e417c4fbaa3ab5b68cc69ceea34c80228fdd Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 26 Feb 2026 16:49:41 +0900 Subject: [PATCH 149/257] =?UTF-8?q?[OT-134][FIX]:=20Admin=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20back-office=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_admin/auth/controller/AdminAuthController.java | 2 +- .../main/java/com/ott/api_admin/config/SecurityConfig.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java index 2a47cd1..127aca8 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/back-office/admin") +@RequestMapping("/back-office") @RequiredArgsConstructor public class AdminAuthController implements AdminAuthApi { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index f2b352d..882e022 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -39,8 +39,8 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/back-office/actuator/health/**", "/back-office/actuator/info", - "/back-office/admin/login", - "/back-office/admin/reissue", + "/back-office/login", + "/back-office/reissue", "/back-office/swagger-ui/**", "/back-office/v3/api-docs/**", "/back-office/swagger-resources/**" @@ -56,4 +56,4 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file +} From 24ed74746570903844c2279a358a9fa69efd7b33 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 17:30:43 +0900 Subject: [PATCH 150/257] =?UTF-8?q?[FIX]:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/common/web/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 6906419..eb568b2 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -48,7 +48,7 @@ public enum ErrorCode { INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), - MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B07", "미디어를 찾을 수 없습니다.") + MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다.") From d52fde2c1c2198ba37f5f103c28b1c7e9bf71064 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 17:41:34 +0900 Subject: [PATCH 151/257] =?UTF-8?q?[FIX]:=20=EC=B2=AB=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=B6=84=EB=A6=AC=20=EC=B2=98=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/likes/service/LikesService.java | 8 +++++--- .../com/ott/domain/likes/repository/LikesRepository.java | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java index 9a93f9e..c2e7887 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java @@ -29,7 +29,8 @@ public class LikesService { /** * 좋아요 버튼 * CONTENTS → 시리즈 에피소드면 부모 Series.media로 처리 - * SHORT_FORM → 시리즈 소속 숏폼이면 부모 Series.media로 처리 + * CONTENTS → 시리즈가 아닐경우 자기 자신 그래도 처리 + * SHORT_FORM → 그대로 처리 * SERIES → 그대로 처리 */ @Transactional @@ -41,10 +42,11 @@ public void editLikes(Long memberId, Long mediaId) { // 실제 좋아요 처리할 미디어 결정 Media targetMedia = resolveTargetMedia(findMedia); - likesRepository.findByMemberIdAndMediaIdAndStatus(memberId, targetMedia.getId(), Status.ACTIVE) + // likes 테이블에서 처음 등록했는지 여부를 판단함 + likesRepository.findByMemberIdAndMediaId(memberId, targetMedia.getId()) .ifPresentOrElse( likes -> { - // 한번 더 상태 검증 + if (likes.getStatus() == Status.ACTIVE) { // 기록이 있을 경우 ACTIVE → DELETE + 카운트 감소 likes.updateStatus(Status.DELETE); diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index 6b76047..1e01854 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -12,4 +12,6 @@ public interface LikesRepository extends JpaRepository { Optional findByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + + Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); } From 2bc2a9b66201ca8971b61b90b9f3c2a3686bd2de Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 20:08:03 +0900 Subject: [PATCH 152/257] =?UTF-8?q?[BUILD]:=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-user/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index 5a3ef52..f37cd84 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -7,7 +7,6 @@ dependencies { implementation project(':modules:common-security') implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' From bb19d9790ecbf8286eb438494f9adbf3b34b8369 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 21:35:08 +0900 Subject: [PATCH 153/257] =?UTF-8?q?[FIX]:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/common/web/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index bc1a11f..981f202 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -48,7 +48,7 @@ public enum ErrorCode { INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), - MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), + MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B009", "북마크를 찾을 수 없습니다") From ce3311f7d3797515fd919705f4c0d3e5e9e88177 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 21:35:49 +0900 Subject: [PATCH 154/257] =?UTF-8?q?[REFACTOR]:=20=EC=95=88=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EC=9E=84=ED=8F=AC=ED=8A=B8=EB=AC=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/likes/service/LikesService.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java index c2e7887..4ad3d21 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java @@ -10,7 +10,6 @@ import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.member.domain.Member; import com.ott.domain.member.repository.MemberRepository; -import com.ott.domain.short_form.repository.ShortFormRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,7 +22,6 @@ public class LikesService { private final MemberRepository memberRepository; private final MediaRepository mediaRepository; private final ContentsRepository contentsRepository; - private final ShortFormRepository shortFormRepository; /** @@ -37,7 +35,7 @@ public class LikesService { public void editLikes(Long memberId, Long mediaId) { Media findMedia = mediaRepository.findById(mediaId) - .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUNT)); + .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); // 실제 좋아요 처리할 미디어 결정 Media targetMedia = resolveTargetMedia(findMedia); From f6d99926c18417658cf6bf0fb4105ff0fa064af4 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 21:37:16 +0900 Subject: [PATCH 155/257] =?UTF-8?q?[FIX]:=20mediaType=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 2 +- .../bookmark/service/BookmarkService.java | 50 +++++++++++++++---- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index 50c39b6..e38e71f 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -21,7 +21,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -@Validated + @RestController @RequiredArgsConstructor @RequestMapping("/bookmarks") diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index 40da01a..9ba5c0a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -10,6 +10,7 @@ import com.ott.domain.bookmark.repository.BookmarkRepository; import com.ott.domain.common.MediaType; import com.ott.domain.common.Status; +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.member.domain.Member; @@ -30,12 +31,16 @@ public class BookmarkService { private final BookmarkRepository bookmarkRepository; private final MemberRepository memberRepository; private final MediaRepository mediaRepository; + private final ContentsRepository contentsRepository; + /** * 북마크 수정 - * 해당 유저의 ACTIVE 북마크가 있을 경우 -> 상태 DELETE + 카운트 감소 - * 해당 유저의 media의 북마크가 없을 경우 -> insert 상태 ACTIVE + 카운트 증가 - * 해당 유저의 media의 북마크가 있는데 DELETE 일 경우 -> ACTIVE 변경 + 카운트 증가 + * CONTENTS → 시리즈 에피소드면 부모 Series.media로 처리 + * CONTENTS → 시리즈가 아닐 경우 자기 자신 그대로 처리 + * SHORT_FORM → 자기 자신 그대로 처리 + * SERIES → 자기 자신 그대로 처리 + * 해당 메소드로 bookmark 테이블에는 시리즈 / 단편 시나리오 / 숏폼만 저장됨 */ @Transactional public void editBookmark(Long memberId, Long mediaId) { @@ -43,18 +48,21 @@ public void editBookmark(Long memberId, Long mediaId) { Media findMedia = mediaRepository.findById(mediaId) .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); + // 실제 북마크 처리할 타겟 미디어 결정 + Media targetMedia = resolveTargetMedia(findMedia); + // 해당 유저가 해당 미디어에 대해서 북마크를 했는지 여부 체크 - bookmarkRepository.findByMemberIdAndMediaId(memberId, mediaId) + bookmarkRepository.findByMemberIdAndMediaId(memberId, targetMedia.getId()) .ifPresentOrElse( bookmark -> { // 이미 해당 미디어에 대해서 북마크한 경우 -> DELETE 변경 이후 + 카운트 감소 if (bookmark.getStatus() == Status.ACTIVE) { bookmark.updateStatus(Status.DELETE); - findMedia.decreaseBookmarkCount(); + targetMedia.decreaseBookmarkCount(); } else { // 해당 미디어에 대해 북마크를 안한 경우 -> ACTIVE 변경 이후 + 카운트 증가 bookmark.updateStatus(Status.ACTIVE); - findMedia.increaseBookmarkCount(); + targetMedia.increaseBookmarkCount(); } }, () -> { @@ -64,21 +72,22 @@ public void editBookmark(Long memberId, Long mediaId) { bookmarkRepository.save(Bookmark.builder() .member(findMember) - .media(findMedia) + .media(targetMedia) .build()); // 상태 default가 ACTIVE임 - findMedia.increaseBookmarkCount(); + targetMedia.increaseBookmarkCount(); } ); } // 북마크 리스트 조회 + // 이미 DB상에는 시리즈 원본 / 시나리오 / 숏폼만 저장되어 있음 @Transactional(readOnly = true) - public PageResponse getBookmarkMediaList(Long memberId, int page, int size) { + public PageResponse getBookmarkMediaList(Long memberId, Integer page, Integer size) { Pageable pageable = PageRequest.of(page, size); - // 해당 유저의 ACTIVE인 북마크한 콘텐츠 OR 시리즈 타입 목록 페이징 조회 (fetch Join) + // ACTIVE && bookmark.media.mediaType 기준으로 CONTENTS, SERIES 필터링 Page bookmarkPage = bookmarkRepository.findByMemberIdAndStatusAndMedia_MediaTypeInOrderByCreatedDateDesc( memberId, @@ -105,7 +114,7 @@ public PageResponse getBookmarkMediaList(Long memberId, i // 숏폼 리스트 조회 @Transactional(readOnly = true) - public PageResponse getBookmarkShortFormList(Long memberId, int page, int size) { + public PageResponse getBookmarkShortFormList(Long memberId, Integer page, Integer size) { Pageable pageable = PageRequest.of(page, size); @@ -131,4 +140,23 @@ public PageResponse getBookmarkShortFormList(Long mem return PageResponse.toPageResponse(pageInfo, dataList); } + + + /** + * mediaType에 따라 실제 북마크 처리할 타겟 Media 반환 + * CONTENTS → series 소속이면 series.media 반환 + * CONTENTS → series 소속이 아니면 자기 자신 media 반환 + * SHORT_FORM → 자기 자신 media 반환 + * SERIES → 자기 자신 media 반환 + */ + private Media resolveTargetMedia(Media media) { + return switch (media.getMediaType()) { + case CONTENTS -> contentsRepository.findByMediaId(media.getId()) + .filter(contents -> contents.getSeries() != null) + .map(contents -> contents.getSeries().getMedia()) + .orElse(media); + + case SERIES, SHORT_FORM -> media; + }; + } } From 2cb41df411096d9465e0c1a1fb04a4e645878520 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 21:37:52 +0900 Subject: [PATCH 156/257] =?UTF-8?q?[FIX]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_user/bookmark/controller/BookmarkAPI.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java index 5dd100d..fc46e4d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -25,9 +25,9 @@ @Tag(name = "Bookmark API", description = "북마크 관련 API입니다.") public interface BookmarkAPI { - @Operation(summary = "북마크 편집", description = "미디어에 대한 북마크를 수정합니다.") + @Operation(summary = "북마크 수정", description = "미디어에 대한 북마크를 수정합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "북마크 수정 성공"), + @ApiResponse(responseCode = "204", description = "북마크 수정 성공"), @ApiResponse(responseCode = "404", description = "미디어 또는 사용자를 찾을 수 없음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), From 91e2b80fc44062d0c4ff656a182247e52c97140c Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 23:10:46 +0900 Subject: [PATCH 157/257] =?UTF-8?q?[CHORE]:=20gitkeep=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_user/comment/controller/.gitkeep | 0 apps/api-user/src/main/java/com/ott/api_user/comment/dto/.gitkeep | 0 .../src/main/java/com/ott/api_user/comment/service/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/controller/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/dto/.gitkeep delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/service/.gitkeep diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/.gitkeep b/apps/api-user/src/main/java/com/ott/api_user/comment/service/.gitkeep deleted file mode 100644 index e69de29..0000000 From efcbea5132cb65599d76b60a8831c3c8b8f97a8a Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 23:11:24 +0900 Subject: [PATCH 158/257] =?UTF-8?q?[STYLE]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/api_user/likes/controller/LikesAPI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java index c3163d5..a53214e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -@Tag(name = "좋아요 API", description = "좋아요 관련 API입니다.") +@Tag(name = "Likes API", description = "좋아요 API") public interface LikesAPI { @Operation(summary = "좋아요 API", description = "좋아요 상태를 변경합니다. 등록/취소 모두 이 API를 사용합니다.") From 0e7e7d6c411268025424031bafb29d48de990718 Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 23:11:43 +0900 Subject: [PATCH 159/257] =?UTF-8?q?[FEAT]:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/common/web/exception/ErrorCode.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index eb568b2..15d90dc 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -48,7 +48,9 @@ public enum ErrorCode { INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), - MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다.") + MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B008", "댓글을 찾을 수 없습니다."), + COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "B009", "본인이 작성한 댓글만 수정/삭제할 수 있습니다."), From f8063da23bd72e81d88ece40481d5ac03c7c213e Mon Sep 17 00:00:00 2001 From: marulog Date: Thu, 26 Feb 2026 23:12:27 +0900 Subject: [PATCH 160/257] =?UTF-8?q?[FEAT]:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 29 +++++++++ .../dto/request/CreateCommentRequest.java | 26 ++++++++ .../comment/dto/response/CommentResponse.java | 65 +++++++++++++++++++ .../comment/repository/CommentRepository.java | 16 +++++ .../repository/ContentsRepository.java | 4 ++ 5 files changed, 140 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java new file mode 100644 index 0000000..0e9b146 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -0,0 +1,29 @@ +package com.ott.api_user.comment.controller; + +import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.service.CommentService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments") +public class CommentController { + + private final CommentService commentService; + + // 댓글 등록 + @PostMapping + public ResponseEntity> creatComment( + @Valid @RequestBody CreateCommentRequest request, + @AuthenticationPrincipal Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of(commentService.createComment(memberId, request))); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java new file mode 100644 index 0000000..2131cec --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java @@ -0,0 +1,26 @@ +package com.ott.api_user.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@NoArgsConstructor +@Schema(description = "댓글 등록 요청 DTO") +public class CreateCommentRequest { + + @NotNull(message = "콘텐츠 ID는 필수 입니다.") + @Schema(type= "Long", example = "1", description = "댓글 ID") + private Long contentId; + + @NotBlank(message = "댓글 내용은 필수 입니다.") + @Size(max = 100, message = "댓글은 100자 이내로 입력해주세요.") + @Schema(type= "String", example = "아 ㅋㅋ 밤티하둥", description = "댓글") + private String content; + + @Schema(type= "Boolean", example = "true", description = "스포 유무, 디폴트 false") + private Boolean isSpoiler = false; + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..b795d41 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/CommentResponse.java @@ -0,0 +1,65 @@ +package com.ott.api_user.comment.dto.response; + +import com.ott.domain.comment.domain.Comment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "댓글 응답 DTO") +public class CommentResponse { + + @Schema(type = "Long", example = "1", description = "댓글 ID") + private Long commentId; + + @Schema(type = "Long", example = "5", description = "콘텐츠 ID") + private Long contentsId; + + @Schema(type = "String", example = "밤티 하네 ㅋㅋ", description = "댓글 내용") + private String content; + + @Schema(type = "Boolean", example = "false", description = "스포일러 포함 여부") + private Boolean isSpoiler; + + @Schema(type = "LocalDateTime ", example = "2026.02.15 20:14", description = "작성일시") + private LocalDateTime createdDate; + + @Schema(description = "작성자 정보") + private WriterInfo writer; + + public static CommentResponse from(Comment comment) { + return CommentResponse.builder() + .commentId(comment.getId()) + .contentsId(comment.getContents().getId()) + .content(comment.getContent()) + .isSpoiler(comment.getIsSpoiler()) + .createdDate(comment.getCreatedDate()) + .writer(WriterInfo.from(comment)) + .build(); + } + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 작성자 정보") + public static class WriterInfo { + + @Schema(type = "Long", example = "10", description = "작성자 회원 ID") + private Long memberId; + + @Schema(type = "String", example = "김마루", description = "작성자 닉네임") + private String nickname; + + public static WriterInfo from(Comment comment) { + return WriterInfo.builder() + .memberId(comment.getMember().getId()) + .nickname(comment.getMember().getNickname()) + .build(); + } + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..7ffb85a --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java @@ -0,0 +1,16 @@ +package com.ott.domain.comment.repository; + +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"member", "contents", "contents.media"}) + Optional findWithDetailsByIdAndStatus(Long id, Status status); + + Optional findByIdAndStatus(Long id, Status status); +} diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 9ef3bdf..35365c2 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -44,4 +44,8 @@ Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long ser // 좋아요 처리 시 series 소속 여부 확인용 @EntityGraph(attributePaths = {"series", "series.media"}) Optional findByMediaId(Long mediaId); + + // 댓글 작성 시 콘텐츠 조회 + @EntityGraph(attributePaths = {"media"}) + Optional findByIdAndStatus(Long id, Status status); } \ No newline at end of file From 0174bd8a632a1c3c9f1e1df9add45e1acc5088ae Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 09:00:31 +0900 Subject: [PATCH 161/257] =?UTF-8?q?[FEAT]:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 21 ++++-- .../dto/request/UpdateCommentRequest.java | 21 ++++++ .../comment/service/CommentService.java | 67 +++++++++++++++++++ .../ott/domain/comment/domain/Comment.java | 8 +++ .../comment/repository/CommentRepository.java | 2 - 5 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index 0e9b146..5e60349 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -1,29 +1,36 @@ package com.ott.api_user.comment.controller; import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.request.UpdateCommentRequest; import com.ott.api_user.comment.dto.response.CommentResponse; import com.ott.api_user.comment.service.CommentService; import com.ott.common.web.response.SuccessResponse; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @RequestMapping("/comments") -public class CommentController { +public class CommentController implements CommentApi { private final CommentService commentService; // 댓글 등록 + @Override @PostMapping - public ResponseEntity> creatComment( - @Valid @RequestBody CreateCommentRequest request, - @AuthenticationPrincipal Long memberId) { + public ResponseEntity> createComment( + CreateCommentRequest request, Long memberId) { return ResponseEntity.ok(SuccessResponse.of(commentService.createComment(memberId, request))); } + + // 댓글 수정 + @Override + @PutMapping("/{commentId}") + public ResponseEntity> updateComment( + Long commentId, UpdateCommentRequest request, Long memberId) { + + return ResponseEntity.ok(SuccessResponse.of(commentService.updateComment(memberId, commentId, request))); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java new file mode 100644 index 0000000..e4c7a2a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java @@ -0,0 +1,21 @@ +package com.ott.api_user.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "댓글 수정 요청 DTO") +public class UpdateCommentRequest { + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Size(max = 100, message = "댓글은 100자 이내로 입력해주세요") + @Schema(type= "String", example = "아 ㅋㅋ 밤티하둥_수정123", description = "댓글") + private String content; + + @Schema(type= "Boolean", example = "true", description = "스포일러 포함 여부, 디폴트 false") + private Boolean isSpoiler; +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java new file mode 100644 index 0000000..9a170dd --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java @@ -0,0 +1,67 @@ +package com.ott.api_user.comment.service; + +import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.request.UpdateCommentRequest; +import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.comment.repository.CommentRepository; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.member.domain.Member; +import com.ott.domain.member.repository.MemberRepository; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final ContentsRepository contentsRepository; + private final MemberRepository memberRepository; + + // 댓글 작성 + @Transactional + public CommentResponse createComment(Long memberId, CreateCommentRequest request) { + + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Contents contents = contentsRepository.findByIdAndStatus(request.getContentId(), Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // 한 유저가 한 콘텐츠에 여러 댓글 허용? + Comment saved = commentRepository.save( + Comment.builder() + .member(findMember) + .contents(contents) + .content(request.getContent()) + .isSpoiler(request.getIsSpoiler()) + .build() + );; + + return CommentResponse.from(saved); + + } + + // 댓글 수정 - 본인 댓글만 수정 가능 + @Transactional + public CommentResponse updateComment(Long memberId, Long commentId, @Valid UpdateCommentRequest request) { + Comment comment = commentRepository.findByIdAndStatus(commentId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + // 본인만 수정 가능 + if (!comment.getMember().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.COMMENT_FORBIDDEN); + } + + comment.update(request.getContent(), request.getIsSpoiler()); + + return CommentResponse.from(comment); + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java b/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java index ef85b1d..5678a07 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java @@ -43,4 +43,12 @@ public class Comment extends BaseEntity { @Column(name = "is_spoiler", nullable = false) private Boolean isSpoiler; + + + // 댓글 수정 + public void update(String content, Boolean isSpoiler) { + this.content = content; + this.isSpoiler = isSpoiler; + } } + diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java index 7ffb85a..0c52833 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java @@ -10,7 +10,5 @@ public interface CommentRepository extends JpaRepository { @EntityGraph(attributePaths = {"member", "contents", "contents.media"}) - Optional findWithDetailsByIdAndStatus(Long id, Status status); - Optional findByIdAndStatus(Long id, Status status); } From 75fbe5ef0c97b6cb9efeb20a4cff8cb274ce88b7 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Fri, 27 Feb 2026 10:54:54 +0900 Subject: [PATCH 162/257] =?UTF-8?q?[OT-125][FIX]:=20ErrorCode=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=8A=A4=20=EC=BD=94=EB=93=9C(B0XX)=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/common/web/exception/ErrorCode.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 872c96a..9b4d163 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -49,11 +49,11 @@ public enum ErrorCode { CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), - BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B008", "북마크를 찾을 수 없습니다") + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B008", "북마크를 찾을 수 없습니다"), UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B009", "지원하지 않는 이미지 확장자입니다."), - UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B0010", "지원하지 않는 동영상 확장자입니다."), - INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B0011", "파일 확장자가 올바르지 않습니다."), + UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B010", "지원하지 않는 동영상 확장자입니다."), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B011", "파일 확장자가 올바르지 않습니다."), INVALID_TAG_CATEGORY(HttpStatus.NOT_FOUND, "B012", "유효한 카테고리를 찾을 수 없습니다."), INVALID_TAG_SELECTION(HttpStatus.BAD_REQUEST, "B013", "카테고리에 맞지 않거나 존재하지 않는 태그가 포함되어 있습니다."), DUPLICATE_TAG_IN_LIST(HttpStatus.BAD_REQUEST, "B014", "태그 목록에 중복된 값이 있습니다."), From 55774067758770ad4e9ffd7c1fd492b6e2380235 Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 11:01:03 +0900 Subject: [PATCH 163/257] =?UTF-8?q?[FEAT]:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20softDelete=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 19 +++++++++++++-- .../comment/service/CommentService.java | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index 5e60349..6286071 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -5,8 +5,10 @@ import com.ott.api_user.comment.dto.response.CommentResponse; import com.ott.api_user.comment.service.CommentService; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -20,7 +22,8 @@ public class CommentController implements CommentApi { @Override @PostMapping public ResponseEntity> createComment( - CreateCommentRequest request, Long memberId) { + CreateCommentRequest request, + @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of(commentService.createComment(memberId, request))); } @@ -29,8 +32,20 @@ public ResponseEntity> createComment( @Override @PutMapping("/{commentId}") public ResponseEntity> updateComment( - Long commentId, UpdateCommentRequest request, Long memberId) { + Long commentId, + UpdateCommentRequest request, + @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of(commentService.updateComment(memberId, commentId, request))); } + + // 댓글 삭제 + @DeleteMapping("/{commentId}") + public ResponseEntity> deleteComment( + Long commentId, Long memberId) { + + commentService.deleteComment(memberId, commentId); + + return ResponseEntity.ok(SuccessResponse.of(null)); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java index 9a170dd..94ba83e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java @@ -5,6 +5,8 @@ import com.ott.api_user.comment.dto.response.CommentResponse; 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.comment.domain.Comment; import com.ott.domain.comment.repository.CommentRepository; import com.ott.domain.common.Status; @@ -14,9 +16,13 @@ import com.ott.domain.member.repository.MemberRepository; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class CommentService { @@ -64,4 +70,21 @@ public CommentResponse updateComment(Long memberId, Long commentId, @Valid Updat return CommentResponse.from(comment); } + + // 댓글 삭제 - 본인 댓글만 삭제 가능 + @Transactional + public void deleteComment(Long memberId, Long commentId) { + Comment comment = commentRepository.findByIdAndStatus(commentId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + // 본인만 수정 가능 + if (!comment.getMember().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.COMMENT_FORBIDDEN); + } + + comment.updateStatus(Status.DELETE); + } + + + } From 52f51b2f9568880a00c89f6d2ea7612472bf0bf6 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Fri, 27 Feb 2026 11:33:48 +0900 Subject: [PATCH 164/257] =?UTF-8?q?[OT-139]=20[CHORE]:=20DB=20=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EC=A0=95=EB=B3=B4=20=EC=84=A4=EC=A0=95=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-ec2-docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-ec2-docker.yml b/.github/workflows/deploy-ec2-docker.yml index 7c66264..b939104 100644 --- a/.github/workflows/deploy-ec2-docker.yml +++ b/.github/workflows/deploy-ec2-docker.yml @@ -129,8 +129,8 @@ jobs: --parameters commands="[ \"set -e\", \"sudo mkdir -p /etc/oplust\", - \"DB_HOST=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_RDS_ENDPOINT_PARAM' --query 'Parameter.Value' --output text)\", - \"DB_USER=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_USERNAME_PARAM' --query 'Parameter.Value' --output text)\", + \"DB_HOST=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_RDS_ENDPOINT_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", + \"DB_USER=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_USERNAME_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", \"DB_PASS=\$(aws ssm get-parameter --region $AWS_REGION --name '$SSM_DB_PASSWORD_PARAM' --with-decryption --query 'Parameter.Value' --output text)\", \"SERVICE_ENV=\$(aws ssm get-parameter --region $AWS_REGION --name '${service_env_param}' --with-decryption --query 'Parameter.Value' --output text)\", \"echo \\\"SPRING_DATASOURCE_URL=jdbc:mysql://\$DB_HOST:3306/${DB_NAME}\\\" | sudo tee ${env_file} >/dev/null\", From 576e0015310d6bea754d225809902a37389f6842 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Fri, 27 Feb 2026 11:56:05 +0900 Subject: [PATCH 165/257] =?UTF-8?q?[FIX]:=20admin=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/api_admin/config/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 882e022..c9dc2be 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -37,8 +37,8 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .accessDeniedHandler(jwtAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/back-office/actuator/health/**", - "/back-office/actuator/info", + "/actuator/health/**", + "/actuator/info", "/back-office/login", "/back-office/reissue", "/back-office/swagger-ui/**", From 642ea519e5a407ec88d25bf9ed3ca46bd7b085fc Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 13:06:52 +0900 Subject: [PATCH 166/257] =?UTF-8?q?[FEAT]:=20=EB=82=B4=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentApi.java | 105 ++++++++++++++++++ .../comment/controller/CommentController.java | 24 +++- .../dto/response/MyCommentResponse.java | 45 ++++++++ .../comment/service/CommentService.java | 23 ++++ .../comment/repository/CommentRepository.java | 2 +- .../repository/CommentRepositoryCustom.java | 12 ++ .../repository/CommentRepositoryImpl.java | 50 +++++++++ 7 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java new file mode 100644 index 0000000..a93d9bb --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -0,0 +1,105 @@ +package com.ott.api_user.comment.controller; + +import com.ott.api_user.comment.dto.request.CreateCommentRequest; +import com.ott.api_user.comment.dto.request.UpdateCommentRequest; +import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.dto.response.MyCommentResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Comment API", description = "댓글 API") +@SecurityRequirement(name = "BearerAuth") +@RequestMapping("/comments") +public interface CommentApi { + + @Operation(summary = "댓글 작성", description = "콘텐츠에 댓글을 작성합니다. 스포일러 기본값은 false입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "댓글 작성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "콘텐츠 또는 회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + ResponseEntity> createComment( + @Valid @RequestBody CreateCommentRequest request, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "댓글 수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "본인 댓글이 아님", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @PutMapping("/{commentId}") + ResponseEntity> updateComment( + @Positive @Parameter(description = "댓글 ID") @PathVariable Long commentId, + @Valid @RequestBody UpdateCommentRequest request, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + @Operation(summary = "댓글 삭제", description = "본인이 작성한 댓글을 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "댓글 삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "본인 댓글이 아님", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/{commentId}") + ResponseEntity> deleteComment( + @Positive @Parameter(description = "댓글 ID") @PathVariable Long commentId, + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId + ); + + @Operation(summary = "내가 작성한 댓글 목록 조회", description = "내가 작성한 댓글 목록을 최신순으로 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me") + ResponseEntity>> getMyComments( + @PositiveOrZero @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(type = "integer", defaultValue = "0")) @RequestParam(defaultValue = "0") Integer page, + @PositiveOrZero @Parameter(description = "페이지 크기", schema = @Schema(type = "integer", defaultValue = "20")) @RequestParam(defaultValue = "20") Integer size, + @Parameter(hidden = true) Long memberId + ); + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index 6286071..b3650d8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -3,9 +3,12 @@ import com.ott.api_user.comment.dto.request.CreateCommentRequest; import com.ott.api_user.comment.dto.request.UpdateCommentRequest; import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.dto.response.MyCommentResponse; import com.ott.api_user.comment.service.CommentService; +import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -22,7 +25,7 @@ public class CommentController implements CommentApi { @Override @PostMapping public ResponseEntity> createComment( - CreateCommentRequest request, + @RequestBody CreateCommentRequest request, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of(commentService.createComment(memberId, request))); @@ -32,20 +35,33 @@ public ResponseEntity> createComment( @Override @PutMapping("/{commentId}") public ResponseEntity> updateComment( - Long commentId, - UpdateCommentRequest request, + @PathVariable Long commentId, + @RequestBody UpdateCommentRequest request, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok(SuccessResponse.of(commentService.updateComment(memberId, commentId, request))); } // 댓글 삭제 + @Override @DeleteMapping("/{commentId}") public ResponseEntity> deleteComment( - Long commentId, Long memberId) { + @PathVariable Long commentId, + @AuthenticationPrincipal Long memberId) { commentService.deleteComment(memberId, commentId); return ResponseEntity.ok(SuccessResponse.of(null)); } + + // 댓글 조회 - 본인 댓글만 조회 가능, 최신순 정렬 + @Override + @GetMapping("/me") + public ResponseEntity>> getMyComments( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size, + @AuthenticationPrincipal Long memberId) { + return ResponseEntity.ok(SuccessResponse.of(commentService.getMyComments(memberId, page, size))); + } + } diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java new file mode 100644 index 0000000..a869bd3 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java @@ -0,0 +1,45 @@ +package com.ott.api_user.comment.dto.response; + +import com.ott.domain.comment.domain.Comment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "내 댓글 목록 응답 DTO") +public class MyCommentResponse { + + @Schema(type = "Long", example = "1", description = "댓글 ID") + private Long commentId; + + @Schema(type = "String", example = "주인공 43:23초에 죽음 ㅋㅋ", description = "댓글 내용") + private String content; + + @Schema(type = "String", example = "https://cdn.example.com/poster.jpg", description = "콘텐츠 포스터 URL") + private String contentsPosterUrl; + + @Schema(type = "Long", example = "10", description = "작성자 회원 ID") + private Long writerId; + + @Schema(type = "String", example = "김마루", description = "작성자 닉네임") + private String writerNickname; + + @Schema(type = "LocalDateTime", description = "작성일시") + private LocalDateTime createdDate; + + public static MyCommentResponse from(Comment comment) { + return MyCommentResponse.builder() + .commentId(comment.getId()) + .content(comment.getContent()) + .contentsPosterUrl(comment.getContents().getMedia().getPosterUrl()) + .writerId(comment.getMember().getId()) + .writerNickname(comment.getMember().getNickname()) + .createdDate(comment.getCreatedDate()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java index 94ba83e..f13ec34 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java @@ -3,6 +3,7 @@ import com.ott.api_user.comment.dto.request.CreateCommentRequest; import com.ott.api_user.comment.dto.request.UpdateCommentRequest; import com.ott.api_user.comment.dto.response.CommentResponse; +import com.ott.api_user.comment.dto.response.MyCommentResponse; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -85,6 +86,28 @@ public void deleteComment(Long memberId, Long commentId) { comment.updateStatus(Status.DELETE); } + // 댓글 조회 - 본인 댓글만 조회 가능(최신순) + public PageResponse getMyComments( + Long memberId, + Integer page, + Integer size) { + + PageRequest pageable = PageRequest.of(page, size); + + Page commentPage = commentRepository.findMyComments(memberId, Status.ACTIVE, pageable); + + List responseList = commentPage.getContent().stream() + .map(MyCommentResponse::from) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + commentPage.getNumber(), + commentPage.getTotalPages(), + commentPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, responseList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java index 0c52833..79f2049 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java @@ -7,7 +7,7 @@ import java.util.Optional; -public interface CommentRepository extends JpaRepository { +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { @EntityGraph(attributePaths = {"member", "contents", "contents.media"}) Optional findByIdAndStatus(Long id, Status status); diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..2c048fc --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.ott.domain.comment.repository; + +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CommentRepositoryCustom { + + // 내가 작성한 댓글 목록 조회 (페이징, 최신순) + Page findMyComments(Long memberId, Status status, Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java new file mode 100644 index 0000000..e18fc91 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.ott.domain.comment.repository; + +import com.ott.domain.comment.domain.Comment; +import com.ott.domain.common.Status; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +import static com.ott.domain.comment.domain.QComment.comment; +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.member.domain.QMember.member; + +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMyComments(Long memberId, Status status, Pageable pageable) { + List commentList = queryFactory + .selectFrom(comment) + .join(comment.member, member).fetchJoin() + .join(comment.contents, contents).fetchJoin() + .join(contents.media, media).fetchJoin() + .where( + comment.member.id.eq(memberId), + comment.status.eq(status) + ) + .orderBy(comment.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(comment.count()) + .from(comment) + .where( + comment.member.id.eq(memberId), + comment.status.eq(status) + ); + + return PageableExecutionUtils.getPage(commentList, pageable, countQuery::fetchOne); + } +} \ No newline at end of file From 28bde47fe11e6d320de736f88fa1394c631e9719 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:18:46 +0900 Subject: [PATCH 167/257] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=B0=98=EC=98=81=20=EC=88=98=EC=A0=95=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mvn/wrapper/maven-wrapper.jar | Bin 50710 -> 0 bytes .mvn/wrapper/maven-wrapper.properties | 2 -- .../comment/controller/CommentApi.java | 27 +++++++++--------- .../comment/controller/CommentController.java | 5 ++-- .../content/controller/ContentApi.java | 2 +- .../api_user/series/controller/SeriesApi.java | 2 +- .../series/service/SeriesService.java | 25 ++-------------- 7 files changed, 20 insertions(+), 43 deletions(-) delete mode 100644 .mvn/wrapper/maven-wrapper.jar delete mode 100644 .mvn/wrapper/maven-wrapper.properties diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index 2cc7d4a55c0cd0092912bf49ae38b3a9e3fd0054..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50710 zcmbTd1CVCTmM+|7+wQV$+qP}n>auOywyU~q+qUhh+uxis_~*a##hm*_WW?9E7Pb7N%LRFiwbEGCJ0XP=%-6oeT$XZcYgtzC2~q zk(K08IQL8oTl}>>+hE5YRgXTB@fZ4TH9>7=79e`%%tw*SQUa9~$xKD5rS!;ZG@ocK zQdcH}JX?W|0_Afv?y`-NgLum62B&WSD$-w;O6G0Sm;SMX65z)l%m1e-g8Q$QTI;(Q z+x$xth4KFvH@Bs6(zn!iF#nenk^Y^ce;XIItAoCsow38eq?Y-Auh!1in#Rt-_D>H^ z=EjbclGGGa6VnaMGmMLj`x3NcwA43Jb(0gzl;RUIRAUDcR1~99l2SAPkVhoRMMtN} zXvC<tOmX83grD8GSo_Lo?%lNfhD#EBgPo z*nf@ppMC#B!T)Ae0RG$mlJWmGl7CkuU~B8-==5i;rS;8i6rJ=PoQxf446XDX9g|c> zU64ePyMlsI^V5Jq5A+BPe#e73+kpc_r1tv#B)~EZ;7^67F0*QiYfrk0uVW;Qb=NsG zN>gsuCwvb?s-KQIppEaeXtEMdc9dy6Dfduz-tMTms+i01{eD9JE&h?Kht*$eOl#&L zJdM_-vXs(V#$Ed;5wyNWJdPNh+Z$+;$|%qR(t`4W@kDhd*{(7-33BOS6L$UPDeE_53j${QfKN-0v-HG z(QfyvFNbwPK%^!eIo4ac1;b>c0vyf9}Xby@YY!lkz-UvNp zwj#Gg|4B~?n?G^{;(W;|{SNoJbHTMpQJ*Wq5b{l9c8(%?Kd^1?H1om1de0Da9M;Q=n zUfn{f87iVb^>Exl*nZ0hs(Yt>&V9$Pg`zX`AI%`+0SWQ4Zc(8lUDcTluS z5a_KerZWe}a-MF9#Cd^fi!y3%@RFmg&~YnYZ6<=L`UJ0v={zr)>$A;x#MCHZy1st7 ztT+N07NR+vOwSV2pvWuN1%lO!K#Pj0Fr>Q~R40{bwdL%u9i`DSM4RdtEH#cW)6}+I-eE< z&tZs+(Ogu(H_;$a$!7w`MH0r%h&@KM+<>gJL@O~2K2?VrSYUBbhCn#yy?P)uF3qWU z0o09mIik+kvzV6w>vEZy@&Mr)SgxPzUiDA&%07m17udz9usD82afQEps3$pe!7fUf z0eiidkJ)m3qhOjVHC_M(RYCBO%CZKZXFb8}s0-+}@CIn&EF(rRWUX2g^yZCvl0bI} zbP;1S)iXnRC&}5-Tl(hASKqdSnO?ASGJ*MIhOXIblmEudj(M|W!+I3eDc}7t`^mtg z)PKlaXe(OH+q-)qcQ8a@!llRrpGI8DsjhoKvw9T;TEH&?s=LH0w$EzI>%u;oD@x83 zJL7+ncjI9nn!TlS_KYu5vn%f*@qa5F;| zEFxY&B?g=IVlaF3XNm_03PA)=3|{n-UCgJoTr;|;1AU9|kPE_if8!Zvb}0q$5okF$ zHaJdmO&gg!9oN|M{!qGE=tb|3pVQ8PbL$}e;NgXz<6ZEggI}wO@aBP**2Wo=yN#ZC z4G$m^yaM9g=|&!^ft8jOLuzc3Psca*;7`;gnHm}tS0%f4{|VGEwu45KptfNmwxlE~ z^=r30gi@?cOm8kAz!EylA4G~7kbEiRlRIzwrb~{_2(x^$-?|#e6Bi_**(vyr_~9Of z!n>Gqf+Qwiu!xhi9f53=PM3`3tNF}pCOiPU|H4;pzjcsqbwg*{{kyrTxk<;mx~(;; z1NMrpaQ`57yn34>Jo3b|HROE(UNcQash!0p2-!Cz;{IRv#Vp5!3o$P8!%SgV~k&Hnqhp`5eLjTcy93cK!3Hm-$`@yGnaE=?;*2uSpiZTs_dDd51U%i z{|Zd9ou-;laGS_x=O}a+ zB||za<795A?_~Q=r=coQ+ZK@@ zId~hWQL<%)fI_WDIX#=(WNl!Dm$a&ROfLTd&B$vatq!M-2Jcs;N2vps$b6P1(N}=oI3<3luMTmC|0*{ zm1w8bt7vgX($!0@V0A}XIK)w!AzUn7vH=pZEp0RU0p?}ch2XC-7r#LK&vyc2=-#Q2 z^L%8)JbbcZ%g0Du;|8=q8B>X=mIQirpE=&Ox{TiuNDnOPd-FLI^KfEF729!!0x#Es z@>3ursjFSpu%C-8WL^Zw!7a0O-#cnf`HjI+AjVCFitK}GXO`ME&on|^=~Zc}^LBp9 zj=-vlN;Uc;IDjtK38l7}5xxQF&sRtfn4^TNtnzXv4M{r&ek*(eNbIu!u$>Ed%` z5x7+&)2P&4>0J`N&ZP8$vcR+@FS0126s6+Jx_{{`3ZrIMwaJo6jdrRwE$>IU_JTZ} z(||hyyQ)4Z1@wSlT94(-QKqkAatMmkT7pCycEB1U8KQbFX&?%|4$yyxCtm3=W`$4fiG0WU3yI@c zx{wfmkZAYE_5M%4{J-ygbpH|(|GD$2f$3o_Vti#&zfSGZMQ5_f3xt6~+{RX=$H8at z?GFG1Tmp}}lmm-R->ve*Iv+XJ@58p|1_jRvfEgz$XozU8#iJS})UM6VNI!3RUU!{5 zXB(+Eqd-E;cHQ>)`h0(HO_zLmzR3Tu-UGp;08YntWwMY-9i^w_u#wR?JxR2bky5j9 z3Sl-dQQU$xrO0xa&>vsiK`QN<$Yd%YXXM7*WOhnRdSFt5$aJux8QceC?lA0_if|s> ze{ad*opH_kb%M&~(~&UcX0nFGq^MqjxW?HJIP462v9XG>j(5Gat_)#SiNfahq2Mz2 zU`4uV8m$S~o9(W>mu*=h%Gs(Wz+%>h;R9Sg)jZ$q8vT1HxX3iQnh6&2rJ1u|j>^Qf`A76K%_ubL`Zu?h4`b=IyL>1!=*%!_K)=XC z6d}4R5L+sI50Q4P3upXQ3Z!~1ZXLlh!^UNcK6#QpYt-YC=^H=EPg3)z*wXo*024Q4b2sBCG4I# zlTFFY=kQ>xvR+LsuDUAk)q%5pEcqr(O_|^spjhtpb1#aC& zghXzGkGDC_XDa%t(X`E+kvKQ4zrQ*uuQoj>7@@ykWvF332)RO?%AA&Fsn&MNzmFa$ zWk&&^=NNjxLjrli_8ESU)}U|N{%j&TQmvY~lk!~Jh}*=^INA~&QB9em!in_X%Rl1&Kd~Z(u z9mra#<@vZQlOY+JYUwCrgoea4C8^(xv4ceCXcejq84TQ#sF~IU2V}LKc~Xlr_P=ry zl&Hh0exdCbVd^NPCqNNlxM3vA13EI8XvZ1H9#bT7y*U8Y{H8nwGpOR!e!!}*g;mJ#}T{ekSb}5zIPmye*If(}}_=PcuAW#yidAa^9-`<8Gr0 z)Fz=NiZ{)HAvw{Pl5uu)?)&i&Us$Cx4gE}cIJ}B4Xz~-q7)R_%owbP!z_V2=Aq%Rj z{V;7#kV1dNT9-6R+H}}(ED*_!F=~uz>&nR3gb^Ce%+0s#u|vWl<~JD3MvS0T9thdF zioIG3c#Sdsv;LdtRv3ml7%o$6LTVL>(H`^@TNg`2KPIk*8-IB}X!MT0`hN9Ddf7yN z?J=GxPL!uJ7lqwowsl?iRrh@#5C$%E&h~Z>XQcvFC*5%0RN-Opq|=IwX(dq(*sjs+ zqy99+v~m|6T#zR*e1AVxZ8djd5>eIeCi(b8sUk)OGjAsKSOg^-ugwl2WSL@d#?mdl zib0v*{u-?cq}dDGyZ%$XRY=UkQwt2oGu`zQneZh$=^! zj;!pCBWQNtvAcwcWIBM2y9!*W|8LmQy$H~5BEx)78J`4Z0(FJO2P^!YyQU{*Al+fs z){!4JvT1iLrJ8aU3k0t|P}{RN)_^v%$$r;+p0DY7N8CXzmS*HB*=?qaaF9D@#_$SN zSz{moAK<*RH->%r7xX~9gVW$l7?b|_SYI)gcjf0VAUJ%FcQP(TpBs; zg$25D!Ry_`8xpS_OJdeo$qh#7U+cepZ??TII7_%AXsT$B z=e)Bx#v%J0j``00Zk5hsvv6%T^*xGNx%KN-=pocSoqE5_R)OK%-Pbu^1MNzfds)mL zxz^F4lDKV9D&lEY;I+A)ui{TznB*CE$=9(wgE{m}`^<--OzV-5V4X2w9j(_!+jpTr zJvD*y6;39&T+==$F&tsRKM_lqa1HC}aGL0o`%c9mO=fts?36@8MGm7Vi{Y z^<7m$(EtdSr#22<(rm_(l_(`j!*Pu~Y>>xc>I9M#DJYDJNHO&4=HM%YLIp?;iR&$m z#_$ZWYLfGLt5FJZhr3jpYb`*%9S!zCG6ivNHYzNHcI%khtgHBliM^Ou}ZVD7ehU9 zS+W@AV=?Ro!=%AJ>Kcy9aU3%VX3|XM_K0A+ZaknKDyIS3S-Hw1C7&BSW5)sqj5Ye_ z4OSW7Yu-;bCyYKHFUk}<*<(@TH?YZPHr~~Iy%9@GR2Yd}J2!N9K&CN7Eq{Ka!jdu; zQNB*Y;i(7)OxZK%IHGt#Rt?z`I|A{q_BmoF!f^G}XVeTbe1Wnzh%1g>j}>DqFf;Rp zz7>xIs12@Ke0gr+4-!pmFP84vCIaTjqFNg{V`5}Rdt~xE^I;Bxp4)|cs8=f)1YwHz zqI`G~s2~qqDV+h02b`PQpUE#^^Aq8l%y2|ByQeXSADg5*qMprEAE3WFg0Q39`O+i1 z!J@iV!`Y~C$wJ!5Z+j5$i<1`+@)tBG$JL=!*uk=2k;T<@{|s1$YL079FvK%mPhyHV zP8^KGZnp`(hVMZ;s=n~3r2y;LTwcJwoBW-(ndU-$03{RD zh+Qn$ja_Z^OuMf3Ub|JTY74s&Am*(n{J3~@#OJNYuEVVJd9*H%)oFoRBkySGm`hx! zT3tG|+aAkXcx-2Apy)h^BkOyFTWQVeZ%e2@;*0DtlG9I3Et=PKaPt&K zw?WI7S;P)TWED7aSH$3hL@Qde?H#tzo^<(o_sv_2ci<7M?F$|oCFWc?7@KBj-;N$P zB;q!8@bW-WJY9do&y|6~mEruZAVe$!?{)N9rZZxD-|oltkhW9~nR8bLBGXw<632!l z*TYQn^NnUy%Ds}$f^=yQ+BM-a5X4^GHF=%PDrRfm_uqC zh{sKwIu|O0&jWb27;wzg4w5uA@TO_j(1X?8E>5Zfma|Ly7Bklq|s z9)H`zoAGY3n-+&JPrT!>u^qg9Evx4y@GI4$n-Uk_5wttU1_t?6><>}cZ-U+&+~JE) zPlDbO_j;MoxdLzMd~Ew|1o^a5q_1R*JZ=#XXMzg?6Zy!^hop}qoLQlJ{(%!KYt`MK z8umEN@Z4w!2=q_oe=;QttPCQy3Nm4F@x>@v4sz_jo{4m*0r%J(w1cSo;D_hQtJs7W z><$QrmG^+<$4{d2bgGo&3-FV}avg9zI|Rr(k{wTyl3!M1q+a zD9W{pCd%il*j&Ft z5H$nENf>>k$;SONGW`qo6`&qKs*T z2^RS)pXk9b@(_Fw1bkb)-oqK|v}r$L!W&aXA>IpcdNZ_vWE#XO8X`#Yp1+?RshVcd zknG%rPd*4ECEI0wD#@d+3NbHKxl}n^Sgkx==Iu%}HvNliOqVBqG?P2va zQ;kRJ$J6j;+wP9cS za#m;#GUT!qAV%+rdWolk+)6kkz4@Yh5LXP+LSvo9_T+MmiaP-eq6_k;)i6_@WSJ zlT@wK$zqHu<83U2V*yJ|XJU4farT#pAA&@qu)(PO^8PxEmPD4;Txpio+2)#!9 z>&=i7*#tc0`?!==vk>s7V+PL#S1;PwSY?NIXN2=Gu89x(cToFm))7L;< z+bhAbVD*bD=}iU`+PU+SBobTQ%S!=VL!>q$rfWsaaV}Smz>lO9JXT#`CcH_mRCSf4%YQAw`$^yY z3Y*^Nzk_g$xn7a_NO(2Eb*I=^;4f!Ra#Oo~LLjlcjke*k*o$~U#0ZXOQ5@HQ&T46l z7504MUgZkz2gNP1QFN8Y?nSEnEai^Rgyvl}xZfMUV6QrJcXp;jKGqB=D*tj{8(_pV zqyB*DK$2lgYGejmJUW)*s_Cv65sFf&pb(Yz8oWgDtQ0~k^0-wdF|tj}MOXaN@ydF8 zNr={U?=;&Z?wr^VC+`)S2xl}QFagy;$mG=TUs7Vi2wws5zEke4hTa2)>O0U?$WYsZ z<8bN2bB_N4AWd%+kncgknZ&}bM~eDtj#C5uRkp21hWW5gxWvc6b*4+dn<{c?w9Rmf zIVZKsPl{W2vQAlYO3yh}-{Os=YBnL8?uN5(RqfQ=-1cOiUnJu>KcLA*tQK3FU`_bM zM^T28w;nAj5EdAXFi&Kk1Nnl2)D!M{@+D-}bIEe+Lc4{s;YJc-{F#``iS2uk;2!Zp zF9#myUmO!wCeJIoi^A+T^e~20c+c2C}XltaR!|U-HfDA=^xF97ev}$l6#oY z&-&T{egB)&aV$3_aVA51XGiU07$s9vubh_kQG?F$FycvS6|IO!6q zq^>9|3U^*!X_C~SxX&pqUkUjz%!j=VlXDo$!2VLH!rKj@61mDpSr~7B2yy{>X~_nc zRI+7g2V&k zd**H++P9dg!-AOs3;GM`(g<+GRV$+&DdMVpUxY9I1@uK28$az=6oaa+PutlO9?6#? zf-OsgT>^@8KK>ggkUQRPPgC7zjKFR5spqQb3ojCHzj^(UH~v+!y*`Smv)VpVoPwa6 zWG18WJaPKMi*F6Zdk*kU^`i~NNTfn3BkJniC`yN98L-Awd)Z&mY? zprBW$!qL-OL7h@O#kvYnLsfff@kDIegt~?{-*5A7JrA;#TmTe?jICJqhub-G@e??D zqiV#g{)M!kW1-4SDel7TO{;@*h2=_76g3NUD@|c*WO#>MfYq6_YVUP+&8e4|%4T`w zXzhmVNziAHazWO2qXcaOu@R1MrPP{t)`N)}-1&~mq=ZH=w=;-E$IOk=y$dOls{6sRR`I5>|X zpq~XYW4sd;J^6OwOf**J>a7u$S>WTFPRkjY;BfVgQst)u4aMLR1|6%)CB^18XCz+r ztkYQ}G43j~Q&1em(_EkMv0|WEiKu;z2zhb(L%$F&xWwzOmk;VLBYAZ8lOCziNoPw1 zv2BOyXA`A8z^WH!nXhKXM`t0;6D*-uGds3TYGrm8SPnJJOQ^fJU#}@aIy@MYWz**H zvkp?7I5PE{$$|~{-ZaFxr6ZolP^nL##mHOErB^AqJqn^hFA=)HWj!m3WDaHW$C)i^ z9@6G$SzB=>jbe>4kqr#sF7#K}W*Cg-5y6kun3u&0L7BpXF9=#7IN8FOjWrWwUBZiU zT_se3ih-GBKx+Uw0N|CwP3D@-C=5(9T#BH@M`F2!Goiqx+Js5xC92|Sy0%WWWp={$(am!#l~f^W_oz78HX<0X#7 zp)p1u~M*o9W@O8P{0Qkg@Wa# z2{Heb&oX^CQSZWSFBXKOfE|tsAm#^U-WkDnU;IowZ`Ok4!mwHwH=s|AqZ^YD4!5!@ zPxJj+Bd-q6w_YG`z_+r;S86zwXb+EO&qogOq8h-Ect5(M2+>(O7n7)^dP*ws_3U6v zVsh)sk^@*c>)3EML|0<-YROho{lz@Nd4;R9gL{9|64xVL`n!m$-Jjrx?-Bacp!=^5 z1^T^eB{_)Y<9)y{-4Rz@9_>;_7h;5D+@QcbF4Wv7hu)s0&==&6u)33 zHRj+&Woq-vDvjwJCYES@$C4{$?f$Ibi4G()UeN11rgjF+^;YE^5nYprYoJNoudNj= zm1pXSeG64dcWHObUetodRn1Fw|1nI$D9z}dVEYT0lQnsf_E1x2vBLql7NrHH!n&Sq z6lc*mvU=WS6=v9Lrl}&zRiu_6u;6g%_DU{9b+R z#YHqX7`m9eydf?KlKu6Sb%j$%_jmydig`B*TN`cZL-g!R)iE?+Q5oOqBFKhx z%MW>BC^(F_JuG(ayE(MT{S3eI{cKiwOtPwLc0XO*{*|(JOx;uQOfq@lp_^cZo=FZj z4#}@e@dJ>Bn%2`2_WPeSN7si^{U#H=7N4o%Dq3NdGybrZgEU$oSm$hC)uNDC_M9xc zGzwh5Sg?mpBIE8lT2XsqTt3j3?We8}3bzLBTQd639vyg^$0#1epq8snlDJP2(BF)K zSx30RM+{f+b$g{9usIL8H!hCO117Xgv}ttPJm9wVRjPk;ePH@zxv%j9k5`TzdXLeT zFgFX`V7cYIcBls5WN0Pf6SMBN+;CrQ(|EsFd*xtwr#$R{Z9FP`OWtyNsq#mCgZ7+P z^Yn$haBJ)r96{ZJd8vlMl?IBxrgh=fdq_NF!1{jARCVz>jNdC)H^wfy?R94#MPdUjcYX>#wEx+LB#P-#4S-%YH>t-j+w zOFTI8gX$ard6fAh&g=u&56%3^-6E2tpk*wx3HSCQ+t7+*iOs zPk5ysqE}i*cQocFvA68xHfL|iX(C4h*67@3|5Qwle(8wT&!&{8*{f%0(5gH+m>$tq zp;AqrP7?XTEooYG1Dzfxc>W%*CyL16q|fQ0_jp%%Bk^k!i#Nbi(N9&T>#M{gez_Ws zYK=l}adalV(nH}I_!hNeb;tQFk3BHX7N}}R8%pek^E`X}%ou=cx8InPU1EE0|Hen- zyw8MoJqB5=)Z%JXlrdTXAE)eqLAdVE-=>wGHrkRet}>3Yu^lt$Kzu%$3#(ioY}@Gu zjk3BZuQH&~7H+C*uX^4}F*|P89JX;Hg2U!pt>rDi(n(Qe-c}tzb0#6_ItoR0->LSt zR~UT<-|@TO%O`M+_e_J4wx7^)5_%%u+J=yF_S#2Xd?C;Ss3N7KY^#-vx+|;bJX&8r zD?|MetfhdC;^2WG`7MCgs>TKKN=^=!x&Q~BzmQio_^l~LboTNT=I zC5pme^P@ER``p$2md9>4!K#vV-Fc1an7pl>_|&>aqP}+zqR?+~Z;f2^`a+-!Te%V? z;H2SbF>jP^GE(R1@%C==XQ@J=G9lKX+Z<@5}PO(EYkJh=GCv#)Nj{DkWJM2}F&oAZ6xu8&g7pn1ps2U5srwQ7CAK zN&*~@t{`31lUf`O;2w^)M3B@o)_mbRu{-`PrfNpF!R^q>yTR&ETS7^-b2*{-tZAZz zw@q5x9B5V8Qd7dZ!Ai$9hk%Q!wqbE1F1c96&zwBBaRW}(^axoPpN^4Aw}&a5dMe+*Gomky_l^54*rzXro$ z>LL)U5Ry>~FJi=*{JDc)_**c)-&faPz`6v`YU3HQa}pLtb5K)u%K+BOqXP0)rj5Au$zB zW1?vr?mDv7Fsxtsr+S6ucp2l#(4dnr9sD*v+@*>g#M4b|U?~s93>Pg{{a5|rm2xfI z`>E}?9S@|IoUX{Q1zjm5YJT|3S>&09D}|2~BiMo=z4YEjXlWh)V&qs;*C{`UMxp$9 zX)QB?G$fPD6z5_pNs>Jeh{^&U^)Wbr?2D6-q?)`*1k@!UvwQgl8eG$r+)NnFoT)L6 zg7lEh+E6J17krfYJCSjWzm67hEth24pomhz71|Qodn#oAILN)*Vwu2qpJirG)4Wnv}9GWOFrQg%Je+gNrPl8mw7ykE8{ z=|B4+uwC&bpp%eFcRU6{mxRV32VeH8XxX>v$du<$(DfinaaWxP<+Y97Z#n#U~V zVEu-GoPD=9$}P;xv+S~Ob#mmi$JQmE;Iz4(){y*9pFyW-jjgdk#oG$fl4o9E8bo|L zWjo4l%n51@Kz-n%zeSCD`uB?T%FVk+KBI}=ve zvlcS#wt`U6wrJo}6I6Rwb=1GzZfwE=I&Ne@p7*pH84XShXYJRgvK)UjQL%R9Zbm(m zxzTQsLTON$WO7vM)*vl%Pc0JH7WhP;$z@j=y#avW4X8iqy6mEYr@-}PW?H)xfP6fQ z&tI$F{NNct4rRMSHhaelo<5kTYq+(?pY)Ieh8*sa83EQfMrFupMM@nfEV@EmdHUv9 z35uzIrIuo4#WnF^_jcpC@uNNaYTQ~uZWOE6P@LFT^1@$o&q+9Qr8YR+ObBkpP9=F+$s5+B!mX2~T zAuQ6RenX?O{IlLMl1%)OK{S7oL}X%;!XUxU~xJN8xk z`xywS*naF(J#?vOpB(K=o~lE;m$zhgPWDB@=p#dQIW>xe_p1OLoWInJRKbEuoncf; zmS1!u-ycc1qWnDg5Nk2D)BY%jmOwCLC+Ny>`f&UxFowIsHnOXfR^S;&F(KXd{ODlm z$6#1ccqt-HIH9)|@fHnrKudu!6B$_R{fbCIkSIb#aUN|3RM>zuO>dpMbROZ`^hvS@ z$FU-;e4W}!ubzKrU@R*dW*($tFZ>}dd*4_mv)#O>X{U@zSzQt*83l9mI zI$8O<5AIDx`wo0}f2fsPC_l>ONx_`E7kdXu{YIZbp1$(^oBAH({T~&oQ&1{X951QW zmhHUxd)t%GQ9#ak5fTjk-cahWC;>^Rg7(`TVlvy0W@Y!Jc%QL3Ozu# zDPIqBCy&T2PWBj+d-JA-pxZlM=9ja2ce|3B(^VCF+a*MMp`(rH>Rt6W1$;r{n1(VK zLs>UtkT43LR2G$AOYHVailiqk7naz2yZGLo*xQs!T9VN5Q>eE(w zw$4&)&6xIV$IO^>1N-jrEUg>O8G4^@y+-hQv6@OmF@gy^nL_n1P1-Rtyy$Bl;|VcV zF=p*&41-qI5gG9UhKmmnjs932!6hceXa#-qfK;3d*a{)BrwNFeKU|ge?N!;zk+kB! zMD_uHJR#%b54c2tr~uGPLTRLg$`fupo}cRJeTwK;~}A>(Acy4k-Xk&Aa1&eWYS1ULWUj@fhBiWY$pdfy+F z@G{OG{*v*mYtH3OdUjwEr6%_ZPZ3P{@rfbNPQG!BZ7lRyC^xlMpWH`@YRar`tr}d> z#wz87t?#2FsH-jM6m{U=gp6WPrZ%*w0bFm(T#7m#v^;f%Z!kCeB5oiF`W33W5Srdt zdU?YeOdPG@98H7NpI{(uN{FJdu14r(URPH^F6tOpXuhU7T9a{3G3_#Ldfx_nT(Hec zo<1dyhsVsTw;ZkVcJ_0-h-T3G1W@q)_Q30LNv)W?FbMH+XJ* zy=$@39Op|kZv`Rt>X`zg&at(?PO^I=X8d9&myFEx#S`dYTg1W+iE?vt#b47QwoHI9 zNP+|3WjtXo{u}VG(lLUaW0&@yD|O?4TS4dfJI`HC-^q;M(b3r2;7|FONXphw-%7~* z&;2!X17|05+kZOpQ3~3!Nb>O94b&ZSs%p)TK)n3m=4eiblVtSx@KNFgBY_xV6ts;NF;GcGxMP8OKV^h6LmSb2E#Qnw ze!6Mnz7>lE9u{AgQ~8u2zM8CYD5US8dMDX-5iMlgpE9m*s+Lh~A#P1er*rF}GHV3h z=`STo?kIXw8I<`W0^*@mB1$}pj60R{aJ7>C2m=oghKyxMbFNq#EVLgP0cH3q7H z%0?L93-z6|+jiN|@v>ix?tRBU(v-4RV`}cQH*fp|)vd3)8i9hJ3hkuh^8dz{F5-~_ zUUr1T3cP%cCaTooM8dj|4*M=e6flH0&8ve32Q)0dyisl))XkZ7Wg~N}6y`+Qi2l+e zUd#F!nJp{#KIjbQdI`%oZ`?h=5G^kZ_uN`<(`3;a!~EMsWV|j-o>c?x#;zR2ktiB! z);5rrHl?GPtr6-o!tYd|uK;Vbsp4P{v_4??=^a>>U4_aUXPWQ$FPLE4PK$T^3Gkf$ zHo&9$U&G`d(Os6xt1r?sg14n)G8HNyWa^q8#nf0lbr4A-Fi;q6t-`pAx1T*$eKM*$ z|CX|gDrk#&1}>5H+`EjV$9Bm)Njw&7-ZR{1!CJTaXuP!$Pcg69`{w5BRHysB$(tWUes@@6aM69kb|Lx$%BRY^-o6bjH#0!7b;5~{6J+jKxU!Kmi# zndh@+?}WKSRY2gZ?Q`{(Uj|kb1%VWmRryOH0T)f3cKtG4oIF=F7RaRnH0Rc_&372={_3lRNsr95%ZO{IX{p@YJ^EI%+gvvKes5cY+PE@unghjdY5#9A!G z70u6}?zmd?v+{`vCu-53_v5@z)X{oPC@P)iA3jK$`r zSA2a7&!^zmUiZ82R2=1cumBQwOJUPz5Ay`RLfY(EiwKkrx%@YN^^XuET;tE zmr-6~I7j!R!KrHu5CWGSChO6deaLWa*9LLJbcAJsFd%Dy>a!>J`N)Z&oiU4OEP-!Ti^_!p}O?7`}i7Lsf$-gBkuY*`Zb z7=!nTT;5z$_5$=J=Ko+Cp|Q0J=%oFr>hBgnL3!tvFoLNhf#D0O=X^h+x08iB;@8pXdRHxX}6R4k@i6%vmsQwu^5z zk1ip`#^N)^#Lg#HOW3sPI33xqFB4#bOPVnY%d6prwxf;Y-w9{ky4{O6&94Ra8VN@K zb-lY;&`HtxW@sF!doT5T$2&lIvJpbKGMuDAFM#!QPXW87>}=Q4J3JeXlwHys?!1^#37q_k?N@+u&Ns20pEoBeZC*np;i;M{2C0Z4_br2gsh6eL z#8`#sn41+$iD?^GL%5?cbRcaa-Nx0vE(D=*WY%rXy3B%gNz0l?#noGJGP728RMY#q z=2&aJf@DcR?QbMmN)ItUe+VM_U!ryqA@1VVt$^*xYt~-qvW!J4Tp<-3>jT=7Zow5M z8mSKp0v4b%a8bxFr>3MwZHSWD73D@+$5?nZAqGM#>H@`)mIeC#->B)P8T$zh-Pxnc z8)~Zx?TWF4(YfKuF3WN_ckpCe5;x4V4AA3(i$pm|78{%!q?|~*eH0f=?j6i)n~Hso zmTo>vqEtB)`%hP55INf7HM@taH)v`Fw40Ayc*R!T?O{ziUpYmP)AH`euTK!zg9*6Z z!>M=$3pd0!&TzU=hc_@@^Yd3eUQpX4-33}b{?~5t5lgW=ldJ@dUAH%`l5US1y_`40 zs(X`Qk}vvMDYYq+@Rm+~IyCX;iD~pMgq^KY)T*aBz@DYEB={PxA>)mI6tM*sx-DmGQHEaHwRrAmNjO!ZLHO4b;;5mf@zzlPhkP($JeZGE7 z?^XN}Gf_feGoG~BjUgVa*)O`>lX=$BSR2)uD<9 z>o^|nb1^oVDhQbfW>>!;8-7<}nL6L^V*4pB=>wwW+RXAeRvKED(n1;R`A6v$6gy0I(;Vf?!4;&sgn7F%LpM}6PQ?0%2Z@b{It<(G1CZ|>913E0nR2r^Pa*Bp z@tFGi*CQ~@Yc-?{cwu1 zsilf=k^+Qs>&WZG(3WDixisHpR>`+ihiRwkL(3T|=xsoNP*@XX3BU8hr57l3k;pni zI``=3Nl4xh4oDj<%>Q1zYXHr%Xg_xrK3Nq?vKX3|^Hb(Bj+lONTz>4yhU-UdXt2>j z<>S4NB&!iE+ao{0Tx^N*^|EZU;0kJkx@zh}S^P{ieQjGl468CbC`SWnwLRYYiStXm zOxt~Rb3D{dz=nHMcY)#r^kF8|q8KZHVb9FCX2m^X*(|L9FZg!5a7((!J8%MjT$#Fs)M1Pb zq6hBGp%O1A+&%2>l0mpaIzbo&jc^!oN^3zxap3V2dNj3x<=TwZ&0eKX5PIso9j1;e zwUg+C&}FJ`k(M|%%}p=6RPUq4sT3-Y;k-<68ciZ~_j|bt>&9ZLHNVrp#+pk}XvM{8 z`?k}o-!if>hVlCP9j%&WI2V`5SW)BCeR5>MQhF)po=p~AYN%cNa_BbV6EEh_kk^@a zD>4&>uCGCUmyA-c)%DIcF4R6!>?6T~Mj_m{Hpq`*(wj>foHL;;%;?(((YOxGt)Bhx zuS+K{{CUsaC++%}S6~CJ=|vr(iIs-je)e9uJEU8ZJAz)w166q)R^2XI?@E2vUQ!R% zn@dxS!JcOimXkWJBz8Y?2JKQr>`~SmE2F2SL38$SyR1^yqj8_mkBp)o$@+3BQ~Mid z9U$XVqxX3P=XCKj0*W>}L0~Em`(vG<>srF8+*kPrw z20{z(=^w+ybdGe~Oo_i|hYJ@kZl*(9sHw#Chi&OIc?w`nBODp?ia$uF%Hs(X>xm?j zqZQ`Ybf@g#wli`!-al~3GWiE$K+LCe=Ndi!#CVjzUZ z!sD2O*;d28zkl))m)YN7HDi^z5IuNo3^w(zy8 zszJG#mp#Cj)Q@E@r-=NP2FVxxEAeOI2e=|KshybNB6HgE^(r>HD{*}S}mO>LuRGJT{*tfTzw_#+er-0${}%YPe@CMJ1Ng#j#)i)SnY@ss3gL;g zg2D~#Kpdfu#G;q1qz_TwSz1VJT(b3zby$Vk&;Y#1(A)|xj`_?i5YQ;TR%jice5E;0 zYHg;`zS5{S*9xI6o^j>rE8Ua*XhIw{_-*&@(R|C(am8__>+Ws&Q^ymy*X4~hR2b5r zm^p3sw}yv=tdyncy_Ui7{BQS732et~Z_@{-IhHDXAV`(Wlay<#hb>%H%WDi+K$862nA@BDtM#UCKMu+kM`!JHyWSi?&)A7_ z3{cyNG%a~nnH_!+;g&JxEMAmh-Z}rC!o7>OVzW&PoMyTA_g{hqXG)SLraA^OP**<7 zjWbr7z!o2n3hnx7A=2O=WL;`@9N{vQIM@&|G-ljrPvIuJHYtss0Er0fT5cMXNUf1B z7FAwBDixt0X7C3S)mPe5g`YtME23wAnbU)+AtV}z+e8G;0BP=bI;?(#|Ep!vVfDbK zvx+|CKF>yt0hWQ3drchU#XBU+HiuG*V^snFAPUp-5<#R&BUAzoB!aZ+e*KIxa26V}s6?nBK(U-7REa573wg-jqCg>H8~>O{ z*C0JL-?X-k_y%hpUFL?I>0WV{oV`Nb)nZbJG01R~AG>flIJf)3O*oB2i8~;!P?Wo_ z0|QEB*fifiL6E6%>tlAYHm2cjTFE@*<);#>689Z6S#BySQ@VTMhf9vYQyLeDg1*F} zjq>i1*x>5|CGKN{l9br3kB0EHY|k4{%^t7-uhjd#NVipUZa=EUuE5kS1_~qYX?>hJ z$}!jc9$O$>J&wnu0SgfYods^z?J4X;X7c77Me0kS-dO_VUQ39T(Kv(Y#s}Qqz-0AH z^?WRL(4RzpkD+T5FG_0NyPq-a-B7A5LHOCqwObRJi&oRi(<;OuIN7SV5PeHU$<@Zh zPozEV`dYmu0Z&Tqd>t>8JVde9#Pt+l95iHe$4Xwfy1AhI zDM4XJ;bBTTvRFtW>E+GzkN)9k!hA5z;xUOL2 zq4}zn-DP{qc^i|Y%rvi|^5k-*8;JZ~9a;>-+q_EOX+p1Wz;>i7c}M6Nv`^NY&{J-> z`(mzDJDM}QPu5i44**2Qbo(XzZ-ZDu%6vm8w@DUarqXj41VqP~ zs&4Y8F^Waik3y1fQo`bVUH;b=!^QrWb)3Gl=QVKr+6sxc=ygauUG|cm?|X=;Q)kQ8 zM(xrICifa2p``I7>g2R~?a{hmw@{!NS5`VhH8+;cV(F>B94M*S;5#O`YzZH1Z%yD? zZ61w(M`#aS-*~Fj;x|J!KM|^o;MI#Xkh0ULJcA?o4u~f%Z^16ViA27FxU5GM*rKq( z7cS~MrZ=f>_OWx8j#-Q3%!aEU2hVuTu(7`TQk-Bi6*!<}0WQi;_FpO;fhpL4`DcWp zGOw9vx0N~6#}lz(r+dxIGZM3ah-8qrqMmeRh%{z@dbUD2w15*_4P?I~UZr^anP}DB zU9CCrNiy9I3~d#&!$DX9e?A});BjBtQ7oGAyoI$8YQrkLBIH@2;lt4E^)|d6Jwj}z z&2_E}Y;H#6I4<10d_&P0{4|EUacwFHauvrjAnAm6yeR#}f}Rk27CN)vhgRqEyPMMS7zvunj2?`f;%?alsJ+-K+IzjJx>h8 zu~m_y$!J5RWAh|C<6+uiCNsOKu)E72M3xKK(a9Okw3e_*O&}7llNV!=P87VM2DkAk zci!YXS2&=P0}Hx|wwSc9JP%m8dMJA*q&VFB0yMI@5vWoAGraygwn){R+Cj6B1a2Px z5)u(K5{+;z2n*_XD!+Auv#LJEM)(~Hx{$Yb^ldQmcYF2zNH1V30*)CN_|1$v2|`LnFUT$%-tO0Eg|c5$BB~yDfzS zcOXJ$wpzVK0MfTjBJ0b$r#_OvAJ3WRt+YOLlJPYMx~qp>^$$$h#bc|`g0pF-Ao43? z>*A+8lx>}L{p(Tni2Vvk)dtzg$hUKjSjXRagj)$h#8=KV>5s)J4vGtRn5kP|AXIz! zPgbbVxW{2o4s-UM;c#We8P&mPN|DW7_uLF!a|^0S=wr6Esx9Z$2|c1?GaupU6$tb| zY_KU`(_29O_%k(;>^|6*pZURH3`@%EuKS;Ns z1lujmf;r{qAN&Q0&m{wJSZ8MeE7RM5+Sq;ul_ z`+ADrd_Um+G37js6tKsArNB}n{p*zTUxQr>3@wA;{EUbjNjlNd6$Mx zg0|MyU)v`sa~tEY5$en7^PkC=S<2@!nEdG6L=h(vT__0F=S8Y&eM=hal#7eM(o^Lu z2?^;05&|CNliYrq6gUv;|i!(W{0N)LWd*@{2q*u)}u*> z7MQgk6t9OqqXMln?zoMAJcc zMKaof_Up})q#DzdF?w^%tTI7STI^@8=Wk#enR*)&%8yje>+tKvUYbW8UAPg55xb70 zEn5&Ba~NmOJlgI#iS8W3-@N%>V!#z-ZRwfPO1)dQdQkaHsiqG|~we2ALqG7Ruup(DqSOft2RFg_X%3w?6VqvV1uzX_@F(diNVp z4{I|}35=11u$;?|JFBEE*gb;T`dy+8gWJ9~pNsecrO`t#V9jW-6mnfO@ff9od}b(3s4>p0i30gbGIv~1@a^F2kl7YO;DxmF3? zWi-RoXhzRJV0&XE@ACc?+@6?)LQ2XNm4KfalMtsc%4!Fn0rl zpHTrHwR>t>7W?t!Yc{*-^xN%9P0cs0kr=`?bQ5T*oOo&VRRu+1chM!qj%2I!@+1XF z4GWJ=7ix9;Wa@xoZ0RP`NCWw0*8247Y4jIZ>GEW7zuoCFXl6xIvz$ezsWgKdVMBH> z{o!A7f;R-@eK9Vj7R40xx)T<2$?F2E<>Jy3F;;=Yt}WE59J!1WN367 zA^6pu_zLoZIf*x031CcwotS{L8bJE(<_F%j_KJ2P_IusaZXwN$&^t716W{M6X2r_~ zaiMwdISX7Y&Qi&Uh0upS3TyEIXNDICQlT5fHXC`aji-c{U(J@qh-mWl-uMN|T&435 z5)a1dvB|oe%b2mefc=Vpm0C%IUYYh7HI*;3UdgNIz}R##(#{(_>82|zB0L*1i4B5j-xi9O4x10rs_J6*gdRBX=@VJ+==sWb&_Qc6tSOowM{BX@(zawtjl zdU!F4OYw2@Tk1L^%~JCwb|e#3CC>srRHQ*(N%!7$Mu_sKh@|*XtR>)BmWw!;8-mq7 zBBnbjwx8Kyv|hd*`5}84flTHR1Y@@uqjG`UG+jN_YK&RYTt7DVwfEDXDW4U+iO{>K zw1hr{_XE*S*K9TzzUlJH2rh^hUm2v7_XjwTuYap|>zeEDY$HOq3X4Tz^X}E9z)x4F zs+T?Ed+Hj<#jY-`Va~fT2C$=qFT-5q$@p9~0{G&eeL~tiIAHXA!f6C(rAlS^)&k<- zXU|ZVs}XQ>s5iONo~t!XXZgtaP$Iau;JT%h)>}v54yut~pykaNye4axEK#5@?TSsQ zE;Jvf9I$GVb|S`7$pG)4vgo9NXsKr?u=F!GnA%VS2z$@Z(!MR9?EPcAqi5ft)Iz6sNl`%kj+_H-X`R<>BFrBW=fSlD|{`D%@Rcbu2?%>t7i34k?Ujb)2@J-`j#4 zLK<69qcUuniIan-$A1+fR=?@+thwDIXtF1Tks@Br-xY zfB+zblrR(ke`U;6U~-;p1Kg8Lh6v~LjW@9l2P6s+?$2!ZRPX`(ZkRGe7~q(4&gEi<$ch`5kQ?*1=GSqkeV z{SA1EaW_A!t{@^UY2D^YO0(H@+kFVzZaAh0_`A`f(}G~EP~?B|%gtxu&g%^x{EYSz zk+T;_c@d;+n@$<>V%P=nk36?L!}?*=vK4>nJSm+1%a}9UlmTJTrfX4{Lb7smNQn@T zw9p2%(Zjl^bWGo1;DuMHN(djsEm)P8mEC2sL@KyPjwD@d%QnZ$ zMJ3cnn!_!iP{MzWk%PI&D?m?C(y2d|2VChluN^yHya(b`h>~GkI1y;}O_E57zOs!{ zt2C@M$^PR2U#(dZmA-sNreB@z-yb0Bf7j*yONhZG=onhx>t4)RB`r6&TP$n zgmN*)eCqvgriBO-abHQ8ECN0bw?z5Bxpx z=jF@?zFdVn?@gD5egM4o$m`}lV(CWrOKKq(sv*`mNcHcvw&Xryfw<{ch{O&qc#WCTXX6=#{MV@q#iHYba!OUY+MGeNTjP%Fj!WgM&`&RlI^=AWTOqy-o zHo9YFt!gQ*p7{Fl86>#-JLZo(b^O`LdFK~OsZBRR@6P?ad^Ujbqm_j^XycM4ZHFyg ziUbIFW#2tj`65~#2V!4z7DM8Z;fG0|APaQ{a2VNYpNotB7eZ5kp+tPDz&Lqs0j%Y4tA*URpcfi z_M(FD=fRGdqf430j}1z`O0I=;tLu81bwJXdYiN7_&a-?ly|-j*+=--XGvCq#32Gh(=|qj5F?kmihk{%M&$}udW5)DHK zF_>}5R8&&API}o0osZJRL3n~>76nUZ&L&iy^s>PMnNcYZ|9*1$v-bzbT3rpWsJ+y{ zPrg>5Zlery96Um?lc6L|)}&{992{_$J&=4%nRp9BAC6!IB=A&=tF>r8S*O-=!G(_( zwXbX_rGZgeiK*&n5E;f=k{ktyA1(;x_kiMEt0*gpp_4&(twlS2e5C?NoD{n>X2AT# zY@Zp?#!b1zNq96MQqeO*M1MMBin5v#RH52&Xd~DO6-BZLnA6xO1$sou(YJ1Dlc{WF zVa%2DyYm`V#81jP@70IJ;DX@y*iUt$MLm)ByAD$eUuji|5{ptFYq(q)mE(5bOpxjM z^Q`AHWq44SG3`_LxC9fwR)XRVIp=B%<(-lOC3jI#bb@dK(*vjom!=t|#<@dZql%>O z15y^{4tQoeW9Lu%G&V$90x6F)xN6y_oIn;!Q zs)8jT$;&;u%Y>=T3hg34A-+Y*na=|glcStr5D;&5*t5*DmD~x;zQAV5{}Ya`?RRGa zT*t9@$a~!co;pD^!J5bo?lDOWFx%)Y=-fJ+PDGc0>;=q=s?P4aHForSB+)v0WY2JH z?*`O;RHum6j%#LG)Vu#ciO#+jRC3!>T(9fr+XE7T2B7Z|0nR5jw@WG)kDDzTJ=o4~ zUpeyt7}_nd`t}j9BKqryOha{34erm)RmST)_9Aw)@ zHbiyg5n&E{_CQR@h<}34d7WM{s{%5wdty1l+KX8*?+-YkNK2Be*6&jc>@{Fd;Ps|| z26LqdI3#9le?;}risDq$K5G3yoqK}C^@-8z^wj%tdgw-6@F#Ju{Sg7+y)L?)U$ez> zoOaP$UFZ?y5BiFycir*pnaAaY+|%1%8&|(@VB)zweR%?IidwJyK5J!STzw&2RFx zZV@qeaCB01Hu#U9|1#=Msc8Pgz5P*4Lrp!Q+~(G!OiNR{qa7|r^H?FC6gVhkk3y7=uW#Sh;&>78bZ}aK*C#NH$9rX@M3f{nckYI+5QG?Aj1DM)@~z_ zw!UAD@gedTlePB*%4+55naJ8ak_;))#S;4ji!LOqY5VRI){GMwHR~}6t4g>5C_#U# ztYC!tjKjrKvRy=GAsJVK++~$|+s!w9z3H4G^mACv=EErXNSmH7qN}%PKcN|8%9=i)qS5+$L zu&ya~HW%RMVJi4T^pv?>mw*Gf<)-7gf#Qj|e#w2|v4#t!%Jk{&xlf;$_?jW*n!Pyx zkG$<18kiLOAUPuFfyu-EfWX%4jYnjBYc~~*9JEz6oa)_R|8wjZA|RNrAp%}14L7fW zi7A5Wym*K+V8pkqqO-X#3ft{0qs?KVt^)?kS>AicmeO&q+~J~ zp0YJ_P~_a8j= zsAs~G=8F=M{4GZL{|B__UorX@MRNQLn?*_gym4aW(~+i13knnk1P=khoC-ViMZk+x zLW(l}oAg1H`dU+Fv**;qw|ANDSRs>cGqL!Yw^`; zv;{E&8CNJcc)GHzTYM}f&NPw<6j{C3gaeelU#y!M)w-utYEHOCCJo|Vgp7K6C_$14 zqIrLUB0bsgz^D%V%fbo2f9#yb#CntTX?55Xy|Kps&Xek*4_r=KDZ z+`TQuv|$l}MWLzA5Ay6Cvsa^7xvwXpy?`w(6vx4XJ zWuf1bVSb#U8{xlY4+wlZ$9jjPk)X_;NFMqdgq>m&W=!KtP+6NL57`AMljW+es zzqjUjgz;V*kktJI?!NOg^s_)ph45>4UDA!Vo0hn>KZ+h-3=?Y3*R=#!fOX zP$Y~+14$f66ix?UWB_6r#fMcC^~X4R-<&OD1CSDNuX~y^YwJ>sW0j`T<2+3F9>cLo z#!j57$ll2K9(%$4>eA7(>FJX5e)pR5&EZK!IMQzOfik#FU*o*LGz~7u(8}XzIQRy- z!U7AlMTIe|DgQFmc%cHy_9^{o`eD%ja_L>ckU6$O4*U**o5uR7`FzqkU8k4gxtI=o z^P^oGFPm5jwZMI{;nH}$?p@uV8FT4r=|#GziKXK07bHJLtK}X%I0TON$uj(iJ`SY^ zc$b2CoxCQ>7LH@nxcdW&_C#fMYBtTxcg46dL{vf%EFCZ~eErMvZq&Z%Lhumnkn^4A zsx$ay(FnN7kYah}tZ@0?-0Niroa~13`?hVi6`ndno`G+E8;$<6^gsE-K3)TxyoJ4M zb6pj5=I8^FD5H@`^V#Qb2^0cx7wUz&cruA5g>6>qR5)O^t1(-qqP&1g=qvY#s&{bx zq8Hc%LsbK1*%n|Y=FfojpE;w~)G0-X4i*K3{o|J7`krhIOd*c*$y{WIKz2n2*EXEH zT{oml3Th5k*vkswuFXdGDlcLj15Nec5pFfZ*0?XHaF_lVuiB%Pv&p7z)%38}%$Gup zVTa~C8=cw%6BKn_|4E?bPNW4PT7}jZQLhDJhvf4z;~L)506IE0 zX!tWXX(QOQPRj-p80QG79t8T2^az4Zp2hOHziQlvT!|H)jv{Ixodabzv6lBj)6WRB z{)Kg@$~~(7$-az?lw$4@L%I&DI0Lo)PEJJziWP33a3azb?jyXt1v0N>2kxwA6b%l> zZqRpAo)Npi&loWbjFWtEV)783BbeIAhqyuc+~>i7aQ8shIXt)bjCWT6$~ro^>99G} z2XfmT0(|l!)XJb^E!#3z4oEGIsL(xd; zYX1`1I(cG|u#4R4T&C|m*9KB1`UzKvho5R@1eYtUL9B72{i(ir&ls8g!pD ztR|25xGaF!4z5M+U@@lQf(12?xGy`!|3E}7pI$k`jOIFjiDr{tqf0va&3pOn6Pu)% z@xtG2zjYuJXrV)DUrIF*y<1O1<$#54kZ#2;=X51J^F#0nZ0(;S$OZDt_U2bx{RZ=Q zMMdd$fH|!s{ zXq#l;{`xfV`gp&C>A`WrQU?d{!Ey5(1u*VLJt>i27aZ-^&2IIk=zP5p+{$q(K?2(b z8?9h)kvj9SF!Dr zoyF}?V|9;6abHxWk2cEvGs$-}Pg}D+ZzgkaN&$Snp%;5m%zh1E#?Wac-}x?BYlGN#U#Mek*}kek#I9XaHt?mz3*fDrRTQ#&#~xyeqJk1QJ~E$7qsw6 z?sV;|?*=-{M<1+hXoj?@-$y+(^BJ1H~wQ9G8C0#^aEAyhDduNX@haoa=PuPp zYsGv8UBfQaRHgBgLjmP^eh>fLMeh{8ic)?xz?#3kX-D#Z{;W#cd_`9OMFIaJg-=t`_3*!YDgtNQ2+QUEAJB9M{~AvT$H`E)IKmCR21H532+ata8_i_MR@ z2Xj<3w<`isF~Ah$W{|9;51ub*f4#9ziKrOR&jM{x7I_7()O@`F*5o$KtZ?fxU~g`t zUovNEVKYn$U~VX8eR)qb`7;D8pn*Pp$(otYTqL)5KH$lUS-jf}PGBjy$weoceAcPp z&5ZYB$r&P$MN{0H0AxCe4Qmd3T%M*5d4i%#!nmBCN-WU-4m4Tjxn-%j3HagwTxCZ9 z)j5vO-C7%s%D!&UfO>bi2oXiCw<-w{vVTK^rVbv#W=WjdADJy8$khnU!`ZWCIU`># zyjc^1W~pcu>@lDZ{zr6gv%)2X4n27~Ve+cQqcND%0?IFSP4sH#yIaXXYAq^z3|cg` z`I3$m%jra>e2W-=DiD@84T!cb%||k)nPmEE09NC%@PS_OLhkrX*U!cgD*;;&gIaA(DyVT4QD+q_xu z>r`tg{hiGY&DvD-)B*h+YEd+Zn)WylQl}<4>(_NlsKXCRV;a)Rcw!wtelM2_rWX`j zTh5A|i6=2BA(iMCnj_fob@*eA;V?oa4Z1kRBGaU07O70fb6-qmA$Hg$ps@^ka1=RO zTbE_2#)1bndC3VuK@e!Sftxq4=Uux}fDxXE#Q5_x=E1h>T5`DPHz zbH<_OjWx$wy7=%0!mo*qH*7N4tySm+R0~(rbus`7;+wGh;C0O%x~fEMkt!eV>U$`i z5>Q(o z=t$gPjgGh0&I7KY#k50V7DJRX<%^X z>6+ebc9efB3@eE2Tr){;?_w`vhgF>`-GDY(YkR{9RH(MiCnyRtd!LxXJ75z+?2 zGi@m^+2hKJ5sB1@Xi@s_@p_Kwbc<*LQ_`mr^Y%j}(sV_$`J(?_FWP)4NW*BIL~sR>t6 zM;qTJZ~GoY36&{h-Pf}L#y2UtR}>ZaI%A6VkU>vG4~}9^i$5WP2Tj?Cc}5oQxe2=q z8BeLa$hwCg_psjZyC2+?yX4*hJ58Wu^w9}}7X*+i5Rjqu5^@GzXiw#SUir1G1`jY% zOL=GE_ENYxhcyUrEt9XlMNP6kx6h&%6^u3@zB8KUCAa18T(R2J`%JjWZ z!{7cXaEW+Qu*iJPu+m>QqW}Lo$4Z+!I)0JNzZ&_M%=|B1yejFRM04bGAvu{=lNPd+ zJRI^DRQ(?FcVUD+bgEcAi@o(msqys9RTCG#)TjI!9~3-dc`>gW;HSJuQvH~d`MQs86R$|SKXHh zqS9Qy)u;T`>>a!$LuaE2keJV%;8g)tr&Nnc;EkvA-RanHXsy)D@XN0a>h}z2j81R; zsUNJf&g&rKpuD0WD@=dDrPHdBoK42WoBU|nMo17o(5^;M|dB4?|FsAGVrSyWcI`+FVw^vTVC`y}f(BwJl zrw3Sp151^9=}B})6@H*i4-dIN_o^br+BkcLa^H56|^2XsT0dESw2 zMX>(KqNl=x2K5=zIKg}2JpGAZu{I_IO}0$EQ5P{4zol**PCt3F4`GX}2@vr8#Y)~J zKb)gJeHcFnR@4SSh%b;c%J`l=W*40UPjF#q{<}ywv-=vHRFmDjv)NtmC zQx9qm)d%0zH&qG7AFa3VAU1S^(n8VFTC~Hb+HjYMjX8r#&_0MzlNR*mnLH5hi}`@{ zK$8qiDDvS_(L9_2vHgzEQ${DYSE;DqB!g*jhJghE&=LTnbgl&Xepo<*uRtV{2wDHN z)l;Kg$TA>Y|K8Lc&LjWGj<+bp4Hiye_@BfU(y#nF{fpR&|Ltbye?e^j0}8JC4#xi% zv29ZR%8%hk=3ZDvO-@1u8KmQ@6p%E|dlHuy#H1&MiC<*$YdLkHmR#F3ae;bKd;@*i z2_VfELG=B}JMLCO-6UQy^>RDE%K4b>c%9ki`f~Z2Qu8hO7C#t%Aeg8E%+}6P7Twtg z-)dj(w}_zFK&86KR@q9MHicUAucLVshUdmz_2@32(V`y3`&Kf8Q2I)+!n0mR=rrDU zXvv^$ho;yh*kNqJ#r1}b0|i|xRUF6;lhx$M*uG3SNLUTC@|htC z-=fsw^F%$qqz4%QdjBrS+ov}Qv!z00E+JWas>p?z@=t!WWU3K*?Z(0meTuTOC7OTx zU|kFLE0bLZ+WGcL$u4E}5dB0g`h|uwv3=H6f+{5z9oLv-=Q45+n~V4WwgO=CabjM% zBAN+RjM65(-}>Q2V#i1Na@a0`08g&y;W#@sBiX6Tpy8r}*+{RnyGUT`?XeHSqo#|J z^ww~c;ou|iyzpErDtlVU=`8N7JSu>4M z_pr9=tX0edVn9B}YFO2y(88j#S{w%E8vVOpAboK*27a7e4Ekjt0)hIX99*1oE;vex z7#%jhY=bPijA=Ce@9rRO(Vl_vnd00!^TAc<+wVvRM9{;hP*rqEL_(RzfK$er_^SN; z)1a8vo8~Dr5?;0X0J62Cusw$A*c^Sx1)dom`-)Pl7hsW4i(r*^Mw`z5K>!2ixB_mu z*Ddqjh}zceRFdmuX1akM1$3>G=#~|y?eYv(e-`Qy?bRHIq=fMaN~fB zUa6I8Rt=)jnplP>yuS+P&PxeWpJ#1$F`iqRl|jF$WL_aZFZl@kLo&d$VJtu&w?Q0O zzuXK>6gmygq(yXJy0C1SL}T8AplK|AGNUOhzlGeK_oo|haD@)5PxF}rV+5`-w{Aag zus45t=FU*{LguJ11Sr-28EZkq;!mJO7AQGih1L4rEyUmp>B!%X0YemsrV3QFvlgt* z5kwlPzaiJ+kZ^PMd-RRbl(Y?F*m`4*UIhIuf#8q>H_M=fM*L_Op-<_r zBZagV=4B|EW+KTja?srADTZXCd3Yv%^Chfpi)cg{ED${SI>InNpRj5!euKv?=Xn92 zsS&FH(*w`qLIy$doc>RE&A5R?u zzkl1sxX|{*fLpXvIW>9d<$ePROttn3oc6R!sN{&Y+>Jr@yeQN$sFR z;w6A<2-0%UA?c8Qf;sX7>>uKRBv3Ni)E9pI{uVzX|6Bb0U)`lhLE3hK58ivfRs1}d zNjlGK0hdq0qjV@q1qI%ZFMLgcpWSY~mB^LK)4GZ^h_@H+3?dAe_a~k*;9P_d7%NEFP6+ zgV(oGr*?W(ql?6SQ~`lUsjLb%MbfC4V$)1E0Y_b|OIYxz4?O|!kRb?BGrgiH5+(>s zoqM}v*;OBfg-D1l`M6T6{K`LG+0dJ1)!??G5g(2*vlNkm%Q(MPABT$r13q?|+kL4- zf)Mi5r$sn;u41aK(K#!m+goyd$c!KPl~-&-({j#D4^7hQkV3W|&>l_b!}!z?4($OA z5IrkfuT#F&S1(`?modY&I40%gtroig{YMvF{K{>5u^I51k8RriGd${z)=5k2tG zM|&Bp5kDTfb#vfuTTd?)a=>bX=lokw^y9+2LS?kwHQIWI~pYgy7 zb?A-RKVm_vM5!9?C%qYdfRAw& zAU7`up~%g=p@}pg#b7E)BFYx3g%(J36Nw(Dij!b>cMl@CSNbrW!DBDbTD4OXk!G4x zi}JBKc8HBYx$J~31PXH+4^x|UxK~(<@I;^3pWN$E=sYma@JP|8YL`L(zI6Y#c%Q{6 z*APf`DU$S4pr#_!60BH$FGViP14iJmbrzSrOkR;f3YZa{#E7Wpd@^4E-zH8EgPc-# zKWFPvh%WbqU_%ZEt`=Q?odKHc7@SUmY{GK`?40VuL~o)bS|is$Hn=<=KGHOsEC5tB zFb|q}gGlL97NUf$G$>^1b^3E18PZ~Pm9kX%*ftnolljiEt@2#F2R5ah$zbXd%V_Ev zyDd{1o_uuoBga$fB@Fw!V5F3jIr=a-ykqrK?WWZ#a(bglI_-8pq74RK*KfQ z0~Dzus7_l;pMJYf>Bk`)`S8gF!To-BdMnVw5M-pyu+aCiC5dwNH|6fgRsIKZcF&)g zr}1|?VOp}I3)IR@m1&HX1~#wsS!4iYqES zK}4J{Ei>;e3>LB#Oly>EZkW14^@YmpbgxCDi#0RgdM${&wxR+LiX}B+iRioOB0(pDKpVEI;ND?wNx>%e|m{RsqR_{(nmQ z3ZS}@t!p4a(BKx_-CYwrcyJ5u1TO9bcXti$8sy>xcLKqKCc#~UOZYD{llKTSFEjJ~ zyNWt>tLU}*>^`TvPxtP%F`ZJQw@W0^>x;!^@?k_)9#bF$j0)S3;mH-IR5y82l|%=F z2lR8zhP?XNP-ucZZ6A+o$xOyF!w;RaLHGh57GZ|TCXhJqY~GCh)aXEV$1O&$c}La1 zjuJxkY9SM4av^Hb;i7efiYaMwI%jGy`3NdY)+mcJhF(3XEiSlU3c|jMBi|;m-c?~T z+x0_@;SxcoY=(6xNgO$bBt~Pj8`-<1S|;Bsjrzw3@zSjt^JC3X3*$HI79i~!$RmTz zsblZsLYs7L$|=1CB$8qS!tXrWs!F@BVuh?kN(PvE5Av-*r^iYu+L^j^m9JG^#=m>@ z=1soa)H*w6KzoR$B8mBCXoU;f5^bVuwQ3~2LKg!yxomG1#XPmn(?YH@E~_ED+W6mxs%x{%Z<$pW`~ON1~2XjP5v(0{C{+6Dm$00tsd3w=f=ZENy zOgb-=f}|Hb*LQ$YdWg<(u7x3`PKF)B7ZfZ6;1FrNM63 z?O6tE%EiU@6%rVuwIQjvGtOofZBGZT1Sh(xLIYt9c4VI8`!=UJd2BfLjdRI#SbVAX ziT(f*RI^T!IL5Ac>ql7uduF#nuCRJ1)2bdvAyMxp-5^Ww5p#X{rb5)(X|fEhDHHW{ zw(Lfc$g;+Q`B0AiPGtmK%*aWfQQ$d!*U<|-@n2HZvCWSiw^I>#vh+LyC;aaVWGbmkENr z&kl*8o^_FW$T?rDYLO1Pyi%>@&kJKQoH2E0F`HjcN}Zlnx1ddoDA>G4Xu_jyp6vuT zPvC}pT&Owx+qB`zUeR|4G;OH(<<^_bzkjln0k40t`PQxc$7h(T8Ya~X+9gDc8Z9{Z z&y0RAU}#_kQGrM;__MK9vwIwK^aoqFhk~dK!ARf1zJqHMxF2?7-8|~yoO@_~Ed;_wvT%Vs{9RK$6uUQ|&@#6vyBsFK9eZW1Ft#D2)VpQRwpR(;x^ zdoTgMqfF9iBl%{`QDv7B0~8{8`8k`C4@cbZAXBu00v#kYl!#_Wug{)2PwD5cNp?K^ z9+|d-4z|gZ!L{57>!Ogfbzchm>J1)Y%?NThxIS8frAw@z>Zb9v%3_3~F@<=LG%r*U zaTov}{{^z~SeX!qgSYow`_5)ij*QtGp4lvF`aIGQ>@3ZTkDmsl#@^5*NGjOuu82}o zzLF~Q9SW+mP=>88%eSA1W4_W7-Q>rdq^?t=m6}^tDPaBRGFLg%ak93W!kOp#EO{6& zP%}Iff5HZQ9VW$~+9r=|Quj#z*=YwcnssS~9|ub2>v|u1JXP47vZ1&L1O%Z1DsOrDfSIMHU{VT>&>H=9}G3i@2rP+rx@eU@uE8rJNec zij~#FmuEBj03F1~ct@C@$>y)zB+tVyjV3*n`mtAhIM0$58vM9jOQC}JJOem|EpwqeMuYPxu3sv}oMS?S#o6GGK@8PN59)m&K4Dc&X% z(;XL_kKeYkafzS3Wn5DD>Yiw{LACy_#jY4op(>9q>>-*9@C0M+=b#bknAWZ37^(Ij zq>H%<@>o4a#6NydoF{_M4i4zB_KG)#PSye9bk0Ou8h%1Dtl7Q_y#7*n%g)?m>xF~( zjqvOwC;*qvN_3(*a+w2|ao0D?@okOvg8JskUw(l7n`0fncglavwKd?~l_ryKJ^Ky! zKCHkIC-o7%fFvPa$)YNh022lakMar^dgL=t#@XLyNHHw!b?%WlM)R@^!)I!smZL@k zBi=6wE5)2v&!UNV(&)oOYW(6Qa!nUjDKKBf-~Da=#^HE4(@mWk)LPvhyN3i4goB$3K8iV7uh zsv+a?#c4&NWeK(3AH;ETrMOIFgu{_@%XRwCZ;L=^8Ts)hix4Pf3yJRQ<8xb^CkdmC z?c_gB)XmRsk`9ch#tx4*hO=#qS7={~Vb4*tTf<5P%*-XMfUUYkI9T1cEF;ObfxxI-yNuA=I$dCtz3ey znVkctYD*`fUuZ(57+^B*R=Q}~{1z#2!ca?)+YsRQb+lt^LmEvZt_`=j^wqig+wz@n@ z`LIMQJT3bxMzuKg8EGBU+Q-6cs5(@5W?N>JpZL{$9VF)veF`L5%DSYTNQEypW%6$u zm_~}T{HeHj1bAlKl8ii92l9~$dm=UM21kLemA&b$;^!wB7#IKWGnF$TVq!!lBlG4 z{?Rjz?P(uvid+|i$VH?`-C&Gcb3{(~Vpg`w+O);Wk1|Mrjxrht0GfRUnZqz2MhrXa zqgVC9nemD5)H$to=~hp)c=l9?#~Z_7i~=U-`FZxb-|TR9@YCxx;Zjo-WpMNOn2)z) zFPGGVl%3N$f`gp$gPnWC+f4(rmts%fidpo^BJx72zAd7|*Xi{2VXmbOm)1`w^tm9% znM=0Fg4bDxH5PxPEm{P3#A(mxqlM7SIARP?|2&+c7qmU8kP&iApzL|F>Dz)Ixp_`O zP%xrP1M6@oYhgo$ZWwrAsYLa4 z|I;DAvJxno9HkQrhLPQk-8}=De{9U3U%)dJ$955?_AOms!9gia%)0E$Mp}$+0er@< zq7J&_SzvShM?e%V?_zUu{niL@gt5UFOjFJUJ}L?$f%eU%jUSoujr{^O=?=^{19`ON zlRIy8Uo_nqcPa6@yyz`CM?pMJ^^SN^Fqtt`GQ8Q#W4kE7`V9^LT}j#pMChl!j#g#J zr-=CCaV%xyFeQ9SK+mG(cTwW*)xa(eK;_Z(jy)woZp~> zA(4}-&VH+TEeLzPTqw&FOoK(ZjD~m{KW05fiGLe@E3Z2`rLukIDahE*`u!ubU)9`o zn^-lyht#E#-dt~S>}4y$-mSbR8{T@}22cn^refuQ08NjLOv?JiEWjyOnzk<^R5%gO zhUH_B{oz~u#IYwVnUg8?3P*#DqD8#X;%q%HY**=I>>-S|!X*-!x1{^l#OnR56O>iD zc;i;KS+t$koh)E3)w0OjWJl_aW2;xF=9D9Kr>)(5}4FqUbk# zI#$N8o0w;IChL49m9CJTzoC!|u{Ljd%ECgBOf$}&jA^$(V#P#~)`&g`H8E{uv52pp zwto`xUL-L&WTAVREEm$0g_gYPL(^vHq(*t1WCH_6alhkeW&GCZ3hL)|{O-jiFOBrF z!EW=Jej|dqQitT6!B-7&io2K)WIm~Q)v@yq%U|VpV+I?{y0@Yd%n8~-NuuM*pM~KA z85YB};IS~M(c<}4Hxx>qRK0cdl&e?t253N%vefkgds>Ubn8X}j6Vpgs>a#nFq$osY z1ZRwLqFv=+BTb=i%D2Wv>_yE0z}+niZ4?rE|*a3d7^kndWGwnFqt+iZ(7+aln<}jzbAQ(#Z2SS}3S$%Bd}^ zc9ghB%O)Z_mTZMRC&H#)I#fiLuIkGa^`4e~9oM5zKPx?zjkC&Xy0~r{;S?FS%c7w< zWbMpzc(xSw?9tGxG~_l}Acq}zjt5ClaB7-!vzqnlrX;}$#+PyQ9oU)_DfePh2E1<7 ztok6g6K^k^DuHR*iJ?jw?bs_whk|bx`dxu^nC6#e{1*m~z1eq7m}Cf$*^Eua(oi_I zAL+3opNhJteu&mWQ@kQWPucmiP)4|nFG`b2tpC;h{-PI@`+h?9v=9mn|0R-n8#t=+Z*FD(c5 zjj79Jxkgck*DV=wpFgRZuwr%}KTm+dx?RT@aUHJdaX-ODh~gByS?WGx&czAkvkg;x zrf92l8$Or_zOwJVwh>5rB`Q5_5}ef6DjS*$x30nZbuO3dijS*wvNEqTY5p1_A0gWr znH<(Qvb!os14|R)n2Ost>jS2;d1zyLHu`Svm|&dZD+PpP{Bh>U&`Md;gRl64q;>{8MJJM$?UNUd`aC>BiLe>*{ zJY15->yW+<3rLgYeTruFDtk1ovU<$(_y7#HgUq>)r0{^}Xbth}V#6?%5jeFYt;SG^ z3qF)=uWRU;Jj)Q}cpY8-H+l_n$2$6{ZR?&*IGr{>ek!69ZH0ZoJ*Ji+ezzlJ^%qL3 zO5a`6gwFw(moEzqxh=yJ9M1FTn!eo&qD#y5AZXErHs%22?A+JmS&GIolml!)rZTnUDM3YgzYfT#;OXn)`PWv3Ta z!-i|-Wojv*k&bC}_JJDjiAK(Ba|YZgUI{f}TdEOFT2+}nPmttytw7j%@bQZDV1vvj z^rp{gRkCDmYJHGrE1~e~AE!-&6B6`7UxVQuvRrfdFkGX8H~SNP_X4EodVd;lXd^>eV1jN+Tt4}Rsn)R0LxBz0c=NXU|pUe!MQQFkGBWbR3&(jLm z%RSLc#p}5_dO{GD=DEFr=Fc% z85CBF>*t!6ugI?soX(*JNxBp+-DdZ4X0LldiK}+WWGvXV(C(Ht|!3$psR=&c*HIM=BmX;pRIpz@Ale{9dhGe(U2|Giv;# zOc|;?p67J=Q(kamB*aus=|XP|m{jN^6@V*Bpm?ye56Njh#vyJqE=DweC;?Rv7faX~ zde03n^I~0B2vUmr;w^X37tVxUK?4}ifsSH5_kpKZIzpYu0;Kv}SBGfI2AKNp+VN#z`nI{UNDRbo-wqa4NEls zICRJpu)??cj^*WcZ^MAv+;bDbh~gpN$1Cor<{Y2oyIDws^JsfW^5AL$azE(T0p&pP z1Mv~6Q44R&RHoH95&OuGx2srIr<@zYJTOMKiVs;Bx3py89I87LOb@%mr`0)#;7_~Z zzcZj8?w=)>%5@HoCHE_&hnu(n_yQ-L(~VjpjjkbT7e)Dk5??fApg(d>vwLRJ-x{um z*Nt?DqTSxh_MIyogY!vf1mU1`Gld-&L)*43f6dilz`Q@HEz;+>MDDYv9u!s;WXeao zUq=TaL$P*IFgJzrGc>j1dDOd zed+=ZBo?w4mr$2)Ya}?vedDopomhW1`#P<%YOJ_j=WwClX0xJH-f@s?^tmzs_j7t!k zK@j^zS0Q|mM4tVP5Ram$VbS6|YDY&y?Q1r1joe9dj08#CM{RSMTU}(RCh`hp_Rkl- zGd|Cv~G@F{DLhCizAm9AN!^{rNs8hu!G@8RpnGx7e`-+K$ffN<0qjR zGq^$dj_Tv!n*?zOSyk5skI7JVKJ)3jysnjIu-@VSzQiP8r6MzudCU=~?v-U8yzo^7 zGf~SUTvEp+S*!X9uX!sq=o}lH;r{pzk~M*VA(uyQ`3C8!{C;)&6)95fv(cK!%Cuz$ z_Zal57H6kPN>25KNiI6z6F)jzEkh#%OqU#-__Xzy)KyH};81#N6OfX$$IXWzOn`Q& z4f$Z1t>)8&8PcYfEwY5UadU1yg+U*(1m2ZlHoC-!2?gB!!fLhmTl))D@dhvkx#+Yj z1O=LV{(T%{^IeCuFK>%QR!VZ4GnO5tK8a+thWE zg4VytZrwcS?7^ zuZfhYnB8dwd%VLO?DK7pV5Wi<(`~DYqOXn8#jUIL^)12*Dbhk4GmL_E2`WX&iT16o zk(t|hok(Y|v-wzn?4x34T)|+SfZP>fiq!><*%vnxGN~ypST-FtC+@TPv*vYv@iU!_ z@2gf|PrgQ?Ktf*9^CnJ(x*CtZVB8!OBfg0%!wL;Z8(tYYre0vcnPGlyCc$V(Ipl*P z_(J!a=o@vp^%Efme!K74(Ke7A>Y}|sxV+JL^aYa{~m%5#$$+R1? zGaQhZTTX!#s#=Xtpegqero$RNt&`4xn3g$)=y*;=N=Qai)}~`xtxI_N*#MMCIq#HFifT zz(-*m;pVH&+4bixL&Bbg)W5FN^bH87pAHp)zPkWNMfTFqS=l~AC$3FX3kQUSh_C?-ZftyClgM)o_D7cX$RGlEYblux0jv5 zTr|i-I3@ZPCGheCl~BGhImF)K4!9@?pC(gi3ozX=a!|r1)LFxy_8c&wY0<^{2cm|P zv6Y`QktY*;I)IUd5y3ne1CqpVanlY45z8hf4&$EUBnucDj16pDa4&GI&TArYhf*xh zdj>*%APH8(h~c>o@l#%T>R$e>rwVx_WUB|~V`p^JHsg*y12lzj&zF}w6W09HwB2yb z%Q~`es&(;7#*DUC_w-Dmt7|$*?TA_m;zB+-u{2;Bg{O}nV7G_@7~<)Bv8fH^G$XG8$(&{A zwXJK5LRK%M34(t$&NI~MHT{UQ9qN-V_yn|%PqC81EIiSzmMM=2zb`mIwiP_b)x+2M z7Gd`83h79j#SItpQ}luuf2uOU`my_rY5T{6P#BNlb%h%<#MZb=m@y5aW;#o1^2Z)SWo+b`y0gV^iRcZtz5!-05vF z7wNo=hc6h4hc&s@uL^jqRvD6thVYtbErDK9k!;+a0xoE0WL7zLixjn5;$fXvT=O3I zT6jI&^A7k6R{&5#lVjz#8%_RiAa2{di{`kx79K+j72$H(!ass|B%@l%KeeKchYLe_ z>!(JC2fxsv>XVen+Y42GeYPxMWqm`6F$(E<6^s|g(slNk!lL*6v^W2>f6hh^mE$s= z3D$)}{V5(Qm&A6bp%2Q}*GZ5Qrf}n7*Hr51?bJOyA-?B4vg6y_EX<*-e20h{=0Mxs zbuQGZ$fLyO5v$nQ&^kuH+mNq9O#MWSfThtH|0q1i!NrWj^S}_P;Q1OkYLW6U^?_7G zx2wg?CULj7))QU(n{$0JE%1t2dWrMi2g-Os{v|8^wK{@qlj%+1b^?NI z$}l2tjp0g>K3O+p%yK<9!XqmQ?E9>z&(|^Pi~aSRwI5x$jaA62GFz9%fmO3t3a>cq zK8Xbv=5Ps~4mKN5+Eqw12(!PEyedFXv~VLxMB~HwT1Vfo51pQ#D8e$e4pFZ{&RC2P z5gTIzl{3!&(tor^BwZfR8j4k{7Rq#`riKXP2O-Bh66#WWK2w=z;iD9GLl+3 zpHIaI4#lQ&S-xBK8PiQ%dwOh?%BO~DCo06pN7<^dnZCN@NzY{_Z1>rrB0U|nC&+!2 z2y!oBcTd2;@lzyk(B=TkyZ)zy0deK05*Q0zk+o$@nun`VI1Er7pjq>8V zNmlW{p7S^Btgb(TA}jL(uR>`0w8gHP^T~Sh5Tkip^spk4SBAhC{TZU}_Z)UJw-}zm zPq{KBm!k)?P{`-(9?LFt&YN4s%SIZ-9lJ!Ws~B%exHOeVFk3~}HewnnH(d)qkLQ_d z6h>O)pEE{vbOVw}E+jdYC^wM+AAhaI(YAibUc@B#_mDss0Ji&BK{WG`4 zOk>vSNq(Bq2IB@s>>Rxm6Wv?h;ZXkpb1l8u|+_qXWdC*jjcPCixq;!%BVPSp#hP zqo`%cNf&YoQXHC$D=D45RiT|5ngPlh?0T~?lUf*O)){K@*Kbh?3RW1j9-T?%lDk@y z4+~?wKI%Y!-=O|_IuKz|=)F;V7ps=5@g)RrE;;tvM$gUhG>jHcw2Hr@fS+k^Zr~>G z^JvPrZc}_&d_kEsqAEMTMJw!!CBw)u&ZVzmq+ZworuaE&TT>$pYsd9|g9O^0orAe8 z221?Va!l1|Y5X1Y?{G7rt1sX#qFA^?RLG^VjoxPf63;AS=_mVDfGJKg73L zsGdnTUD40y(>S##2l|W2Cy!H(@@5KBa(#gs`vlz}Y~$ot5VsqPQ{{YtjYFvIumZzt zA{CcxZLJR|4#{j7k~Tu*jkwz8QA|5G1$Cl895R`Zyp;irp1{KN){kB30O8P1W5;@bG znvX74roeMmQlUi=v9Y%(wl$ZC#9tKNFpvi3!C}f1m6Ct|l2g%psc{TJp)@yu)*e2> z((p0Fg*8gJ!|3WZke9;Z{8}&NRkv7iP=#_y-F}x^y?2m%-D_aj^)f04%mneyjo_;) z6qc_Zu$q37d~X``*eP~Q>I2gg%rrV8v=kDfpp$=%Vj}hF)^dsSWygoN(A$g*E=Do6FX?&(@F#7pbiJ`;c0c@Ul zDqW_90Wm#5f2L<(Lf3)3TeXtI7nhYwRm(F;*r_G6K@OPW4H(Y3O5SjUzBC}u3d|eQ8*8d@?;zUPE+i#QNMn=r(ap?2SH@vo*m z3HJ%XuG_S6;QbWy-l%qU;8x;>z>4pMW7>R}J%QLf%@1BY(4f_1iixd-6GlO7Vp*yU zp{VU^3?s?90i=!#>H`lxT!q8rk>W_$2~kbpz7eV{3wR|8E=8**5?qn8#n`*(bt1xRQrdGxyx2y%B$qmw#>ZV$c7%cO#%JM1lY$Y0q?Yuo> ze9KdJoiM)RH*SB%^;TAdX-zEjA7@%y=!0=Zg%iWK7jVI9b&Dk}0$Af&08KHo+ zOwDhFvA(E|ER%a^cdh@^wLUlmIv6?_3=BvX8jKk92L=Y}7Jf5OGMfh` zBdR1wFCi-i5@`9km{isRb0O%TX+f~)KNaEz{rXQa89`YIF;EN&gN)cigu6mNh>?Cm zAO&Im2flv6D{jwm+y<%WsPe4!89n~KN|7}Cb{Z;XweER73r}Qp2 zz}WP4j}U0&(uD&9yGy6`!+_v-S(yG*iytsTR#x_Rc>=6u^vnRDnf1gP{#2>`ffrAC% zTZ5WQ@hAK;P;>kX{D)mIXe4%a5p=LO1xXH@8T?mz7Q@d)$3pL{{B!2{-v70L*o1AO+|n5beiw~ zk@(>m?T3{2k2c;NWc^`4@P&Z?BjxXJ@;x1qhn)9Mn*IFdt_J-dIqx5#d`NfyfX~m( zIS~5)MfZ2Uy?_4W`47i}u0ZgPh<{D|w_d#;D}Q&U$Q-G}xM1A@1f{#%A$jh6Qp&0hQ<0bPOM z-{1Wm&p%%#eb_?x7i;bol EfAhh=DF6Tf diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index 9b66d8c..0000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,2 +0,0 @@ -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java index 8e8a86d..c58d7b4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -21,18 +21,17 @@ @Tag(name = "Comment", description = "댓글 API") public interface CommentApi { - @Operation(summary = "콘텐츠 댓글 목록 조회", description = "콘텐츠의 댓글 목록을 페이징하여 최신순으로 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "댓글 목록 조회 성공", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), - @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) - }) - @GetMapping("/contents/{contentsId}/comments") - ResponseEntity>> getContentCommentsList( - @Parameter(description = "콘텐츠 ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer size, - @Parameter(description = "스포일러 포함 여부 (true: 전체 조회, false: 스포 제외)", example = "false") @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler, - @Parameter(hidden = true) Long memberId); + @Operation(summary = "콘텐츠 댓글 목록 조회", description = "콘텐츠의 댓글 목록을 페이징하여 최신순으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글 목록 조회 성공", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), + @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/contents/{contentsId}/comments") + ResponseEntity>> getContentCommentsList( + @Parameter(description = "콘텐츠 ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer size, + @Parameter(description = "스포일러 포함 여부 (true: 전체 조회, false: 스포 제외)", example = "false") @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index 768e847..8280138 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -24,11 +24,10 @@ public ResponseEntity>> getContent @PathVariable(value = "contentsId") Long contentsId, @RequestParam(value = "page", defaultValue = "0") Integer pageParam, @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, - @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler, - @AuthenticationPrincipal Long memberId) { + @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler) { return ResponseEntity.ok( SuccessResponse.of(commentService.getContentsCommentList(contentsId, pageParam, - sizeParam, includeSpoiler))); // + sizeParam, includeSpoiler))); } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java index 1b7a5c1..c6634d4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java @@ -41,7 +41,7 @@ ResponseEntity> getContentDetail( @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "플레이리스트 조회 성공", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), - @ApiResponse(responseCode = "400", description = "요청 파라미터 오류 (page/size 누락 또는 형식 오류)", content = { + @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) @GetMapping("/{contentsId}/playlist") diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index 0733798..f22beec 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -41,7 +41,7 @@ ResponseEntity> getSeriesDetail( @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), - @ApiResponse(responseCode = "400", description = "요청 파라미터 오류 (page/size 누락 또는 형식 오류)", content = { + @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index 01f2327..6cdb0a1 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -68,33 +68,14 @@ public PageResponse getSeriesContents(Long seriesId, int .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Pageable pageable = PageRequest.of(page, size); - - System.out.println("🚨 1. DB 쿼리 실행 직전!"); // <--- 추가 - + Page contentsPage = contentsRepository .findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc( seriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); - System.out.println("🚨 2. DB 조회 완료! (결과 개수: " + contentsPage.getContent().size() + ")"); // <--- 추가 - // List contentsList = contentsPage.getContent().stream() - // .map(SeriesContentsResponse::from).collect(Collectors.toList()); - - - // ======== 여기서부터 교체 ======== - List contentsList = new java.util.ArrayList<>(); - try { - for (Contents content : contentsPage.getContent()) { - System.out.println("👉 변환 시도 중인 에피소드 ID: " + content.getId()); - contentsList.add(SeriesContentsResponse.from(content)); - System.out.println("✅ 에피소드 ID: " + content.getId() + " 변환 성공!"); - } - } catch (Exception e) { - System.out.println("💥💥💥 대참사 발생! 범인은 바로 아래 에러입니다 💥💥💥"); - e.printStackTrace(); // <--- 이게 에러의 진짜 원인을 콘솔에 뱉어줍니다! - } - // ======== 여기까지 교체 ======== + List contentsList = contentsPage.getContent().stream() + .map(SeriesContentsResponse::from).collect(Collectors.toList()); - System.out.println("🚨 3. DTO 변환 및 리턴 직전!"); // <--- 추가 PageInfo pageInfo = PageInfo.builder() .currentPage(contentsPage.getNumber()) .totalPage(contentsPage.getTotalPages()) From 677ac7a5ed0da846de16ece1ed9bf72f0ad844e4 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:32:43 +0900 Subject: [PATCH 168/257] =?UTF-8?q?=EC=A4=80=ED=9D=AC=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=9B=90=EC=83=81=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC(=EB=AC=B4=EC=8B=9C=EB=B0=94=EB=9E=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/bookmark/service/BookmarkService.java | 2 +- .../main/java/com/ott/domain/tag/repository/TagRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index 9a5bb20..47a9f08 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -154,4 +154,4 @@ private Media resolveTargetMedia(Media media) { case SERIES, SHORT_FORM -> media; }; } -} +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index c4d83f4..89dfbce 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -23,7 +23,7 @@ public interface TagRepository extends JpaRepository { WHERE mt.media.id = :mediaId AND t.status = :status AND mt.status = :status - """) // 태그 삭제 x, 태그 연결 ` + """) List findTagNamesByMediaId(@Param("mediaId") Long mediaId, @Param("status") Status status); // 모든 카테고리에 있는 태그 조회 From 619a89104610f972715e8533679abf0e7deb67db Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:53:29 +0900 Subject: [PATCH 169/257] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EC=A0=9C=EC=95=88=20=EB=B0=98=EC=98=81=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/likes/service/LikesService.java | 2 +- .../java/com/ott/api_user/series/controller/SeriesApi.java | 3 +++ .../com/ott/domain/media/repository/MediaRepositoryImpl.java | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java index 66fdf79..cafd15a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/service/LikesService.java @@ -26,7 +26,7 @@ public class LikesService { /** * 좋아요 버튼 * CONTENTS → 시리즈 에피소드면 부모 Series.media로 처리 - * CONTENTS → 시리즈가 아닐경우 자기 자신 그래도 처리 + * CONTENTS → 시리즈가 아닐경우 자기 자신 그대로 처리 * SHORT_FORM → 그대로 처리 * SERIES → 그대로 처리 */ diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index f22beec..8a88044 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -40,6 +40,9 @@ ResponseEntity> getSeriesDetail( @ApiResponse(responseCode = "200", description = "시리즈 콘텐츠 목록 조회 성공", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), + @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 6d2b249..abf9963 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -30,7 +30,7 @@ public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, Medi List mediaList = queryFactory .selectFrom(media) .where( - media.mediaType.eq(mediaType), + mediaTypeEq(mediaType), titleContains(searchWord)) .orderBy(media.createdDate.desc()) .offset(pageable.getOffset()) @@ -41,7 +41,7 @@ public Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, Medi .select(media.count()) .from(media) .where( - media.mediaType.eq(mediaType), + mediaTypeEq(mediaType), titleContains(searchWord)); return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); From a66b4b16164815a6cb5cb4bde23c7e387ce1280f Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:01:52 +0900 Subject: [PATCH 170/257] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/comment/controller/CommentApi.java | 2 +- .../java/com/ott/api_user/content/controller/ContentApi.java | 3 ++- .../java/com/ott/api_user/series/controller/SeriesApi.java | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java index c58d7b4..0604115 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -24,7 +24,7 @@ public interface CommentApi { @Operation(summary = "콘텐츠 댓글 목록 조회", description = "콘텐츠의 댓글 목록을 페이징하여 최신순으로 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "댓글 목록 조회 성공", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java index c6634d4..018ce62 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java @@ -1,6 +1,7 @@ package com.ott.api_user.content.controller; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @@ -50,5 +51,5 @@ ResponseEntity>> getContentPlay @Parameter(description = "진입 맥락 (TRENDING, HISTORY, TAG 등)", example = "TAG") @RequestParam(value = "source", required = false) ContentSource source, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer pageParam, @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, - @Parameter(hidden = true) Long memberId); + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index 8a88044..4ef2722 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -32,7 +32,7 @@ public interface SeriesApi { @GetMapping("/{seriesId}") ResponseEntity> getSeriesDetail( @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("seriesId") Long seriesId, - @Parameter(hidden = true) Long memberId // 토큰에서 추출 (스웨거에서는 숨김) + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId // 토큰에서 추출 (스웨거에서는 숨김) ); @Operation(summary = "시리즈 콘텐츠 목록 조회", description = "특정 시리즈에 속한 콘텐츠(에피소드) 목록을 페이징하여 조회합니다.") @@ -58,7 +58,7 @@ ResponseEntity>> getSeriesC @Parameter(description = "페이지 크기", example = "24") @RequestParam(defaultValue = "24") Integer size, - @Parameter(hidden = true) Long memberId); + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); // 추후 이어보기 지점 추가 } \ No newline at end of file From 79a24be5d352b5d757397286eddc3976936d8e0a Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 14:02:16 +0900 Subject: [PATCH 171/257] =?UTF-8?q?[CHORE]:=20=EC=A3=BC=EC=84=9D,=20?= =?UTF-8?q?=EC=98=A4=ED=83=80,=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/comment/controller/CommentApi.java | 2 +- .../api_user/comment/dto/request/CreateCommentRequest.java | 2 +- .../api_user/comment/dto/request/UpdateCommentRequest.java | 2 +- .../java/com/ott/api_user/comment/service/CommentService.java | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java index a93d9bb..50191e7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -99,7 +99,7 @@ ResponseEntity> deleteComment( ResponseEntity>> getMyComments( @PositiveOrZero @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(type = "integer", defaultValue = "0")) @RequestParam(defaultValue = "0") Integer page, @PositiveOrZero @Parameter(description = "페이지 크기", schema = @Schema(type = "integer", defaultValue = "20")) @RequestParam(defaultValue = "20") Integer size, - @Parameter(hidden = true) Long memberId + @AuthenticationPrincipal @Parameter(hidden = true) Long memberId ); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java index 2131cec..20bcefc 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java @@ -12,7 +12,7 @@ public class CreateCommentRequest { @NotNull(message = "콘텐츠 ID는 필수 입니다.") - @Schema(type= "Long", example = "1", description = "댓글 ID") + @Schema(type= "Long", example = "1", description = "콘텐츠 ID") private Long contentId; @NotBlank(message = "댓글 내용은 필수 입니다.") diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java index e4c7a2a..333e989 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java @@ -16,6 +16,6 @@ public class UpdateCommentRequest { @Schema(type= "String", example = "아 ㅋㅋ 밤티하둥_수정123", description = "댓글") private String content; - @Schema(type= "Boolean", example = "true", description = "스포일러 포함 여부, 디폴트 false") + @Schema(type= "Boolean", example = "true", description = "스포일러 포함 여부") private Boolean isSpoiler; } diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java index f13ec34..258b57e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java @@ -50,7 +50,7 @@ public CommentResponse createComment(Long memberId, CreateCommentRequest request .content(request.getContent()) .isSpoiler(request.getIsSpoiler()) .build() - );; + ); return CommentResponse.from(saved); @@ -78,7 +78,7 @@ public void deleteComment(Long memberId, Long commentId) { Comment comment = commentRepository.findByIdAndStatus(commentId, Status.ACTIVE) .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); - // 본인만 수정 가능 + // 본인만 삭제 가능 -> softDelete if (!comment.getMember().getId().equals(memberId)) { throw new BusinessException(ErrorCode.COMMENT_FORBIDDEN); } From 050bbc51840343778086e2baceab8e51b1fb35d6 Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 14:02:32 +0900 Subject: [PATCH 172/257] =?UTF-8?q?[FIX]:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/common/web/exception/ErrorCode.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index d169343..c041996 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -48,11 +48,9 @@ public enum ErrorCode { INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), - MEDIA_NOT_FOUNT(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), + MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B008", "댓글을 찾을 수 없습니다."), COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "B009", "본인이 작성한 댓글만 수정/삭제할 수 있습니다."), - MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), - BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B008", "북마크를 찾을 수 없습니다"), UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B009", "지원하지 않는 이미지 확장자입니다."), UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B010", "지원하지 않는 동영상 확장자입니다."), INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B011", "파일 확장자가 올바르지 않습니다."), @@ -61,7 +59,8 @@ public enum ErrorCode { DUPLICATE_TAG_IN_LIST(HttpStatus.BAD_REQUEST, "B014", "태그 목록에 중복된 값이 있습니다."), INVALID_SHORTFORM_TARGET(HttpStatus.BAD_REQUEST, "B015", "seriesId와 contentsId 중 하나만 제공해야 합니다."), INVALID_SHORTFORM_CONTENTS_TARGET(HttpStatus.BAD_REQUEST, "B016", "시리즈에 속한 콘텐츠는 숏폼 원본으로 선택할 수 없습니다."), - SHORTFORM_ORIGIN_MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B017", "숏폼 원본 미디어를 찾을 수 없습니다.") + SHORTFORM_ORIGIN_MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B017", "숏폼 원본 미디어를 찾을 수 없습니다."), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B018", "북마크를 찾을 수 없습니다"), ; private final HttpStatus status; From 658983326e2ce7ca999bae21e424402ce7e0976a Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 14:02:53 +0900 Subject: [PATCH 173/257] =?UTF-8?q?[FEAT]:=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20201=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/comment/controller/CommentController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index b3650d8..ff755b9 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -7,9 +7,8 @@ import com.ott.api_user.comment.service.CommentService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -28,7 +27,8 @@ public ResponseEntity> createComment( @RequestBody CreateCommentRequest request, @AuthenticationPrincipal Long memberId) { - return ResponseEntity.ok(SuccessResponse.of(commentService.createComment(memberId, request))); + return ResponseEntity.status(HttpStatus.CREATED) + .body(SuccessResponse.of(commentService.createComment(memberId, request))); } // 댓글 수정 From ae96a7b969c9a5019facfe43f2675612679303ae Mon Sep 17 00:00:00 2001 From: marulog Date: Fri, 27 Feb 2026 14:03:11 +0900 Subject: [PATCH 174/257] =?UTF-8?q?[FEAT]:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/domain/comment/domain/Comment.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java b/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java index 5678a07..3ce8742 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/domain/Comment.java @@ -47,6 +47,11 @@ public class Comment extends BaseEntity { // 댓글 수정 public void update(String content, Boolean isSpoiler) { + + if (content == null || content.isBlank()) { + throw new IllegalArgumentException("댓글 내용이 비어있습니다."); + } + this.content = content; this.isSpoiler = isSpoiler; } From 2119ec19025c366492af6aa30f016cfdede97794 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:33:27 +0900 Subject: [PATCH 175/257] =?UTF-8?q?[FIX]:=20source=20defalut=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EC=B2=9C=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/content/service/ContentService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java index b048bdc..6424102 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java @@ -88,8 +88,7 @@ public PageResponse getContentPlayList(Long contentsId, Cont case RECOMMEND -> getRecommendPlaylist(memberId, pageable, mediaId); // 3. OO 님이 좋아하실만한 리스트 case TAG -> getTagPlaylist(mediaId, pageable); // 4. 같은 태그 가진 영상 case BOOKMARK -> getBookmarkPlaylist(memberId, pageable, mediaId); // 5. 내 북마크 목록 - case SEARCH -> getRecommendPlaylist(memberId, pageable, mediaId); // 6. 검색 진입은 추천 리스트로 대체 - default -> getTrendingPlaylist(pageable, mediaId); // 기본값은 인기 차트 + default -> getRecommendPlaylist(memberId, pageable, mediaId); // 기본값은 OO님이 좋아하실만한 콘텐츠 }; List contentList = playListPage.getContent().stream() From 534e4de0aa15f7376e69ca4bc8ac53b58e2361af Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 21:26:14 +0900 Subject: [PATCH 176/257] =?UTF-8?q?[FEAT]:=20NotNull=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/comment/dto/request/CreateCommentRequest.java | 1 + .../ott/api_user/comment/dto/request/UpdateCommentRequest.java | 2 ++ .../ott/api_user/comment/dto/response/MyCommentResponse.java | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java index 20bcefc..4ac348f 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/CreateCommentRequest.java @@ -20,6 +20,7 @@ public class CreateCommentRequest { @Schema(type= "String", example = "아 ㅋㅋ 밤티하둥", description = "댓글") private String content; + @NotNull(message = "스포 유무 입력은 필수 입니다.") @Schema(type= "Boolean", example = "true", description = "스포 유무, 디폴트 false") private Boolean isSpoiler = false; diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java index 333e989..b757e96 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/request/UpdateCommentRequest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,6 +17,7 @@ public class UpdateCommentRequest { @Schema(type= "String", example = "아 ㅋㅋ 밤티하둥_수정123", description = "댓글") private String content; + @NotNull(message = "스포 유무 입력은 필수 입니다.") @Schema(type= "Boolean", example = "true", description = "스포일러 포함 여부") private Boolean isSpoiler; } diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java index a869bd3..302b49c 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/response/MyCommentResponse.java @@ -29,7 +29,7 @@ public class MyCommentResponse { @Schema(type = "String", example = "김마루", description = "작성자 닉네임") private String writerNickname; - @Schema(type = "LocalDateTime", description = "작성일시") + @Schema(type = "string", format = "date-time", example = "2024-01-01T00:00:00", description = "작성일시") private LocalDateTime createdDate; public static MyCommentResponse from(Comment comment) { From 8e675dcc46aed60dd70b31a14ebfbad56dc07665 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 21:27:00 +0900 Subject: [PATCH 177/257] =?UTF-8?q?[FEAT]:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20Patch=20=EC=88=98=EC=A0=95,=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EB=B0=94=EB=94=94=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/comment/controller/CommentApi.java | 15 +++++++++------ .../comment/controller/CommentController.java | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java index 50191e7..0ce0af6 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -62,7 +62,7 @@ ResponseEntity> createComment( content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) }) - @PutMapping("/{commentId}") + @PatchMapping("/{commentId}") ResponseEntity> updateComment( @Positive @Parameter(description = "댓글 ID") @PathVariable Long commentId, @Valid @RequestBody UpdateCommentRequest request, @@ -71,7 +71,7 @@ ResponseEntity> updateComment( @Operation(summary = "댓글 삭제", description = "본인이 작성한 댓글을 삭제합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "댓글 삭제 성공"), + @ApiResponse(responseCode = "204", description = "댓글 삭제 성공"), @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), @@ -83,22 +83,25 @@ ResponseEntity> updateComment( schema = @Schema(implementation = ErrorResponse.class))) }) @DeleteMapping("/{commentId}") - ResponseEntity> deleteComment( + ResponseEntity deleteComment( @Positive @Parameter(description = "댓글 ID") @PathVariable Long commentId, @AuthenticationPrincipal @Parameter(hidden = true) Long memberId ); @Operation(summary = "내가 작성한 댓글 목록 조회", description = "내가 작성한 댓글 목록을 최신순으로 조회합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = MyCommentResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) }) @GetMapping("/me") ResponseEntity>> getMyComments( - @PositiveOrZero @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(type = "integer", defaultValue = "0")) @RequestParam(defaultValue = "0") Integer page, - @PositiveOrZero @Parameter(description = "페이지 크기", schema = @Schema(type = "integer", defaultValue = "20")) @RequestParam(defaultValue = "20") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호 (0부터 시작)", schema = @Schema(type = "Integer", defaultValue = "0")) @RequestParam(defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기", schema = @Schema(type = "Integer", defaultValue = "20")) @RequestParam(defaultValue = "20") Integer size, @AuthenticationPrincipal @Parameter(hidden = true) Long memberId ); diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index ff755b9..c24b694 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -33,7 +33,7 @@ public ResponseEntity> createComment( // 댓글 수정 @Override - @PutMapping("/{commentId}") + @PatchMapping("/{commentId}") public ResponseEntity> updateComment( @PathVariable Long commentId, @RequestBody UpdateCommentRequest request, @@ -45,13 +45,13 @@ public ResponseEntity> updateComment( // 댓글 삭제 @Override @DeleteMapping("/{commentId}") - public ResponseEntity> deleteComment( + public ResponseEntity deleteComment( @PathVariable Long commentId, @AuthenticationPrincipal Long memberId) { commentService.deleteComment(memberId, commentId); - return ResponseEntity.ok(SuccessResponse.of(null)); + return ResponseEntity.noContent().build(); } // 댓글 조회 - 본인 댓글만 조회 가능, 최신순 정렬 From c2fb9b96114510c4896798be7bc4df39db87f010 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 22:35:10 +0900 Subject: [PATCH 178/257] =?UTF-8?q?[FEAT]:=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/auth/oauth2/CustomOAuth2UserService.java | 2 +- .../com/ott/api_user/auth/service/KakaoAuthService.java | 7 +++---- .../com/ott/api_user/member/service/MemberService.java | 1 + .../db/migration/V3__member_onboarding_completed.sql | 6 ++++++ 4 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java index 5f4a890..5391498 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java @@ -42,7 +42,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic Member member = kakaoAuthService.findOrCreateMember(kakaoUserInfo); // 신규 회원 판별 - boolean isNewMember = kakaoAuthService.isNewMember(member.getId()); + boolean isNewMember = kakaoAuthService.isNewMember(member); // attribute에 memberId(PK)와 신규 유저 유무를 적재 // payload memberId, isNewMember만 들어감 -> 민감한 정보 적재 x diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java index 1565268..d23a779 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -24,7 +24,6 @@ public class KakaoAuthService { private final MemberRepository memberRepository; - private final PreferredTagRepository preferredTagRepository; // 카카오 사용자 정보로 회원 조회 or 신규 생성 // 기존 회원일 경우 프로필 동기화 필요 @@ -47,9 +46,9 @@ public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { )); } - // 신규 회원 판별 -> 태그 소유 유무로 판단 - public boolean isNewMember(Long memberId) { - return !preferredTagRepository.existsByMemberId(memberId); + // 신규 회원 판별 -> 컬럼으로 판별 + public boolean isNewMember(Member member) { + return !member.isOnboardingCompleted(); } // refresh token 저장 diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index f35df85..bb48225 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -110,6 +110,7 @@ public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { .toList(); preferredTagRepository.saveAll(preferredTags); + findMember.completeOnboarding(); } } diff --git a/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql new file mode 100644 index 0000000..a062f21 --- /dev/null +++ b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql @@ -0,0 +1,6 @@ +ALTER TABLE member + ADD COLUMN onboarding_completed BOOLEAN NOT NULL DEFAULT FALSE; + +-- Existing users should not be forced back into onboarding after deployment. +UPDATE member +SET onboarding_completed = TRUE; From c0cb59de061b14c6a1e9562a947f365a9947b2d5 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 22:39:17 +0900 Subject: [PATCH 179/257] =?UTF-8?q?[FEAT]:=20Member=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20flyway=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/domain/member/domain/Member.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 08b759c..62d0c80 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -50,6 +50,9 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + @Column(name = "onboarding_completed", nullable = false) + private boolean onboardingCompleted; + public static Member createKakaoMember(String providerId, String email, String nickname) { return Member.builder() .provider(Provider.KAKAO) @@ -84,4 +87,9 @@ public void changeRole(Role targetRole) { public void updateNickname(String nickname) { this.nickname = nickname; } + + // 온보딩 여부 + public void completeOnboarding() { + this.onboardingCompleted = true; + } } From f395a8f35e89ad14787eaa105fe5a51e7defa6dc Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 22:44:50 +0900 Subject: [PATCH 180/257] =?UTF-8?q?[FEAT]:=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=9C=A0=ED=8B=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 56 +++---------------- .../oauth2/handler/OAuth2SuccessHandler.java | 32 +++-------- .../com/ott/api_user/common/CookieUtil.java | 33 +++++++++++ 3 files changed, 49 insertions(+), 72 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java index a314540..2baa3ea 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -3,6 +3,7 @@ import com.ott.api_user.auth.dto.TokenResponse; import com.ott.api_user.auth.service.AuthService; +import com.ott.api_user.common.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import jakarta.servlet.http.Cookie; @@ -12,10 +13,9 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; + @RestController @RequestMapping("/auth") @@ -23,6 +23,7 @@ public class AuthController implements AuthApi { private final AuthService authService; + private final CookieUtil cookieUtil; @Value("${jwt.access-token-expiry}") private int accessTokenExpiry; @@ -46,8 +47,8 @@ public ResponseEntity reissue( TokenResponse tokenResponse = authService.reissue(refreshToken); // 쿠키에 저장 - addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + cookieUtil.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); return ResponseEntity.noContent().build(); } @@ -65,41 +66,14 @@ public ResponseEntity logout( Long memberId = (Long) authentication.getPrincipal(); authService.logout(memberId); - deleteCookie(response, "accessToken"); - deleteCookie(response, "refreshToken"); + cookieUtil.deleteCookie(response, "accessToken"); + cookieUtil.deleteCookie(response, "refreshToken"); return ResponseEntity.noContent().build(); } - // 임시 테스트 코드 -> 추후 프론트 페이지로 변경 예정 - @GetMapping("logincheck") - public ResponseEntity> logincheck( - @RequestParam(value = "isNewMember") boolean isNewMember, - HttpServletRequest request - ) { - String accessToken = extractCookie(request, "accessToken"); - String refreshToken = extractCookie(request, "refreshToken"); - - - return ResponseEntity.ok(Map.of( - "isNewMember", isNewMember, - "accessToken", accessToken, - "refreshToken", refreshToken - )); - } - - - // 인가 테스트용 코드 -> 이렇게 @AuthenticationPrincipal로 쓰시면 됩니다. - // 추후 memberId -> UserDetails로 리팩토링 예정 - @GetMapping("/me") - public Long me(@AuthenticationPrincipal Long memberId) { - return memberId; - } - - - // 쿠키에 대한 접근은 HTTP고 서비스로 내려가면 안되기 때문에 Controller에서 구현 private String extractCookie(HttpServletRequest request, String name) { if (request.getCookies() == null) { @@ -114,22 +88,6 @@ private String extractCookie(HttpServletRequest request, String name) { return null; } - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 삭제 - response.addCookie(cookie); - } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java index a4817e7..747788d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -1,8 +1,8 @@ package com.ott.api_user.auth.oauth2.handler; import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.api_user.common.CookieUtil; import com.ott.common.security.jwt.JwtTokenProvider; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,7 +12,6 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; import java.util.List; @@ -31,6 +30,7 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JwtTokenProvider jwtTokenProvider; private final KakaoAuthService kakaoAuthService; + private final CookieUtil cookieUtil; @Value("${app.frontend-url}") private String frontedUrl; @@ -69,31 +69,17 @@ public void onAuthenticationSuccess( kakaoAuthService.saveRefreshToken(memberId, refreshToken); // 쿠키로 저장 - addCookie(response, "accessToken", accessToken, accessTokenExpiry); - addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); + cookieUtil.addCookie(response, "accessToken", accessToken, accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); - // 리다이렉트에는 쿼리 파라미터로 isNewMember만 전달 - String redirectUri = request.getParameter("redirect_uri"); - if (redirectUri == null || redirectUri.isBlank()) { - redirectUri = frontedUrl + "/auth/logincheck"; // 배포 후 변경 예정 - } - - String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) - .queryParam("isNewMember", isNewMember) - .build() - .toUriString(); + // 리다이렉트에는 isNewMember에 따라서 경로 변경 + String targetUrl = isNewMember + ? frontedUrl + "/auth/userinfo" + : frontedUrl + "/"; getRedirectStrategy().sendRedirect(request, response, targetUrl); } - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - cookie.setSecure(false); // 배포 서버에서는 true로 변경 - cookie.setPath("/"); // 모든 경로에서 전송 - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java b/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java new file mode 100644 index 0000000..08dbac4 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java @@ -0,0 +1,33 @@ +package com.ott.api_user.common; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +@Component +public class CookieUtil { + + public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 + .secure(true) // HTTPS 요청만 허용 + .path("/") // 모든 경로로 전송 + .maxAge(maxAge) + .sameSite("None") // 크로스 사이트에 대해서 쿠키 전송 허용 + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .domain("openthetaste.cloud") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} \ No newline at end of file From 363cbaf6306307476e5bfc7c340f73e8da2dff2c Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 23:30:43 +0900 Subject: [PATCH 181/257] =?UTF-8?q?[DOCS]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthApi.java | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java index 686b105..38d14fe 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java @@ -22,11 +22,24 @@ public interface AdminAuthApi { @Operation( summary = "관리자 로그인", - description = "이메일/비밀번호로 로그인합니다. " - + "Access Token과 Refresh Token은 HttpOnly 쿠키로 세팅됩니다." + description = """ + 이메일/비밀번호로 관리자 로그인을 수행합니다. + + - ADMIN or EDITOR 권한을 가진 계정만 로그인이 가능합니다. + - 응답 Body에는 memberId와 role만 포함됩니다. + """ + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = AdminLoginResponse.class))), + + @ApiResponse( + responseCode = "400", + description = "요청 값 유효성 검증 실패 (이메일 형식 오류, 필드 누락 등)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), @ApiResponse( responseCode = "401", description = "이메일 또는 비밀번호 불일치", @@ -44,7 +57,13 @@ ResponseEntity> login( @Operation( summary = "토큰 재발급", - description = "refreshToken 쿠키를 사용해 Access Token과 Refresh Token을 재발급합니다." + description = """ + 쿠키의 refreshToken을 검증하여 Access Token과 Refresh Token을 재발급합니다. + + - 요청 시 refreshToken 쿠키가 반드시 포함되어야 합니다. + - 보안을 위해 Access Token과 Refresh Token을 모두 재발급합니다. (Refresh Token Rotation) + - 재발급된 토큰은 기존 쿠키를 덮어씁니다. + """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "재발급 성공"), @@ -54,11 +73,18 @@ ResponseEntity> login( content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ) }) - ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + ResponseEntity reissue( + HttpServletRequest request, HttpServletResponse response); @Operation( summary = "로그아웃", - description = "DB의 refreshToken을 삭제하고 accessToken/refreshToken 쿠키를 제거합니다." + description = """ + 로그인된 관리자를 로그아웃 처리합니다. + + - DB에 저장된 refreshToken을 삭제합니다. + - accessToken, refreshToken 쿠키를 즉시 만료시킵니다. + - 이후 해당 토큰으로는 인증이 불가능합니다. + """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "로그아웃 성공"), From 128f1dd43dc026048ff8cc14fc8d116f9b6a96b1 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 23:31:33 +0900 Subject: [PATCH 182/257] =?UTF-8?q?[FEAT]:=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthController.java | 32 +++++------------ .../com/ott/api_admin/common/CookieUtil.java | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java index 127aca8..70ed811 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -4,6 +4,7 @@ import com.ott.api_admin.auth.dto.response.AdminLoginResponse; import com.ott.api_admin.auth.dto.response.AdminTokenResponse; import com.ott.api_admin.auth.service.AdminAuthService; +import com.ott.api_admin.common.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.SuccessResponse; @@ -27,6 +28,7 @@ public class AdminAuthController implements AdminAuthApi { private final AdminAuthService adminAuthService; + private final CookieUtil cookie; @Value("${jwt.access-token-expiry}") private int accessTokenExpiry; @@ -43,8 +45,8 @@ public ResponseEntity> login( AdminLoginResponse loginResponse = adminAuthService.login(request); // 둘 다 쿠키로 - addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); + cookie.addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); // Body에는 memberId, role만 (토큰은 @JsonIgnore) return SuccessResponse.of(loginResponse).asHttp(HttpStatus.OK); @@ -63,8 +65,8 @@ public ResponseEntity reissue( AdminTokenResponse tokenResponse = adminAuthService.reissue(refreshToken); - addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + cookie.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); return ResponseEntity.noContent().build(); } @@ -78,8 +80,8 @@ public ResponseEntity logout( Long memberId = (Long) authentication.getPrincipal(); adminAuthService.logout(memberId); - deleteCookie(response, "accessToken"); - deleteCookie(response, "refreshToken"); + cookie.deleteCookie(response, "accessToken"); + cookie.deleteCookie(response, "refreshToken"); return ResponseEntity.noContent().build(); } @@ -93,22 +95,4 @@ private String extractCookie(HttpServletRequest request, String name) { } return null; } - - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); - } } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java b/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java new file mode 100644 index 0000000..a5e001b --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java @@ -0,0 +1,34 @@ +package com.ott.api_admin.common; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 + .secure(true) // HTTPS 요청만 허용 + .path("/") // 모든 경로로 전송 + .maxAge(maxAge) + .sameSite("None") // 크로스 사이트에 대해서 쿠키 전송 허용 + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} \ No newline at end of file From 8fe448dfc02e9652e75ac4c7d5184c93b9ff0f97 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 23:32:02 +0900 Subject: [PATCH 183/257] =?UTF-8?q?[DOCS]:=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_admin/auth/dto/response/AdminLoginResponse.java | 4 ++-- .../ott/api_admin/auth/dto/response/AdminTokenResponse.java | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java index e0b6d70..413bb6e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java @@ -16,9 +16,9 @@ public class AdminLoginResponse { @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 private String refreshToken; - @Schema(description = "회원 ID", example = "1") + @Schema(type = "Long", description = "회원 ID", example = "1") private Long memberId; - @Schema(description = "회원 역할", example = "ADMIN") + @Schema(type= "String", description = "회원 역할", example = "ADMIN") private String role; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java index 4f20127..dda0cf4 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java @@ -1,5 +1,6 @@ package com.ott.api_admin.auth.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,6 +10,10 @@ @Getter @AllArgsConstructor public class AdminTokenResponse { + + @Schema(type = "String", description = "accessToken", example = "122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") private String accessToken; + + @Schema(type = "String", description = "refreshToken", example = "eym122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") private String refreshToken; } \ No newline at end of file From 3eab2777b09385012ff3928f913b238a6416c9d0 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 23:32:11 +0900 Subject: [PATCH 184/257] =?UTF-8?q?[DOCS]:=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/api_admin/auth/dto/request/AdminLoginRequest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java index b840b35..a68bd06 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java @@ -13,10 +13,10 @@ public class AdminLoginRequest { @Email @NotBlank - @Schema(description = "관리자 이메일", example = "admin@ott.com") + @Schema(type= "String",description = "관리자 이메일", example = "admin@ott.com") private String email; @NotBlank - @Schema(description = "비밀번호", example = "password123") + @Schema(type="String", description = "비밀번호", example = "password123") private String password; } \ No newline at end of file From ac8c0cb8cb47b08475fa01e16ea39f548c34d098 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 23:40:01 +0900 Subject: [PATCH 185/257] =?UTF-8?q?[FEAT]:=20CORS=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B6=8C=ED=95=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_admin/config/SecurityConfig.java | 40 +++++++++++++++---- .../ott/api_user/config/SecurityConfig.java | 40 ++++++++++--------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index c9dc2be..481a727 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -14,6 +14,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @RequiredArgsConstructor @@ -30,20 +35,25 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .cors(AbstractHttpConfigurer::disable) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> e - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - .accessDeniedHandler(jwtAccessDeniedHandler)) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + .authorizeHttpRequests(auth -> auth + // 인증 불필요 .requestMatchers( "/actuator/health/**", "/actuator/info", "/back-office/login", "/back-office/reissue", - "/back-office/swagger-ui/**", - "/back-office/v3/api-docs/**", - "/back-office/swagger-resources/**" + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") @@ -56,4 +66,20 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java index 8bc5552..ba27f25 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -44,13 +44,11 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) // csrf 비활성화, Authorization 헤더로 보냄 - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - -// .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .cors(AbstractHttpConfigurer::disable) - - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증 + .formLogin(AbstractHttpConfigurer::disable) // 카카오 OAtuh2 + JWT기반이라 기본 로그인 폼 안씀 + .httpBasic(AbstractHttpConfigurer::disable) // 카카오 OAtuh2 + JWT기반이라 Basic 인증 안씀 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증이라 세션 유지 x .exceptionHandling(e -> e .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 @@ -61,7 +59,6 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health/**", "/actuator/info", - "/auth/**", "/oauth2/**", "/login/oauth2/**", "/auth/reissue", @@ -69,10 +66,12 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" - ).permitAll() + ).permitAll() - // 나머지 url에 대해서는 인증 필요 - .anyRequest().authenticated() + /* + 역할이 MEMBER인 유저만 그 외 EndPoint 접근 가능하도록 설정 + */ + .anyRequest().hasRole("MEMBER") ) // OAuth2 카카오 로그인 @@ -83,7 +82,9 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .failureHandler(oAuth2FailureHandler) ) - // UsernamePasswordAuthenticationFilter 보다 먼저 실행 + // Spring Security보다 먼저 실행 + // 쿠키에서 AccessToken을 꺼내와서 검증 이후 SecurityContext에 인증 정보 박제 + // 해당 과정에서 memberId, ROLE을 context에 넣어줌 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); @@ -93,19 +94,20 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - // allowedOrigins -> 허용할 Origin 내역 + // allowedOrigins -> 허용할 도메인 내역 // allowCredentials -> 브라우저가 요청에 인증정보를 포함하는 것을 허용하겠냐 // credentials가 true일 경우 Allow-origin의 경우 구체적인 경로를 명시해야됨 - config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); -// config.setAllowedOrigins(List.of(frontedUrl)); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); + config.setAllowedHeaders(List.of("*")); // 모든 헤더 다 받는데 우리 서비스에서는 안씀 + config.setAllowCredentials(true); // 쿠키 요청을 포함 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); + source.registerCorsConfiguration("/**", config); // 위 설정을 모든 경로에 적용 return source; } -} +} \ No newline at end of file From 08de8750b3fb5ffcde1791bdb4ea15ab3b2c65a3 Mon Sep 17 00:00:00 2001 From: marulog Date: Sat, 28 Feb 2026 23:40:18 +0900 Subject: [PATCH 186/257] =?UTF-8?q?[FEAT]:=20=EB=A1=9C=EC=BB=AC=EC=9A=A9?= =?UTF-8?q?=20CORS=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_admin/common/CookieUtil.java | 12 ++++++------ .../java/com/ott/api_user/common/CookieUtil.java | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java b/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java index a5e001b..6669e08 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java @@ -10,24 +10,24 @@ public class CookieUtil { public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { ResponseCookie cookie = ResponseCookie.from(name, value) - .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - .secure(true) // HTTPS 요청만 허용 + .secure(false) // HTTPS 요청만 허용 .path("/") // 모든 경로로 전송 .maxAge(maxAge) - .sameSite("None") // 크로스 사이트에 대해서 쿠키 전송 허용 + .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } public void deleteCookie(HttpServletResponse response, String name) { ResponseCookie cookie = ResponseCookie.from(name, "") - .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) - .secure(true) + .secure(false) .path("/") .maxAge(0) - .sameSite("None") + .sameSite("Lax") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java b/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java index 08dbac4..0ad3da3 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java +++ b/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java @@ -9,24 +9,24 @@ public class CookieUtil { public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { ResponseCookie cookie = ResponseCookie.from(name, value) - .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - .secure(true) // HTTPS 요청만 허용 + .secure(false) // HTTPS 요청만 허용 .path("/") // 모든 경로로 전송 .maxAge(maxAge) - .sameSite("None") // 크로스 사이트에 대해서 쿠키 전송 허용 + .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } public void deleteCookie(HttpServletResponse response, String name) { ResponseCookie cookie = ResponseCookie.from(name, "") +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) - .secure(true) + .secure(false) .path("/") .maxAge(0) - .sameSite("None") - .domain("openthetaste.cloud") + .sameSite("Lax") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } From 3762cd2afcabd83e1e7fae04f28f3d622ab55383 Mon Sep 17 00:00:00 2001 From: marulog Date: Sun, 1 Mar 2026 00:30:10 +0900 Subject: [PATCH 187/257] =?UTF-8?q?[FEAT]:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=EC=9D=B8=EC=A6=9D=20=ED=9B=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20->=20NPE=20=EB=B0=A9=EC=A7=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_user/config/SecurityConfig.java | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java index ba27f25..2373df7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -62,7 +62,6 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/oauth2/**", "/login/oauth2/**", "/auth/reissue", - "/auth/logout", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" From 6a4e1ba98cc4d83d0c1a876b01e621013433c9a6 Mon Sep 17 00:00:00 2001 From: marulog Date: Sun, 1 Mar 2026 01:11:56 +0900 Subject: [PATCH 188/257] =?UTF-8?q?[REFACTOR]:=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B3=B5=ED=86=B5=ED=99=94(common-securit?= =?UTF-8?q?y)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthController.java | 2 +- .../com/ott/api_admin/common/CookieUtil.java | 34 ------------------- .../auth/controller/AuthController.java | 2 +- .../oauth2/handler/OAuth2SuccessHandler.java | 2 +- .../ott/common/security/util}/CookieUtil.java | 3 +- 5 files changed, 5 insertions(+), 38 deletions(-) delete mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java rename {apps/api-user/src/main/java/com/ott/api_user/common => modules/common-security/src/main/java/com/ott/common/security/util}/CookieUtil.java (97%) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java index 70ed811..2207929 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -4,7 +4,7 @@ import com.ott.api_admin.auth.dto.response.AdminLoginResponse; import com.ott.api_admin.auth.dto.response.AdminTokenResponse; import com.ott.api_admin.auth.service.AdminAuthService; -import com.ott.api_admin.common.CookieUtil; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.SuccessResponse; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java b/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java deleted file mode 100644 index 6669e08..0000000 --- a/apps/api-admin/src/main/java/com/ott/api_admin/common/CookieUtil.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.ott.api_admin.common; - -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.stereotype.Component; - -@Component -public class CookieUtil { - - public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - ResponseCookie cookie = ResponseCookie.from(name, value) -// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! - .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - .secure(false) // HTTPS 요청만 허용 - .path("/") // 모든 경로로 전송 - .maxAge(maxAge) - .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); - } - - public void deleteCookie(HttpServletResponse response, String name) { - ResponseCookie cookie = ResponseCookie.from(name, "") -// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! - .httpOnly(true) - .secure(false) - .path("/") - .maxAge(0) - .sameSite("Lax") - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); - } -} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java index 2baa3ea..200e559 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -3,7 +3,7 @@ import com.ott.api_user.auth.dto.TokenResponse; import com.ott.api_user.auth.service.AuthService; -import com.ott.api_user.common.CookieUtil; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import jakarta.servlet.http.Cookie; diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java index 747788d..6444125 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -1,7 +1,7 @@ package com.ott.api_user.auth.oauth2.handler; import com.ott.api_user.auth.service.KakaoAuthService; -import com.ott.api_user.common.CookieUtil; +import com.ott.common.security.util.CookieUtil; import com.ott.common.security.jwt.JwtTokenProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java similarity index 97% rename from apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java rename to modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java index 0ad3da3..8802ab0 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/common/CookieUtil.java +++ b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java @@ -1,9 +1,10 @@ -package com.ott.api_user.common; +package com.ott.common.security.util; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; + @Component public class CookieUtil { From 8627e13bcecf5d77c0393b01890bacb9ea4ac6a0 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:31:00 +0900 Subject: [PATCH 189/257] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentApi.java | 5 ++- .../comment/controller/CommentController.java | 8 +++- .../api_user/comment/dto/CommentResponse.java | 2 +- .../comment/service/CommentService.java | 17 ++------ .../{ContentApi.java => ContentsApi.java} | 16 +++++--- ...ontroller.java => ContentsController.java} | 21 +++++----- ...ponse.java => ContentsDetailResponse.java} | 6 +-- ...ntentService.java => ContentsService.java} | 35 +++++++---------- .../dto/request/PlaylistCondition.java | 39 +++++++++++++++++++ .../dto/response/PlaylistResponse.java | 37 ++++++++++++++++++ .../playlist/service/PlaylistService.java | 33 ++++++++++++++++ .../service/strategy/PlaylistStrategy.java | 6 +++ .../api_user/series/controller/SeriesApi.java | 19 ++++----- .../series/controller/SeriesController.java | 9 +++-- .../comment/repository/CommentRepository.java | 25 ++++++------ .../repository/ContentsRepository.java | 12 +++++- .../media/repository/MediaRepository.java | 1 + 17 files changed, 206 insertions(+), 85 deletions(-) rename apps/api-user/src/main/java/com/ott/api_user/content/controller/{ContentApi.java => ContentsApi.java} (78%) rename apps/api-user/src/main/java/com/ott/api_user/content/controller/{ContentController.java => ContentsController.java} (73%) rename apps/api-user/src/main/java/com/ott/api_user/content/dto/{ContentDetailResponse.java => ContentsDetailResponse.java} (93%) rename apps/api-user/src/main/java/com/ott/api_user/content/service/{ContentService.java => ContentsService.java} (81%) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java index 0604115..9eaa933 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentApi.java @@ -6,13 +6,14 @@ import org.springframework.web.bind.annotation.RequestParam; import com.ott.api_user.comment.dto.CommentResponse; -import com.ott.api_user.content.dto.ContentDetailResponse; +import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; 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; @@ -23,6 +24,8 @@ public interface CommentApi { @Operation(summary = "콘텐츠 댓글 목록 조회", description = "콘텐츠의 댓글 목록을 페이징하여 최신순으로 조회합니다.") @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "조회 성공 - 콘텐츠 댓글 목록 구성", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = CommentResponse.class))) }), @ApiResponse(responseCode = "200", description = "댓글 목록 조회 성공", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index 8280138..4f96108 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -11,8 +12,11 @@ import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +@Validated @RestController @RequiredArgsConstructor public class CommentController implements CommentApi { @@ -22,8 +26,8 @@ public class CommentController implements CommentApi { @Override public ResponseEntity>> getContentCommentsList( @PathVariable(value = "contentsId") Long contentsId, - @RequestParam(value = "page", defaultValue = "0") Integer pageParam, - @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, + @RequestParam(value = "page", defaultValue = "0") @Min(0) Integer pageParam, + @RequestParam(value = "size", defaultValue = "20") @Positive Integer sizeParam, @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler) { return ResponseEntity.ok( SuccessResponse.of(commentService.getContentsCommentList(contentsId, pageParam, diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/CommentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/CommentResponse.java index 24f5d6e..49113b3 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/dto/CommentResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/dto/CommentResponse.java @@ -24,7 +24,7 @@ public class CommentResponse { private String content; @Schema(description = "스포일러 여부", example = "true") - private Boolean isSpoiler; + private boolean isSpoiler; @Schema(description = "작성 일시") private LocalDateTime createdAt; diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java index 3b8644d..c342938 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/service/CommentService.java @@ -17,7 +17,6 @@ import com.ott.common.web.response.PageResponse; import com.ott.domain.comment.domain.Comment; import com.ott.domain.comment.repository.CommentRepository; -import com.ott.domain.common.PublicStatus; import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; import com.ott.domain.contents.repository.ContentsRepository; @@ -27,11 +26,12 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class CommentService { + private final CommentRepository commentRepository; private final ContentsRepository contentsRepository; - public PageResponse getContentsCommentList(Long contentsId, int page, int size, - boolean includeSpoiler) { + + public PageResponse getContentsCommentList(Long contentsId, int page, int size, boolean includeSpoiler) { if (!contentsRepository.existsByIdAndStatus(contentsId, Status.ACTIVE)) { throw new BusinessException(ErrorCode.CONTENT_NOT_FOUND); @@ -39,16 +39,7 @@ public PageResponse getContentsCommentList(Long contentsId, int Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); - Page commentPage; - - if (includeSpoiler) { - // 토글 ON: 스포일러 포함 전체 조회 - commentPage = commentRepository.findByContentsIdAndStatusWithMember(contentsId, Status.ACTIVE, pageable); - } else { - // 토글 OFF (기본): 스포일러 없는 댓글만 조회 - commentPage = commentRepository.findByContentsIdAndStatusAndIsSpoilerFalseWithMember(contentsId, - Status.ACTIVE, pageable); - } + Page commentPage = commentRepository.findByContents_IdAndStatusWithSpoilerCondition(contentsId, Status.ACTIVE, includeSpoiler, pageable); List responseList = commentPage.getContent().stream() .map(CommentResponse::from) diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java similarity index 78% rename from apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java rename to apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java index 018ce62..81d67b8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java @@ -8,14 +8,14 @@ import com.ott.api_user.common.ContentSource; import com.ott.api_user.common.dto.ContentListElement; -import com.ott.api_user.content.dto.ContentDetailResponse; +import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; 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; @@ -23,23 +23,27 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Contents", description = "콘텐츠 상세 및 재생 관련 API") -public interface ContentApi { +public interface ContentsApi { @Operation(summary = "콘텐츠 상세 조회", description = "단편 영화/에피소드의 상세 정보를 조회합니다.(콘텐츠 상세 페이지)") @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "조회 성공 - 콘텐츠 상세 구성", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentsDetailResponse.class))) }), @ApiResponse(responseCode = "200", description = "콘텐츠 상세 조회 성공", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ContentDetailResponse.class)) }), + @Content(mediaType = "application/json", schema = @Schema(implementation = ContentsDetailResponse.class)) }), @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) @GetMapping("/{contentsId}") - ResponseEntity> getContentDetail( + ResponseEntity> getContentDetail( @Parameter(description = "콘텐츠 ID", required = true, example = "1") @PathVariable("contentsId") Long contentsId, - @Parameter(hidden = true) Long memberId); + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 @Operation(summary = "진입점 기반 재생 목록 조회", description = "재생 화면 하단/우측에 노출되는 맞춤형 추천 리스트(재생 목록)를 조회합니다.") @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "조회 성공 - 재생 목록 구성", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentListElement.class))) }), @ApiResponse(responseCode = "200", description = "플레이리스트 조회 성공", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java similarity index 73% rename from apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java rename to apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java index d8d485f..59e513d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -2,31 +2,32 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; 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; - -import com.ott.api_user.comment.dto.CommentResponse; -import com.ott.api_user.comment.service.CommentService; import com.ott.api_user.common.ContentSource; import com.ott.api_user.common.dto.ContentListElement; -import com.ott.api_user.content.dto.ContentDetailResponse; -import com.ott.api_user.content.service.ContentService; +import com.ott.api_user.content.dto.ContentsDetailResponse; +import com.ott.api_user.content.service.ContentsService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/contents") -public class ContentController implements ContentApi { +public class ContentsController implements ContentsApi { - private final ContentService contentService; + private final ContentsService contentService; @Override - public ResponseEntity> getContentDetail( + public ResponseEntity> getContentDetail( @PathVariable(value = "contentsId") Long contentsId, @AuthenticationPrincipal Long memberId) { @@ -39,8 +40,8 @@ public ResponseEntity> getContentDetail( public ResponseEntity>> getContentPlayList( @PathVariable(value = "contentsId") Long contentsId, @RequestParam(value = "source", required = false) ContentSource source, - @RequestParam(value = "page") Integer pageParam, - @RequestParam(value = "size") Integer sizeParam, + @RequestParam(value = "page") @Min(0) Integer pageParam, + @RequestParam(value = "size") @Positive Integer sizeParam, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( SuccessResponse.of(contentService.getContentPlayList(contentsId, source, pageParam, diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java similarity index 93% rename from apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java rename to apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java index 77b0009..f6f7408 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java @@ -14,7 +14,7 @@ @Builder @AllArgsConstructor @Schema(description = "컨텐츠 상세(재생) 조회 응답 DTO") -public class ContentDetailResponse { +public class ContentsDetailResponse { @Schema(description = "콘텐츠 ID", example = "1") private Long id; @@ -48,11 +48,11 @@ public class ContentDetailResponse { @Schema(description = "기존 이어보기 지점(없으면 0)", example = "150") private Integer positionSec; - public static ContentDetailResponse from(Contents contents, List tags, + public static ContentsDetailResponse from(Contents contents, List tags, List categories, Boolean isBookmarked, Boolean isLiked, String masterPlaylistUrl, Integer positionSec) { - return ContentDetailResponse.builder() + return ContentsDetailResponse.builder() .id(contents.getId()) .title(contents.getMedia().getTitle()) .description(contents.getMedia().getDescription()) diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java similarity index 81% rename from apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java rename to apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java index 6424102..d75d0bf 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Service; import com.ott.api_user.common.ContentSource; import com.ott.api_user.common.dto.ContentListElement; -import com.ott.api_user.content.dto.ContentDetailResponse; +import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -31,7 +31,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ContentService { +public class ContentsService { private final ContentsRepository contentsRepository; // private final PlaybackRepository playbackRepository; @@ -40,37 +40,34 @@ public class ContentService { private final TagRepository tagRepository; private final CategoryRepository categoryRepository; + // 재생 상세 - public ContentDetailResponse getContentDetail(Long contentsId, Long memberId) { - Contents contents = contentsRepository.findByIdWithMedia(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + public ContentsDetailResponse getContentDetail(Long contentsId, Long memberId) { + Contents contents = contentsRepository.findByIdAndStatusAndMedia_PublicStatus(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); Long mediaId = contents.getMedia().getId(); List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); - Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, - Status.ACTIVE); + Boolean isBookmarked = bookmarkRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId,Status.ACTIVE); Boolean isLiked = likesRepository.existsByMemberIdAndMediaIdAndStatus(memberId, mediaId, Status.ACTIVE); String masterPlaylistUrl = contents.getMasterPlaylistUrl(); Integer positionSec = 0; - return ContentDetailResponse.from(contents, tags, categories, isBookmarked, isLiked, - masterPlaylistUrl, - positionSec); + return ContentsDetailResponse.from(contents, tags, categories, isBookmarked, isLiked,masterPlaylistUrl, positionSec); } // 해당 콘텐츠를 어디서 진입했는지에 따라 // 콘텐츠의 재생목록이 달라짐. - public PageResponse getContentPlayList(Long contentsId, ContentSource source, - int page, int size, Long memberId) { + public PageResponse getContentPlayList(Long contentsId, ContentSource source, int page, + int size, Long memberId) { - Contents currentContents = contentsRepository.findByIdWithMedia(contentsId, Status.ACTIVE, - PublicStatus.PUBLIC) + Contents currentContents = contentsRepository.findByIdAndStatusAndMedia_PublicStatus(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); if (currentContents.getSeries() != null) { @@ -91,14 +88,10 @@ public PageResponse getContentPlayList(Long contentsId, Cont default -> getRecommendPlaylist(memberId, pageable, mediaId); // 기본값은 OO님이 좋아하실만한 콘텐츠 }; - List contentList = playListPage.getContent().stream() - .map(ContentListElement::from).collect(Collectors.toList()); + List contentList = playListPage.getContent().stream().map(ContentListElement::from) + .collect(Collectors.toList()); - PageInfo pageInfo = PageInfo.builder() - .currentPage(playListPage.getNumber()) - .totalPage(playListPage.getTotalPages()) - .pageSize(playListPage.getSize()) - .build(); + PageInfo pageInfo = PageInfo.toPageInfo(page, page, size); return PageResponse.toPageResponse(pageInfo, contentList); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java new file mode 100644 index 0000000..8a0bebd --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java @@ -0,0 +1,39 @@ +package com.ott.api_user.playlist.dto.request; + +import org.hibernate.annotations.SourceType; + +import com.ott.api_user.common.ContentSource; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.models.media.MediaType; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +// 요청 DTO +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "플레이 리스트 공통 요청 DTO") +public class PlaylistCondition { + + @Schema(description = "진입점 소스 타입", example = "USER, RECOMMEND, SEARCH 등 ..") + private ContentSource contentSource; + + @Schema(description = "사용자 고유 ID", example = "1") + private Long memberId; + + @Schema(description = "현재 컨텐츠의 Id", example = "1") + private Long excludeMediaId; + + @Schema(description = "태그 고유 ID", example = "1") + private Long tagId; + + @Schema(description = "미디어 타입", example = "SERIES, CONTENTS") + private MediaType mediaType; + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java new file mode 100644 index 0000000..158e540 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java @@ -0,0 +1,37 @@ +package com.ott.api_user.playlist.dto.response; + +import com.ott.domain.common.MediaType; +import com.ott.domain.media.domain.Media; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "플레이리스트 조회 응답 DTO") +public class PlaylistResponse { + + @Schema(description = "미디어 고유 ID", example = "100") + private Long mediaId; + + @Schema(description = "컨텐츠 제목", example = "비밀의 숲") + private String title; + + @Schema(description = "포스터 이미지 URL", example = "https://s3.../poster.jpg") + private String posterUrl; + + @Schema(description = "미디어 타입 (UI 분기 처리 및 라우팅용)", example = "SERIES") + private MediaType mediaType; + + public static PlaylistResponse from(Media media) { + return PlaylistResponse.builder() + .mediaId(media.getId()) + .title(media.getTitle()) + .posterUrl(media.getPosterUrl()) + .mediaType(media.getMediaType()) + .build(); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java new file mode 100644 index 0000000..8452ebc --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java @@ -0,0 +1,33 @@ +package com.ott.api_user.playlist.service; + +import java.util.List; +import java.util.stream.Collectors; + +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 com.ott.api_user.common.ContentSource; +import com.ott.api_user.common.dto.ContentListElement; +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.PublicStatus; +import com.ott.domain.common.Status; +import com.ott.domain.contents.domain.Contents; +import com.ott.domain.contents.repository.ContentsRepository; +import com.ott.domain.media.domain.Media; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlaylistService { + + private final ContentsRepository contentsRepository; + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java new file mode 100644 index 0000000..396452e --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java @@ -0,0 +1,6 @@ +package com.ott.api_user.playlist.service.strategy; + +//공통 인터페이스 +public interface PlaylistStrategy { + +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index 4ef2722..edafce8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -14,6 +14,7 @@ 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; @@ -24,6 +25,8 @@ public interface SeriesApi { @Operation(summary = "시리즈 상세 조회", description = "특정 시리즈의 상세 정보를 조회합니다.(시리즈 상세 페이지)") @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "조회 성공 - 시리즈 상세 구성", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesDetailResponse.class))) }), @ApiResponse(responseCode = "200", description = "시리즈 상세 조회 성공", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = SeriesDetailResponse.class)) }), @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { @@ -37,27 +40,21 @@ ResponseEntity> getSeriesDetail( @Operation(summary = "시리즈 콘텐츠 목록 조회", description = "특정 시리즈에 속한 콘텐츠(에피소드) 목록을 페이징하여 조회합니다.") @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "조회 성공 - 시리즈 콘텐츠 목록 구성", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = SeriesContentsResponse.class))) }), @ApiResponse(responseCode = "200", description = "시리즈 콘텐츠 목록 조회 성공", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) - }), + @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class)) }), @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) - }), - + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }), @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) - }) + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) @GetMapping("/{seriesId}/contents") ResponseEntity>> getSeriesContents( - @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("seriesId") Long seriesId, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기", example = "24") @RequestParam(defaultValue = "24") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); // 추후 이어보기 지점 추가 diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java index 5db559a..ce8ea6e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -13,9 +14,11 @@ import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; -import io.micrometer.core.ipc.http.HttpSender.Response; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/series") @@ -34,8 +37,8 @@ public ResponseEntity> getSeriesDetail( @Override public ResponseEntity>> getSeriesContents( @PathVariable(value = "seriesId") Long seriesId, - @RequestParam(value = "page") Integer pageParam, - @RequestParam(value = "size") Integer sizeParam, + @RequestParam(value = "page") @Min(0) Integer pageParam, + @RequestParam(value = "size") @Positive Integer sizeParam, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java index cb830b6..8193da6 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java @@ -10,18 +10,19 @@ import com.ott.domain.common.Status; public interface CommentRepository extends JpaRepository { + @Query(""" + SELECT c FROM Comment c + JOIN FETCH c.member m + WHERE c.contents.id = :contentsId + AND c.status = :status + and (:includeSpoiler = true or c.isSpoiler = false) + """) + Page findByContents_IdAndStatusWithSpoilerCondition( + @Param("contentsId") Long contentsId, + @Param("status") Status status, + @Param("includeSpoiler") boolean includeSpoiler, + Pageable pageable - // 1. 스포 포함 토글 ON -> 전체 댓글 조회 - @Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.contents.id = :contentsId AND c.status = :status") - Page findByContentsIdAndStatusWithMember( - @Param("contentsId") Long contentsId, - @Param("status") Status status, - Pageable pageable); + ); - // 2. 스포 포함 토글 OFF -> 스포 없는 댓글만 조회 - @Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.contents.id = :contentsId AND c.status = :status AND c.isSpoiler = false") - Page findByContentsIdAndStatusAndIsSpoilerFalseWithMember( - @Param("contentsId") Long contentsId, - @Param("status") Status status, - Pageable pageable); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 4574800..d7d76ca 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -50,8 +50,16 @@ Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long ser @EntityGraph(attributePaths = { "series", "series.media" }) Optional findByMediaId(Long mediaId); - @Query("SELECT c FROM Contents c JOIN FETCH c.media m WHERE c.id = :contentsId AND c.status = :status AND m.publicStatus = :publicStatus") - Optional findByIdWithMedia(@Param("contentsId") Long contentsId, @Param("status") Status status, + @Query(""" + SELECT c FROM Contents c + JOIN FETCH c.media m + WHERE c.id = :contentsId + AND c.status = :status + AND m.publicStatus = :publicStatus + """) + Optional findByIdAndStatusAndMedia_PublicStatus( + @Param("contentsId") Long contentsId, + @Param("status") Status status, @Param("publicStatus") PublicStatus publicStatus); boolean existsByIdAndStatus(Long id, Status status); diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java index 97ffc04..8520a1a 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface MediaRepository extends JpaRepository, MediaRepositoryCustom { + } From 0e732e348a8a64dd8f1f4c68147f618b43d07343 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:45:41 +0900 Subject: [PATCH 190/257] =?UTF-8?q?[FIX]:=20vaildated=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?(500=20=EC=97=90=EB=9F=AC=20=EC=9D=B4=EC=8A=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/comment/controller/CommentController.java | 6 +++--- .../ott/api_user/content/controller/ContentsController.java | 6 +++--- .../ott/api_user/series/controller/SeriesController.java | 5 ++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java index 4f96108..64d3003 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/comment/controller/CommentController.java @@ -16,7 +16,7 @@ import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; -@Validated + @RestController @RequiredArgsConstructor public class CommentController implements CommentApi { @@ -26,8 +26,8 @@ public class CommentController implements CommentApi { @Override public ResponseEntity>> getContentCommentsList( @PathVariable(value = "contentsId") Long contentsId, - @RequestParam(value = "page", defaultValue = "0") @Min(0) Integer pageParam, - @RequestParam(value = "size", defaultValue = "20") @Positive Integer sizeParam, + @RequestParam(value = "page", defaultValue = "0") Integer pageParam, + @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, @RequestParam(value = "includeSpoiler", defaultValue = "false") boolean includeSpoiler) { return ResponseEntity.ok( SuccessResponse.of(commentService.getContentsCommentList(contentsId, pageParam, diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java index 59e513d..69da1c3 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -18,7 +18,7 @@ import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; -@Validated + @RestController @RequiredArgsConstructor @RequestMapping("/contents") @@ -40,8 +40,8 @@ public ResponseEntity> getContentDetail( public ResponseEntity>> getContentPlayList( @PathVariable(value = "contentsId") Long contentsId, @RequestParam(value = "source", required = false) ContentSource source, - @RequestParam(value = "page") @Min(0) Integer pageParam, - @RequestParam(value = "size") @Positive Integer sizeParam, + @RequestParam(value = "page") Integer pageParam, + @RequestParam(value = "size") Integer sizeParam, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( SuccessResponse.of(contentService.getContentPlayList(contentsId, source, pageParam, diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java index ce8ea6e..9586177 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -18,7 +18,6 @@ import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; -@Validated @RestController @RequiredArgsConstructor @RequestMapping("/series") @@ -37,8 +36,8 @@ public ResponseEntity> getSeriesDetail( @Override public ResponseEntity>> getSeriesContents( @PathVariable(value = "seriesId") Long seriesId, - @RequestParam(value = "page") @Min(0) Integer pageParam, - @RequestParam(value = "size") @Positive Integer sizeParam, + @RequestParam(value = "page") Integer pageParam, + @RequestParam(value = "size") Integer sizeParam, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( From 712453c832e6cbf66f5458eb590d85413151afea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 15:38:08 +0900 Subject: [PATCH 191/257] =?UTF-8?q?[CHORE]:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B0=8F=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/config/RabbitConfig.java | 16 ++---------- .../transcoder/pipeline/CommandPipeline.java | 21 +++++---------- .../pipeline/hls/MasterPlaylistGenerator.java | 16 +----------- .../ott/transcoder/queue/MessageListener.java | 2 +- .../queue/rabbit/RabbitTranscodeListener.java | 26 ++++++------------- .../ott/transcoder/storage/VideoStorage.java | 4 --- .../src/main/resources/application.yml | 3 +++ 7 files changed, 22 insertions(+), 66 deletions(-) diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java index 36e19dd..0063b1f 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java @@ -13,15 +13,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -/** - * RabbitMQ 설정. - * - * Exchange → Binding → Queue 구조로 메시지 라우팅 - * Producer가 transcode.exchange에 routing key "transcode.request"로 메시지를 발행하면, - * Binding을 통해 transcode.queue로 전달되고, RabbitTranscodeListener가 소비 - * - * transcoder.messaging.provider=rabbit 일 때만 활성화 (SQS 등 전환 시 비활성화) - */ +/** RabbitMQ Exchange/Queue/Binding 설정. transcoder.messaging.provider=rabbit 일 때 활성화. */ @Configuration @ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") public class RabbitConfig { @@ -49,11 +41,7 @@ public Binding transcodeBinding(Queue transcodeQueue, DirectExchange transcodeEx .with(ROUTING_KEY); } - /** - * JSON 메시지를 TranscodeMessage로 역직렬화할 때 사용할 기본 타입 지정 - * 메시지 헤더에 __TypeId__가 없어도 TranscodeMessage로 변환 - * (Management UI 등 외부에서 직접 발행한 메시지 처리를 위해 필요) - */ + /** __TypeId__ 헤더 없는 메시지도 역직렬화 가능하도록 기본 타입 지정 */ @Bean public DefaultClassMapper classMapper() { DefaultClassMapper classMapper = new DefaultClassMapper(); diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java index d0c13b2..dabbda4 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java @@ -1,21 +1,14 @@ package com.ott.transcoder.pipeline; +import com.ott.transcoder.inspection.probe.ProbeResult; + +import java.nio.file.Path; + /** - * 미디어 처리 파이프라인 추상화 인터페이스 - * - * 하나의 미디어에 대해 pre → main → post 흐름을 실행하는 커맨드 단위 - * 커맨드 종류에 따라 구현체가 달라진다 - * - * 현재 구현체: HlsTranscodePipeline (HLS 트랜스코딩) - * 향후 구현체: ThumbnailPipeline, SpritePipeline 등 + * 커맨드별 미디어 처리 파이프라인 + * 구현체는 미디어 처리 자체에만 집중 */ public interface CommandPipeline { - /** - * 파이프라인을 실행 - * - * @param mediaId 대상 미디어 ID - * @param originUrl 원본 영상 위치 (로컬 경로 또는 S3 key) - */ - void execute(Long mediaId, String originUrl); + void execute(Long mediaId, Path inputFile, Path workDir, ProbeResult probeResult) throws Exception; } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java index b533185..003403e 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java @@ -10,21 +10,7 @@ import java.util.List; import java.util.Map; -/** - * HLS 마스터 플레이리스트(master.m3u8) 생성기. - * - * 마스터 플레이리스트는 여러 해상도(variant)를 참조하며, - * HLS 플레이어가 네트워크 상태에 따라 적절한 해상도를 자동 선택(ABR)하게 해준다. - * - * 생성 결과 예시: - * #EXTM3U - * #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360 - * 360p/media.m3u8 - * #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720 - * 720p/media.m3u8 - * #EXT-X-STREAM-INF:BANDWIDTH=4800000,RESOLUTION=1920x1080 - * 1080p/media.m3u8 - */ +/** HLS 마스터 플레이리스트(master.m3u8) 생성기. ABR variant를 포함한다. */ @Slf4j @Component public class MasterPlaylistGenerator { diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java index a6999bf..afc300a 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java @@ -8,5 +8,5 @@ */ public interface MessageListener { - void listen(TranscodeMessage message); + void listen(TranscodeMessage message) throws Exception; } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java index 97e0cfa..d59b158 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java @@ -1,7 +1,7 @@ package com.ott.transcoder.queue.rabbit; +import com.ott.transcoder.JobOrchestrator; import com.ott.transcoder.config.RabbitConfig; -import com.ott.transcoder.pipeline.CommandPipeline; import com.ott.transcoder.queue.MessageListener; import com.ott.transcoder.queue.TranscodeMessage; import lombok.RequiredArgsConstructor; @@ -10,31 +10,21 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -/** - * RabbitMQ 메시지 리스너 (어댑터 역할) - * - * 큐에서 메시지를 소비하여 CommandPipeline에 위임 - * RabbitMQ 전용 로직만 담당, 트랜스코딩 비즈니스 로직은 모르는 상태 - * - * SQS 전환 시 이 클래스 대신 SqsTranscodeListener를 만들면 된다 - */ @Slf4j @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") public class RabbitTranscodeListener implements MessageListener { - private final CommandPipeline commandPipeline; + private final JobOrchestrator jobOrchestrator; @Override @RabbitListener(queues = RabbitConfig.QUEUE_NAME) - public void listen(TranscodeMessage message) { - log.info("트랜스코딩 요청 수신 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); - try { - commandPipeline.execute(message.mediaId(), message.originUrl()); - } catch (Exception e) { - // 예외를 삼켜 requeue를 방지 / DLQ 구성 후 AmqpRejectAndDontRequeueException으로 교체 - log.error("트랜스코딩 처리 실패, 메시지 폐기 - mediaId: {}", message.mediaId(), e); - } + public void listen(TranscodeMessage message) throws Exception { + log.info("작업 요청 수신 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); + + jobOrchestrator.handle(message); + + log.info("작업 요청 처리 완료 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); } } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java index 377b59a..6417267 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java @@ -4,15 +4,12 @@ /** * 영상 파일 저장소 추상화 인터페이스 - * - * 현재 구현체: LocalVideoStorage (로컬 파일시스템) * S3VideoStorage를 추가하여 실제 AWS S3 연동으로 교체할 것 */ public interface VideoStorage { /** * 원본 영상을 저장소에서 로컬 작업 디렉토리로 가져온다 - * * @param sourceKey 원본 위치 (로컬 경로 또는 S3 key) * @param workDir 다운로드 대상 로컬 디렉토리 * @return 다운로드된 로컬 파일 경로 @@ -21,7 +18,6 @@ public interface VideoStorage { /** * 트랜스코딩 결과물을 저장소에 업로드 - * * @param localDir 업로드할 로컬 디렉토리 (HLS 파일들이 들어있음) * @param destinationPrefix 저장소 내 목적지 경로 (예: "media/1/hls") * @return 업로드된 경로 (DB에 저장할 URL 또는 경로) diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 248c648..66a1f85 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -40,6 +40,9 @@ transcoder: path: ${FFMPEG_PATH} temp-dir: ${TRANSCODER_TEMP_DIR} segment-duration: ${TRANSCODER_SEGMENT_DURATION} + ffprobe: + engine: ${TRANSCODER_FFPROBE_ENGINE} + path: ${FFPROBE_PATH} storage: provider: ${STORAGE_PROVIDER} From 5ae8cee24582ece23180bc628b52a847a6148f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:22:56 +0900 Subject: [PATCH 192/257] =?UTF-8?q?[FEAT]:=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=A0=84=20=EA=B3=B5=ED=86=B5=20=EA=B2=80=EC=A6=9D=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/inspection/Inspector.java | 33 ++++ .../inspection/probe/ProbeResult.java | 52 ++++++ .../probe/execution/FfprobeExecutor.java | 21 +++ .../ProcessBuilderFfprobeExecutor.java | 165 ++++++++++++++++++ .../inspection/validation/DiskSpaceGuard.java | 26 +++ .../inspection/validation/FileValidator.java | 150 ++++++++++++++++ .../validation/StreamValidator.java | 91 ++++++++++ 7 files changed, 538 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java new file mode 100644 index 0000000..eaadd58 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java @@ -0,0 +1,33 @@ +package com.ott.transcoder.inspection; + +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.inspection.probe.execution.FfprobeExecutor; +import com.ott.transcoder.inspection.validation.FileValidator; +import com.ott.transcoder.inspection.validation.StreamValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; + +/** + * 입력 파일 검사 + * FileValidator → Probe → StreamValidator 순서로 실행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class Inspector { + + private final FileValidator fileValidator; + private final FfprobeExecutor ffprobeExecutor; + private final StreamValidator streamValidator; + + public ProbeResult inspect(Path inputFile) { + fileValidator.validate(inputFile); + ProbeResult probeResult = ffprobeExecutor.probe(inputFile); + streamValidator.validate(probeResult); + + return probeResult; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java new file mode 100644 index 0000000..55281fa --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java @@ -0,0 +1,52 @@ +package com.ott.transcoder.inspection.probe; + +/** + * ffprobe 실행 결과를 담는 불변 레코드 + * + * @param width 영상 너비 (px) + * @param height 영상 높이 (px) + * @param durationSeconds 전체 재생 시간 (초) + * @param videoCodec 비디오 코덱 (예: h264, hevc, vp9) + * @param audioCodec 오디오 코덱 (예: aac, opus, "none") + * @param fps 프레임레이트 + * @param videoBitrate 비디오 비트레이트 (bps) + * @param audioBitrate 오디오 비트레이트 (bps) + * @param audioChannels 오디오 채널 수 (예: 2=stereo, 6=5.1ch) + * @param pixelFormat 픽셀 포맷 (예: yuv420p, yuv422p) + * @param rotation 회전 각도 (0, 90, 180, 270). 스마트폰 세로 촬영 시 90 또는 270 + */ +public record ProbeResult( + int width, + int height, + double durationSeconds, + String videoCodec, + String audioCodec, + double fps, + long videoBitrate, + long audioBitrate, + int audioChannels, + String pixelFormat, + int rotation +) { + /** + * 회전을 고려한 실제 영상 높이. + * 90° 또는 270° 회전된 영상은 width와 height가 뒤바뀐다. + * 예: 1080x1920(세로 촬영, rotation=90) → 실제 출력은 1920x1080 → effectiveHeight = 1080 + */ + public int effectiveHeight() { + return isRotated() ? this.width : this.height; + } + + public int effectiveWidth() { + return isRotated() ? this.height : this.width; + } + + public boolean isRotated() { + return rotation == 90 || rotation == 270; + } + + // 회전을 고려하여 업스케일 여부 판단 + public boolean isUpscaleFor(int targetHeight) { + return targetHeight > effectiveHeight(); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java new file mode 100644 index 0000000..3f4a606 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java @@ -0,0 +1,21 @@ +package com.ott.transcoder.inspection.probe.execution; + +import com.ott.transcoder.inspection.probe.ProbeResult; + +import java.nio.file.Path; + +/** + * ffprobe 실행 추상화 인터페이스 + * + * 입력 파일의 미디어 메타데이터 추출 + */ +public interface FfprobeExecutor { + + /** + * 입력 파일에 대해 ffprobe를 실행하여 메타데이터 추출 + * + * @param inputFile 분석 대상 파일 경로 + * @return 추출된 메타데이터 + */ + ProbeResult probe(Path inputFile); +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java new file mode 100644 index 0000000..b473548 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java @@ -0,0 +1,165 @@ +package com.ott.transcoder.inspection.probe.execution.processbuilder; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.transcoder.inspection.probe.execution.FfprobeExecutor; +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * ffprobe를 JSON 출력 모드로 실행하여 미디어 메타데이터를 추출 + * format(컨테이너 정보)과 streams(스트림 정보)를 함께 요청하고, + * 첫 번째 비디오/오디오 스트림에서 필요한 필드를 파싱 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "transcoder.ffprobe.engine", havingValue = "processbuilder") +public class ProcessBuilderFfprobeExecutor implements FfprobeExecutor { + + private final ObjectMapper objectMapper; + + @Value("${transcoder.ffprobe.path:ffprobe}") + private String ffprobePath; + + @Override + public ProbeResult probe(Path inputFile) { + List command = List.of( + ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + inputFile.toString() + ); + + log.info("ffprobe 실행 - input: {}", inputFile); + + try { + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + + boolean finished = process.waitFor(2, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("ffprobe 타임아웃 - input: " + inputFile); + } + if (process.exitValue() != 0) { + throw new RuntimeException( + "ffprobe 실패 - exitCode: " + process.exitValue() + ", output: " + output); + } + + return parseJson(output); + + } catch (IOException | InterruptedException e) { + throw new RuntimeException("ffprobe 실행 실패 - input: " + inputFile, e); + } + } + + private ProbeResult parseJson(String json) throws IOException { + JsonNode root = objectMapper.readTree(json); + JsonNode streamList = root.get("streams"); + + JsonNode videoStream = null; + JsonNode audioStream = null; + + for (JsonNode stream : streamList) { + String codecType = stream.get("codec_type").asText(); + if ("video".equals(codecType) && videoStream == null) { + videoStream = stream; + } else if ("audio".equals(codecType) && audioStream == null) { + audioStream = stream; + } + } + + if (videoStream == null) { + throw new RuntimeException("비디오 스트림을 찾을 수 없음"); + } + + JsonNode format = root.get("format"); // null 가능성 존재 + + double duration = format.has("duration") + ? format.get("duration").asDouble() + : 0.0; + + double fps = parseFps(videoStream.path("r_frame_rate").asText("0/1")); + + long videoBitrate = videoStream.has("bit_rate") + ? videoStream.get("bit_rate").asLong() + : format.path("bit_rate").asLong(0); + + long audioBitrate = (audioStream != null && audioStream.has("bit_rate")) + ? audioStream.get("bit_rate").asLong() + : 0L; + + String audioCodec = (audioStream != null) + ? audioStream.get("codec_name").asText() + : "none"; + + int audioChannels = (audioStream != null) + ? audioStream.path("channels").asInt(0) + : 0; + + String pixelFormat = videoStream.path("pix_fmt").asText("unknown"); + + int rotation = parseRotation(videoStream); + + return new ProbeResult( + videoStream.get("width").asInt(), + videoStream.get("height").asInt(), + duration, + videoStream.get("codec_name").asText(), + audioCodec, + fps, + videoBitrate, + audioBitrate, + audioChannels, + pixelFormat, + rotation + ); + } + + /** side_data_list[].rotation → tags.rotate 순으로 확인 */ + private int parseRotation(JsonNode videoStream) { + // 1. side_data_list에서 rotation 확인 + JsonNode sideDataList = videoStream.path("side_data_list"); + if (sideDataList.isArray()) { + for (JsonNode sideData : sideDataList) { + if (sideData.has("rotation")) { + return Math.abs(sideData.get("rotation").asInt()); + } + } + } + + // 2. tags.rotate 확인 (구버전 호환) + JsonNode tags = videoStream.path("tags"); + if (tags.has("rotate")) { + return Math.abs(tags.get("rotate").asInt()); + } + + return 0; + } + + /** "30/1", "30000/1001" 등 분수 형태 파싱 */ + private double parseFps(String rFrameRate) { + String[] parts = rFrameRate.split("/"); + if (parts.length == 2) { + double numerator = Double.parseDouble(parts[0]); + double denominator = Double.parseDouble(parts[1]); + return denominator > 0 ? numerator / denominator : 0.0; + } + return Double.parseDouble(rFrameRate); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java new file mode 100644 index 0000000..43a663f --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java @@ -0,0 +1,26 @@ +package com.ott.transcoder.inspection.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 다운로드 전 디스크 여유 공간 검증 + * 원본 크기 × multiplier만큼의 공간이 있는지 확인 + */ +@Slf4j +@Component +public class DiskSpaceGuard { + + @Value("${transcoder.validation.disk-space-multiplier:5}") + private double multiplier; + + public void check(Path originPath) { + + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java new file mode 100644 index 0000000..34246d8 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java @@ -0,0 +1,150 @@ +package com.ott.transcoder.inspection.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.Map; + +/** + * probe 전 파일 수준 검증. + * + * ffprobe를 실행하기 전에, 파일 자체가 유효한 미디어 파일인지 기본 방어선을 친다. + * 여기서 걸러지면 ffprobe를 돌릴 필요조차 없다. + * + * 검증 항목: + * 1. 파일 존재 여부 + * 2. 파일 크기 (0 bytes / 상한 초과) + * 3. 읽기 권한 + * 4. 매직 바이트 — 실제 미디어 포맷인지 확인 + * 5. 확장자 vs 매직 바이트 불일치 감지 + */ +@Slf4j +@Component +public class FileValidator { + + /** 파일 크기 상한 (기본 10GB) */ + @Value("${transcoder.validation.max-file-size-bytes:10737418240}") + private long maxFileSizeBytes; + + private static final Map EXTENSION_TO_FORMAT = Map.of( + "mp4", "MP4", + "mov", "MOV", + "mkv", "MKV", + "webm", "WEBM", + "avi", "AVI", + "flv", "FLV", + "ts", "MPEG-TS" + ); + + public void validate(Path inputFile) { + // 1. 파일 존재 + if (!Files.exists(inputFile)) { + throw new IllegalStateException("파일이 존재하지 않음 - " + inputFile); + } + + // 2. 읽기 권한 + if (!Files.isReadable(inputFile)) { + throw new IllegalStateException("파일 읽기 권한 없음 - " + inputFile); + } + + // 3. 파일 크기 + long fileSize; + try { + fileSize = Files.size(inputFile); + } catch (IOException e) { + throw new IllegalStateException("파일 크기 확인 실패 - " + inputFile, e); + } + + if (fileSize == 0) { + throw new IllegalStateException("파일 크기가 0 bytes - " + inputFile); + } + if (fileSize > maxFileSizeBytes) { + throw new IllegalStateException( + "파일 크기 상한 초과 - size: " + fileSize + " bytes, max: " + maxFileSizeBytes + " bytes"); + } + + // 4. 매직 바이트 검증 + String detectedFormat = detectFormatByMagicBytes(inputFile); + if (detectedFormat == null) { + throw new IllegalStateException("알 수 없는 파일 포맷 (매직 바이트 불일치) - " + inputFile); + } + + // 5. 확장자 vs 매직 바이트 불일치 경고 + String extension = getExtension(inputFile); + String expectedFormat = EXTENSION_TO_FORMAT.get(extension); + if (expectedFormat != null && !expectedFormat.equals(detectedFormat)) { + // MOV와 MP4는 동일한 ftyp 계열이므로 호환으로 취급 + if (!isCompatibleFormat(expectedFormat, detectedFormat)) { + log.warn("확장자-포맷 불일치 - file: {}, extension: .{} ({}), detected: {}", + inputFile.getFileName(), extension, expectedFormat, detectedFormat); + } + } + + log.info("파일 검증 통과 - file: {}, size: {} bytes, format: {}", + inputFile.getFileName(), fileSize, detectedFormat); + } + + private String detectFormatByMagicBytes(Path inputFile) { + byte[] header = new byte[12]; + int bytesRead; + + try (InputStream is = Files.newInputStream(inputFile)) { + bytesRead = is.read(header); + } catch (IOException e) { + throw new IllegalStateException("매직 바이트 읽기 실패 - " + inputFile, e); + } + + if (bytesRead < 8) { + return null; + } + + // MP4/MOV: offset 4~7이 "ftyp" + if (header[4] == 0x66 && header[5] == 0x74 && header[6] == 0x79 && header[7] == 0x70) { + return "MP4"; // MP4/MOV/3GP 계열 + } + + // MKV/WebM: EBML 헤더 (0x1A 0x45 0xDF 0xA3) + if (header[0] == 0x1A && header[1] == 0x45 && header[2] == (byte) 0xDF && header[3] == (byte) 0xA3) { + return "MKV"; // MKV/WebM + } + + // AVI: "RIFF" + if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F') { + return "AVI"; + } + + // FLV: "FLV" + if (header[0] == 'F' && header[1] == 'L' && header[2] == 'V') { + return "FLV"; + } + + // MPEG-TS: sync byte 0x47 + if (header[0] == 0x47) { + return "MPEG-TS"; + } + + log.debug("매직 바이트 미식별 - hex: {}", HexFormat.of().formatHex(header, 0, bytesRead)); + return null; + } + + private String getExtension(Path file) { + String fileName = file.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0) return ""; + return fileName.substring(dotIndex + 1).toLowerCase(); + } + + /** MP4/MOV는 동일 ftyp 계열이므로 호환으로 취급 */ + private boolean isCompatibleFormat(String expected, String detected) { + if ("MP4".equals(expected) && "MP4".equals(detected)) return true; + if ("MOV".equals(expected) && "MP4".equals(detected)) return true; + if ("WEBM".equals(expected) && "MKV".equals(detected)) return true; + return false; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java new file mode 100644 index 0000000..c6c796c --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java @@ -0,0 +1,91 @@ +package com.ott.transcoder.inspection.validation; + +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * probe 후 스트림 수준 검증 + * + * ffprobe 결과(ProbeResult)를 받아, 트랜스코딩을 진행해도 안전한지 판단 + * + * 검증 항목: + * 1. 비디오 스트림 존재 (오디오만 있는 파일 차단) + * 2. 비디오 코덱 지원 여부 + * 3. duration 유효성 (0초, 비정상적으로 긴 영상) + * 4. 해상도 범위 (너무 작거나 너무 큰 영상) + * 5. 프레임레이트 이상 감지 + * 6. 손상 감지 (메타데이터 불완전) + */ +@Slf4j +@Component +public class StreamValidator { + + // FFmpeg이 디코딩 가능한 일반적인 비디오 코덱 + private static final Set SUPPORTED_VIDEO_CODEC_SET = Set.of( + "h264", "hevc", "h265", "vp8", "vp9", "av1", + "mpeg4", "mpeg2video", "mpeg1video", + "wmv3", "vc1", + "theora", "prores", "dnxhd", + "mjpeg", "rawvideo" + ); + + /** 최소 해상도 (이보다 작으면 의미 없는 영상) */ + private static final int MIN_RESOLUTION = 32; + + /** 최대 해상도 (8K 초과는 비정상) */ + private static final int MAX_RESOLUTION = 8192; + + /** 최대 프레임레이트 (이보다 높으면 비정상) */ + private static final double MAX_FPS = 240.0; + + @Value("${transcoder.validation.max-duration-seconds:43200}") + private double maxDurationSeconds; // 기본 12시간 + + public void validate(ProbeResult probeResult) { + // 1. 비디오 코덱 존재 및 지원 여부 + if (probeResult.videoCodec() == null || probeResult.videoCodec().isBlank()) { + throw new IllegalStateException("비디오 코덱 정보 없음"); + } + if (!SUPPORTED_VIDEO_CODEC_SET.contains(probeResult.videoCodec().toLowerCase())) { + throw new IllegalStateException( + "지원하지 않는 비디오 코덱 - codec: " + probeResult.videoCodec()); + } + + // 2. 해상도 범위 + if (probeResult.width() < MIN_RESOLUTION || probeResult.height() < MIN_RESOLUTION) { + throw new IllegalStateException( + "해상도가 너무 작음 - " + probeResult.width() + "x" + probeResult.height()); + } + if (probeResult.width() > MAX_RESOLUTION || probeResult.height() > MAX_RESOLUTION) { + throw new IllegalStateException( + "해상도가 너무 큼 - " + probeResult.width() + "x" + probeResult.height()); + } + + // 3. duration 유효성 + if (probeResult.durationSeconds() <= 0) { + throw new IllegalStateException( + "duration이 유효하지 않음 - " + probeResult.durationSeconds() + "s"); + } + if (probeResult.durationSeconds() > maxDurationSeconds) { + throw new IllegalStateException( + "duration 상한 초과 - " + probeResult.durationSeconds() + "s, max: " + maxDurationSeconds + "s"); + } + + // 4. 프레임레이트 이상 + if (probeResult.fps() <= 0) { + throw new IllegalStateException("프레임레이트가 유효하지 않음 - fps: " + probeResult.fps()); + } + if (probeResult.fps() > MAX_FPS) { + throw new IllegalStateException( + "프레임레이트가 비정상적으로 높음 - fps: " + probeResult.fps() + ", max: " + MAX_FPS); + } + + log.info("스트림 검증 통과 - {}x{}, duration: {}s, codec: {}, fps: {}", + probeResult.width(), probeResult.height(), + probeResult.durationSeconds(), probeResult.videoCodec(), probeResult.fps()); + } +} From 849b453c9ec5c50814e385cf348b2a995f384080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:23:31 +0900 Subject: [PATCH 193/257] =?UTF-8?q?[CHORE]:=20FFmpeg=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EA=B8=B0=20=ED=8F=B4=EB=8D=94=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/ffmpeg/FfmpegExecutor.java | 29 ----- .../ffmpeg/execution/FfmpegExecutor.java | 25 ++++ .../ProcessBuilderFfmpegExecutor.java | 86 +++++++++++++ .../ProcessBuilderFfmpegExecutor.java | 117 ------------------ 4 files changed, 111 insertions(+), 146 deletions(-) delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java deleted file mode 100644 index f1c9c46..0000000 --- a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ott.transcoder.ffmpeg; - -import com.ott.domain.video_profile.domain.Resolution; - -import java.io.IOException; -import java.nio.file.Path; - -/** - * FFmpeg 실행 추상화 인터페이스 - * - * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 - * 단일 해상도에 대한 HLS 트랜스코딩을 수행한다. - * - * 구현체 전환: transcoder.ffmpeg.engine 프로퍼티로 선택 - * - processbuilder: ProcessBuilderFfmpegExecutor (CLI 직접 호출) - * - jaffree: (향후) JaffreeFfmpegExecutor (라이브러리 호출) - */ -public interface FfmpegExecutor { - - /** - * 단일 해상도에 대해 HLS 트랜스코딩을 수행한다. - * - * @param inputFile 원본 영상 파일 경로 - * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) - * @param resolution 대상 해상도 - * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 - */ - Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException; -} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java new file mode 100644 index 0000000..38f1c1c --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java @@ -0,0 +1,25 @@ +package com.ott.transcoder.ffmpeg.execution; + +import com.ott.transcoder.ffmpeg.TranscodeProfile; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg 실행 추상화 인터페이스 + * + * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 + * 단일 해상도에 대한 HLS 트랜스코딩을 수행 + */ +public interface FfmpegExecutor { + + /** + * 단일 프로파일에 대해 HLS 트랜스코딩을 수행 + * + * @param inputFile 원본 영상 파일 경로 + * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) + * @param profile 트랜스코딩 설정 (해상도, 비트레이트, 코덱 등) + * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 + */ + Path execute(Path inputFile, Path outputDir, TranscodeProfile profile) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 0000000..4b1730f --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,86 @@ +package com.ott.transcoder.ffmpeg.execution.processbuilder; + +import com.ott.transcoder.ffmpeg.execution.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * ProcessBuilder 기반 FFmpeg CLI 래퍼 + * 단일 해상도에 대해 HLS 트랜스코딩을 수행 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, TranscodeProfile profile) throws IOException, InterruptedException { + String resolutionKey = profile.resolution().getKey().toLowerCase(); + + // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolutionKey); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg 명령어 조립 — TranscodeProfile에서 설정값을 가져옴 + // TODO: FFmpeg Filter Chain 구성 로직 추가 필요 + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + profile.height(), + "-c:v", profile.videoCodec(), "-preset", profile.preset(), + "-c:a", profile.audioCodec(), "-b:a", profile.audioBitrate(), + "-b:v", profile.videoBitrate(), + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg 실행 - resolution: {}, command: {}", resolutionKey, String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + boolean finished = process.waitFor(30, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolutionKey); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg 실패 - resolution: " + resolutionKey + ", exitCode: " + exitCode); + } + + log.info("FFmpeg 완료 - resolution: {}, output: {}", resolutionKey, playlistPath); + return playlistPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java deleted file mode 100644 index 1995ddf..0000000 --- a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.ott.transcoder.ffmpeg.processbuilder; - -import com.ott.domain.video_profile.domain.Resolution; -import com.ott.transcoder.ffmpeg.FfmpegExecutor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -/** - * ProcessBuilder 기반 FFmpeg CLI 래퍼 - * - * 시스템에 설치된 FFmpeg 바이너리를 ProcessBuilder로 직접 호출 - * 단일 해상도에 대해 HLS 트랜스코딩을 수행하며, - * 결과물로 media.m3u8 (미디어 플레이리스트) + segment_XXX.ts (세그먼트 파일)를 생성 - * - * FFmpeg 내부 처리 흐름: - * Demux(컨테이너 분리) → Decode(디코딩) → Filter(스케일링) → Encode(재인코딩) → Mux(HLS 패키징) - * 이 전체가 하나의 FFmpeg 명령어로 실행됨 - */ -@Slf4j -@Component -@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") -public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { - - /** 해상도별 출력 높이 (너비는 -2로 자동 계산, 짝수 보장) */ - private static final Map HEIGHT_MAP = Map.of( - Resolution.P360, 360, - Resolution.P720, 720, - Resolution.P1080, 1080 - ); - - /** 해상도별 비디오 비트레이트 */ - private static final Map VIDEO_BITRATE_MAP = Map.of( - Resolution.P360, "800k", - Resolution.P720, "2400k", - Resolution.P1080, "4800k" - ); - - /** 해상도별 오디오 비트레이트 */ - private static final Map AUDIO_BITRATE_MAP = Map.of( - Resolution.P360, "96k", - Resolution.P720, "128k", - Resolution.P1080, "192k" - ); - - @Value("${transcoder.ffmpeg.path:ffmpeg}") - private String ffmpegPath; - - /** HLS 세그먼트 하나의 길이 (초) */ - @Value("${transcoder.ffmpeg.segment-duration:10}") - private int segmentDuration; - - @Override - public Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException { - int height = HEIGHT_MAP.get(resolution); - String videoBitrate = VIDEO_BITRATE_MAP.get(resolution); - String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); - - // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) - Path resolutionDir = outputDir.resolve(resolution.getKey().toLowerCase()); - Files.createDirectories(resolutionDir); - - Path playlistPath = resolutionDir.resolve("media.m3u8"); - String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); - - // FFmpeg 명령어 조립 - List command = List.of( - ffmpegPath, "-i", inputFile.toString(), - "-vf", "scale=-2:" + height, - "-c:v", "libx264", "-preset", "fast", - "-c:a", "aac", "-b:a", audioBitrate, - "-b:v", videoBitrate, - "-f", "hls", - "-hls_time", String.valueOf(segmentDuration), - "-hls_list_size", "0", - "-hls_segment_filename", segmentPattern, - playlistPath.toString() - ); - - log.info("FFmpeg 실행 - resolution: {}, command: {}", resolution.getKey(), String.join(" ", command)); - - ProcessBuilder processBuilder = new ProcessBuilder(command); - processBuilder.redirectErrorStream(true); - - Process process = processBuilder.start(); - - // FFmpeg 출력을 읽어야 프로세스가 블로킹되지 않는다 (버퍼 가득 참 방지) - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - log.debug("[FFmpeg] {}", line); - } - } - - boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); - if (!finished) { - process.destroyForcibly(); - throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolution.getKey()); - } - int exitCode = process.exitValue(); - if (exitCode != 0) { - throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); - } - - log.info("FFmpeg 완료 - resolution: {}, output: {}", resolution.getKey(), playlistPath); - return playlistPath; - } -} From 409201a3165f9ecf355f680640fb0727cea79188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:24:11 +0900 Subject: [PATCH 194/257] =?UTF-8?q?[FEAT]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=8B=A4=ED=96=89=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transcoder/ffmpeg/TranscodeProfile.java | 36 ++++++ .../pipeline/hls/TranscodePlanner.java | 121 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java new file mode 100644 index 0000000..fc203de --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java @@ -0,0 +1,36 @@ +package com.ott.transcoder.ffmpeg; + +import com.ott.domain.video_profile.domain.Resolution; + +/** + * 단일 해상도에 대한 트랜스코딩 설정 묶음 + * + * 현재는 Resolution enum 기반의 고정 프리셋이지만, + * 향후 TranscodePlanner가 ProbeResult를 분석하여 동적으로 생성 + * + * @param resolution 대상 해상도 (DB 저장용) + * @param height 출력 높이 (px). 너비는 FFmpeg -2 옵션으로 자동 계산 + * @param videoBitrate 비디오 비트레이트 (예: "800k", "2400k") + * @param audioBitrate 오디오 비트레이트 (예: "96k", "128k") + * @param videoCodec 비디오 인코더 (예: "libx264") + * @param audioCodec 오디오 인코더 (예: "aac") + * @param preset 인코딩 프리셋 (예: "fast", "medium") + */ +public record TranscodeProfile( + Resolution resolution, + int height, + String videoBitrate, + String audioBitrate, + String videoCodec, + String audioCodec, + String preset +) { + /** 기존 하드코딩 값과 동일한 기본 프리셋 */ + public static TranscodeProfile defaultFor(Resolution resolution) { + return switch (resolution) { + case P360 -> new TranscodeProfile(resolution, 360, "800k", "96k", "libx264", "aac", "fast"); + case P720 -> new TranscodeProfile(resolution, 720, "2400k", "128k", "libx264", "aac", "fast"); + case P1080 -> new TranscodeProfile(resolution, 1080, "4800k", "192k", "libx264", "aac", "fast"); + }; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java new file mode 100644 index 0000000..f9491a7 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java @@ -0,0 +1,121 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * ProbeResult를 분석하여 HLS 트랜스코딩 대상 해상도/비트레이트 결정 + * 업스케일 방지, 원본 비트레이트 상한 적용 등 + */ +@Slf4j +@Component +public class TranscodePlanner { + + /** 해상도별 높이 (Resolution enum에 height 필드가 없으므로 여기서 관리) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** 해상도별 기본 비디오 비트레이트 (bps) — 원본 비트레이트와 비교용 */ + private static final Map DEFAULT_VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, 800_000L, + Resolution.P720, 2_400_000L, + Resolution.P1080, 4_800_000L + ); + + /** 해상도별 기본 오디오 비트레이트 문자열 */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + /** 트랜스코딩 대상 해상도 */ + private static final List CANDIDATE_RESOLUTION_LIST = List.of( + Resolution.P360, Resolution.P720, Resolution.P1080 + ); + + /** + * ProbeResult를 분석하여 트랜스코딩할 프로파일 목록 생성 + * + * @param probeResult ffprobe 결과 + * @return 트랜스코딩 대상 프로파일 목록 (업스케일 해상도 제외) + */ + public List plan(ProbeResult probeResult) { + List profileList = new ArrayList<>(); + + for (Resolution resolution : CANDIDATE_RESOLUTION_LIST) { + int targetHeight = HEIGHT_MAP.get(resolution); + + // 업스케일 방지 + if (probeResult.isUpscaleFor(targetHeight)) { + continue; + } + + String videoBitrate = decideVideoBitrate(probeResult, resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + String audioCodec = decideAudioCodec(probeResult); + + TranscodeProfile profile = new TranscodeProfile( + resolution, + targetHeight, + videoBitrate, + audioBitrate, + "libx264", + audioCodec, + "fast" + ); + + profileList.add(profile); + } + + if (profileList.isEmpty()) { + // 원본이 360p 미만이어도 최소 1개는 생성 (원본 해상도로) + log.warn("모든 해상도가 업스케일 — 최소 프로파일 생성 (360p 기준, 원본 높이: {})", probeResult.height()); + profileList.add(TranscodeProfile.defaultFor(Resolution.P360)); + } + + log.info("트랜스코딩 계획 수립 완료 - 대상 해상도: {}", + profileList.stream().map(p -> p.resolution().getKey()).toList()); + + return profileList; + } + + /** + * 비디오 비트레이트 결정 + * 원본 비트레이트가 기본값보다 낮으면 원본 비트레이트를 상한으로 사용하여 과도한 할당을 방지 + */ + private String decideVideoBitrate(ProbeResult probeResult, Resolution resolution) { + long defaultBitrate = DEFAULT_VIDEO_BITRATE_MAP.get(resolution); + long originBitrate = probeResult.videoBitrate(); + + // 원본 비트레이트 정보가 없으면 기본값 사용 + if (originBitrate <= 0) { + return formatBitrate(defaultBitrate); + } + + // 원본이 기본값보다 낮으면 원본을 상한으로 + long chosen = Math.min(defaultBitrate, originBitrate); + return formatBitrate(chosen); + } + + private String decideAudioCodec(ProbeResult probeResult) { + return "aac"; + } + + private String formatBitrate(long bps) { + if (bps >= 1_000_000) { + return (bps / 1_000) + "k"; + } + return bps + ""; + } +} From 746d6d37414060c25edec1fb315470b1b39529f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:24:27 +0900 Subject: [PATCH 195/257] =?UTF-8?q?[FEAT]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=98=A4=EC=BC=80?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4=ED=84=B0=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/transcoder/JobOrchestrator.java | 80 +++++++++++++++++ .../pipeline/hls/HlsTranscodePipeline.java | 86 +++++-------------- 2 files changed, 101 insertions(+), 65 deletions(-) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java b/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java new file mode 100644 index 0000000..93c258c --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java @@ -0,0 +1,80 @@ +package com.ott.transcoder; + +import com.ott.transcoder.inspection.Inspector; +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.inspection.validation.DiskSpaceGuard; +import com.ott.transcoder.pipeline.CommandPipeline; +import com.ott.transcoder.queue.TranscodeMessage; +import com.ott.transcoder.storage.VideoStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +/** + * 작업 전체 흐름 조율 + * diskSpaceGuard → workDir 생성 → download → inspect → pipeline 실행 → cleanup + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class JobOrchestrator { + + private final DiskSpaceGuard diskSpaceGuard; + private final VideoStorage videoStorage; + private final Inspector inspector; + private final CommandPipeline pipeline; + + @Value("${transcoder.ffmpeg.temp-dir:#{systemProperties['java.io.tmpdir'] + '/ott-transcode'}}") + private String tempDir; + + public void handle(TranscodeMessage message) throws Exception { + Long mediaId = message.mediaId(); + // TODO: 0. DB 확인 필요 + + Path workDir = Path.of(tempDir, "media-" + mediaId); + + // 1. 디스크 공간 확인 + diskSpaceGuard.check(Path.of(message.originUrl())); + + try { + // 2. workDir 생성 + Files.createDirectories(workDir); + + // 3. 원본 다운로드 + Path inputFile = videoStorage.download(message.originUrl(), workDir); + + // 4. 검사 (FileValidator → Probe → StreamValidator) + ProbeResult probeResult = inspector.inspect(inputFile); + + // TODO: 5. 커맨드 생성 -> 각 커맨드 파이프라인 실행 + + // 6. 파이프라인 실행 + pipeline.execute(mediaId, inputFile, workDir, probeResult); + + } finally { + cleanUp(workDir); + } + } + + private void cleanUp(Path workDir) { + try { + if (Files.exists(workDir)) { + Files.walk(workDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + }); + log.info("작업 디렉토리 정리 완료 - {}", workDir); + } + } catch (IOException e) { + log.warn("작업 디렉토리 정리 실패 - {}", workDir, e); + } + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java index 13988c5..9575c71 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java @@ -1,92 +1,48 @@ package com.ott.transcoder.pipeline.hls; import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import com.ott.transcoder.ffmpeg.execution.FfmpegExecutor; +import com.ott.transcoder.inspection.probe.ProbeResult; import com.ott.transcoder.pipeline.CommandPipeline; import com.ott.transcoder.storage.VideoStorage; -import com.ott.transcoder.ffmpeg.FfmpegExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Comparator; import java.util.List; -/** - * HLS 트랜스코딩 파이프라인 - * - * 전체 흐름을 조율: - * 1. 임시 작업 디렉토리 생성 - * 2. 원본 다운로드 (VideoStorage) - * 3. 해상도별 FFmpeg HLS 트랜스코딩 (360p → 720p → 1080p 순차 실행) - * 4. 마스터 플레이리스트 생성 (master.m3u8) - * 5. 결과물 업로드 (VideoStorage) - * 6. 임시 작업 디렉토리 정리 (성공/실패 모두) - */ @Slf4j @Component @RequiredArgsConstructor public class HlsTranscodePipeline implements CommandPipeline { - private static final List TARGET_RESOLUTION_LIST = List.of( - Resolution.P360, Resolution.P720, Resolution.P1080 - ); - - private final VideoStorage videoStorage; + private final TranscodePlanner transcodePlanner; private final FfmpegExecutor ffmpegExecutor; private final MasterPlaylistGenerator masterPlaylistGenerator; - - @Value("${transcoder.ffmpeg.temp-dir:#{systemProperties['java.io.tmpdir'] + '/ott-transcode'}}") - private String tempDir; + private final VideoStorage videoStorage; @Override - public void execute(Long mediaId, String originUrl) { - Path workDir = Path.of(tempDir, "media-" + mediaId); - - try { - Files.createDirectories(workDir); - log.info("트랜스코딩 시작 - mediaId: {}, originUrl: {}", mediaId, originUrl); - - // 1. 원본 영상을 임시 작업 디렉토리로 가져옴 - Path inputFile = videoStorage.download(originUrl, workDir); + public void execute(Long mediaId, Path inputFile, Path workDir, ProbeResult probeResult) throws Exception { + log.info("HLS 트랜스코딩 시작 - mediaId: {}", mediaId); - // 2. 해상도별 HLS 트랜스코딩 (각각 media.m3u8 + segment_XXX.ts 생성) - for (Resolution resolution : TARGET_RESOLUTION_LIST) { - ffmpegExecutor.execute(inputFile, workDir, resolution); - } + // plan + // TODO: Filter Chain 구성 필요 + List profileList = transcodePlanner.plan(probeResult); - // 3. 마스터 플레이리스트 생성 (3개 variant를 참조하는 master.m3u8) - masterPlaylistGenerator.generate(workDir, TARGET_RESOLUTION_LIST); - - // 4. 결과물을 저장소에 업로드 (output-dir/media/{mediaId}/hls/) - String uploadedPath = videoStorage.upload(workDir, "media/" + mediaId + "/hls"); - - log.info("트랜스코딩 완료 - mediaId: {}, uploadedPath: {}", mediaId, uploadedPath); - - } catch (Exception e) { - log.error("트랜스코딩 실패 - mediaId: {}", mediaId, e); - throw new RuntimeException("트랜스코딩 실패 - mediaId: " + mediaId, e); - } finally { - cleanUp(workDir); + // main + for (TranscodeProfile profile : profileList) { + ffmpegExecutor.execute(inputFile, workDir, profile); } - } - /** 임시 작업 디렉토리 삭제. 하위 파일부터 역순으로 삭제. */ - private void cleanUp(Path workDir) { - try { - if (Files.exists(workDir)) { - Files.walk(workDir) - .sorted(Comparator.reverseOrder()) - .forEach(path -> { - try { Files.deleteIfExists(path); } catch (IOException ignored) {} - }); - log.info("임시 디렉토리 정리 완료 - {}", workDir); - } - } catch (IOException e) { - log.warn("임시 디렉토리 정리 실패 - {}", workDir, e); - } + // post + List resolutionList = profileList.stream() + .map(TranscodeProfile::resolution) + .toList(); + masterPlaylistGenerator.generate(workDir, resolutionList); + + String uploadedPath = videoStorage.upload(workDir, "media/" + mediaId + "/hls"); + log.info("HLS 트랜스코딩 완료 - mediaId: {}, uploadedPath: {}", mediaId, uploadedPath); } } From fa9afc6d25f2c11c857341e5e186994a171983df Mon Sep 17 00:00:00 2001 From: marulog Date: Sun, 1 Mar 2026 21:00:49 +0900 Subject: [PATCH 196/257] =?UTF-8?q?[REFACTOR]:=20conflict=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java index a1219f2..e65fe57 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -3,6 +3,7 @@ import com.ott.api_user.auth.dto.TokenResponse; import com.ott.api_user.auth.service.AuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import jakarta.servlet.http.Cookie; @@ -12,11 +13,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequestMapping("/auth") @RequiredArgsConstructor @@ -86,23 +84,4 @@ private String extractCookie(HttpServletRequest request, String name) { } return null; } - - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 삭제 - response.addCookie(cookie); - } - } From ac65182b7b71fcb4f9667b07575af47a55c28b42 Mon Sep 17 00:00:00 2001 From: marulog Date: Sun, 1 Mar 2026 21:01:41 +0900 Subject: [PATCH 197/257] =?UTF-8?q?[DOCS]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/bookmark/controller/BookmarkAPI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java index fc46e4d..b6bb4b9 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; -@Tag(name = "Bookmark API", description = "북마크 관련 API입니다.") +@Tag(name = "Bookmark API", description = "북마크 API") public interface BookmarkAPI { @Operation(summary = "북마크 수정", description = "미디어에 대한 북마크를 수정합니다.") @ApiResponses(value = { From 85f1edc02a8357e5fe9cabea54c1a97f188b5c9b Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 2 Mar 2026 02:37:17 +0900 Subject: [PATCH 198/257] =?UTF-8?q?[FEAT]:=20=EA=B8=B0=EB=B3=B8=20Strategy?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1(=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=ED=99=94=20=EC=B6=94=EC=B2=9C=20=EC=A0=9C=EC=99=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/common/ContentSource.java | 3 +- .../content/controller/ContentsApi.java | 34 ++--- .../controller/ContentsController.java | 24 +-- .../content/service/ContentsService.java | 116 +++++++-------- .../playlist/controller/PlaylistApi.java | 42 ++++++ .../controller/PlaylistController.java | 42 ++++++ .../dto/request/PlaylistCondition.java | 5 +- .../dto/response/PlaylistResponse.java | 10 +- .../playlist/service/PlaylistService.java | 84 ++++++++--- .../strategy/BookmarkPlaylistStrategy.java | 27 ++++ .../strategy/HistoryPlaylistStrategy.java | 28 ++++ .../service/strategy/PlaylistStrategy.java | 17 ++- .../strategy/RecommendPlaylistStrategy.java | 18 +++ .../service/strategy/TagPlaylistStrategy.java | 37 +++++ .../strategy/TrendingPlaylistStrategy.java | 28 ++++ .../ott/common/web/exception/ErrorCode.java | 53 ++++--- .../media/repository/MediaRepository.java | 7 + .../repository/MediaRepositoryCustom.java | 14 +- .../media/repository/MediaRepositoryImpl.java | 137 +++++++++++++++++- 19 files changed, 589 insertions(+), 137 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java index 66d078b..ce69500 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java +++ b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java @@ -11,7 +11,8 @@ public enum ContentSource { HISTORY("HISTORY"), // 최근 시청 중인 콘텐츠에서 진입 시 TAG("TAG"), // 특정 태그(예: #스릴러) 클릭 시 RECOMMEND("RECOMMEND"), // "OO님이 좋아할 만한 리스트"에서 진입 시 - SEARCH("SEARCH"); // 검색 결과에서 진입 시 + SEARCH("SEARCH"), // 검색 결과에서 진입 시 + SERIES("SERISE"); // 시리즈 상세 페이지에서 진입 시 private final String value; } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java index 81d67b8..18574f0 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java @@ -39,21 +39,21 @@ ResponseEntity> getContentDetail( @Parameter(description = "콘텐츠 ID", required = true, example = "1") @PathVariable("contentsId") Long contentsId, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); - // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 - @Operation(summary = "진입점 기반 재생 목록 조회", description = "재생 화면 하단/우측에 노출되는 맞춤형 추천 리스트(재생 목록)를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "0", description = "조회 성공 - 재생 목록 구성", content = { - @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentListElement.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)) }) - }) - @GetMapping("/{contentsId}/playlist") - ResponseEntity>> getContentPlayList( - @Parameter(description = "현재 재생 중인 콘텐츠 ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, - @Parameter(description = "진입 맥락 (TRENDING, HISTORY, TAG 등)", example = "TAG") @RequestParam(value = "source", required = false) ContentSource source, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer pageParam, - @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); + // // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 + // @Operation(summary = "진입점 기반 재생 목록 조회", description = "재생 화면 하단/우측에 노출되는 맞춤형 추천 리스트(재생 목록)를 조회합니다.") + // @ApiResponses(value = { + // @ApiResponse(responseCode = "0", description = "조회 성공 - 재생 목록 구성", content = { + // @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentListElement.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)) }) + // }) + // @GetMapping("/{contentsId}/playlist") + // ResponseEntity>> getContentPlayList( + // @Parameter(description = "현재 재생 중인 콘텐츠 ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, + // @Parameter(description = "진입 맥락 (TRENDING, HISTORY, TAG 등)", example = "TAG") @RequestParam(value = "source", required = false) ContentSource source, + // @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer pageParam, + // @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, + // @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java index 69da1c3..d445cb5 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -35,16 +35,16 @@ public ResponseEntity> getContentDetail( SuccessResponse.of(contentService.getContentDetail(contentsId, memberId))); } - // 재생목록(플레이리스트) 리스트 API (/contents/{contentsId}/playlist?source={SOURCE}) - @Override - public ResponseEntity>> getContentPlayList( - @PathVariable(value = "contentsId") Long contentsId, - @RequestParam(value = "source", required = false) ContentSource source, - @RequestParam(value = "page") Integer pageParam, - @RequestParam(value = "size") Integer sizeParam, - @AuthenticationPrincipal Long memberId) { - return ResponseEntity.ok( - SuccessResponse.of(contentService.getContentPlayList(contentsId, source, pageParam, - sizeParam, memberId))); - } + // // 재생목록(플레이리스트) 리스트 API (/contents/{contentsId}/playlist?source={SOURCE}) + // @Override + // public ResponseEntity>> getContentPlayList( + // @PathVariable(value = "contentsId") Long contentsId, + // @RequestParam(value = "source", required = false) ContentSource source, + // @RequestParam(value = "page") Integer pageParam, + // @RequestParam(value = "size") Integer sizeParam, + // @AuthenticationPrincipal Long memberId) { + // return ResponseEntity.ok( + // SuccessResponse.of(contentService.getContentPlayList(contentsId, source, pageParam, + // sizeParam, memberId))); + // } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java index d75d0bf..d39096f 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java @@ -62,63 +62,63 @@ public ContentsDetailResponse getContentDetail(Long contentsId, Long memberId) { } - // 해당 콘텐츠를 어디서 진입했는지에 따라 - // 콘텐츠의 재생목록이 달라짐. - public PageResponse getContentPlayList(Long contentsId, ContentSource source, int page, - int size, Long memberId) { - - Contents currentContents = contentsRepository.findByIdAndStatusAndMedia_PublicStatus(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) - .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - - if (currentContents.getSeries() != null) { - throw new BusinessException(ErrorCode.INVALID_REQUEST_FOR_SERIES_PLAYLIST); - } - - Pageable pageable = PageRequest.of(page, size); - ContentSource currentSource = (source != null) ? source : ContentSource.TRENDING; - Long mediaId = currentContents.getMedia().getId(); - - Page playListPage = switch (currentSource) { - // 진입점에 따른 재생 목록 노출 리스트 분기 로직 - case TRENDING -> getTrendingPlaylist(pageable, mediaId); // 1. 북마크 많은 순 - case HISTORY -> getHistoryPlaylist(memberId, pageable, mediaId); // 2. 최근 시청한 순 (신규) - case RECOMMEND -> getRecommendPlaylist(memberId, pageable, mediaId); // 3. OO 님이 좋아하실만한 리스트 - case TAG -> getTagPlaylist(mediaId, pageable); // 4. 같은 태그 가진 영상 - case BOOKMARK -> getBookmarkPlaylist(memberId, pageable, mediaId); // 5. 내 북마크 목록 - default -> getRecommendPlaylist(memberId, pageable, mediaId); // 기본값은 OO님이 좋아하실만한 콘텐츠 - }; - - List contentList = playListPage.getContent().stream().map(ContentListElement::from) - .collect(Collectors.toList()); - - PageInfo pageInfo = PageInfo.toPageInfo(page, page, size); - - return PageResponse.toPageResponse(pageInfo, contentList); - } - - // 현재는 switch 문을 활용해 직관적 분기 처리를 구현하였지만 - // 새로운 추천 로직이 추가될때마다 서비스코드가 길어질 것을 우려해서 - // Strategy Pattern 을 적용해 객체 지향적 코드로 리팩토링해야함! - - private Page getTrendingPlaylist(Pageable pageable, Long excludeMediaId) { - return Page.empty(); - } - - private Page getHistoryPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { - return Page.empty(); - } - - private Page getRecommendPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { - return Page.empty(); - } - - private Page getTagPlaylist(Long targetMediaId, Pageable pageable) { - // 태그는 '기준 미디어(targetMediaId)'의 태그를 찾으면서, 동시에 해당 미디어를 결과에서 제외해야 함. - return Page.empty(); - } - - private Page getBookmarkPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { - return Page.empty(); - } + // // 해당 콘텐츠를 어디서 진입했는지에 따라 + // // 콘텐츠의 재생목록이 달라짐. + // public PageResponse getContentPlayList(Long contentsId, ContentSource source, int page, + // int size, Long memberId) { + + // Contents currentContents = contentsRepository.findByIdAndStatusAndMedia_PublicStatus(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) + // .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // if (currentContents.getSeries() != null) { + // throw new BusinessException(ErrorCode.INVALID_REQUEST_FOR_SERIES_PLAYLIST); + // } + + // Pageable pageable = PageRequest.of(page, size); + // ContentSource currentSource = (source != null) ? source : ContentSource.TRENDING; + // Long mediaId = currentContents.getMedia().getId(); + + // Page playListPage = switch (currentSource) { + // // 진입점에 따른 재생 목록 노출 리스트 분기 로직 + // case TRENDING -> getTrendingPlaylist(pageable, mediaId); // 1. 북마크 많은 순 + // case HISTORY -> getHistoryPlaylist(memberId, pageable, mediaId); // 2. 최근 시청한 순 (신규) + // case RECOMMEND -> getRecommendPlaylist(memberId, pageable, mediaId); // 3. OO 님이 좋아하실만한 리스트 + // case TAG -> getTagPlaylist(mediaId, pageable); // 4. 같은 태그 가진 영상 + // case BOOKMARK -> getBookmarkPlaylist(memberId, pageable, mediaId); // 5. 내 북마크 목록 + // default -> getRecommendPlaylist(memberId, pageable, mediaId); // 기본값은 OO님이 좋아하실만한 콘텐츠 + // }; + + // List contentList = playListPage.getContent().stream().map(ContentListElement::from) + // .collect(Collectors.toList()); + + // PageInfo pageInfo = PageInfo.toPageInfo(page, page, size); + + // return PageResponse.toPageResponse(pageInfo, contentList); + // } + + // // 현재는 switch 문을 활용해 직관적 분기 처리를 구현하였지만 + // // 새로운 추천 로직이 추가될때마다 서비스코드가 길어질 것을 우려해서 + // // Strategy Pattern 을 적용해 객체 지향적 코드로 리팩토링해야함! + + // private Page getTrendingPlaylist(Pageable pageable, Long excludeMediaId) { + // return Page.empty(); + // } + + // private Page getHistoryPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { + // return Page.empty(); + // } + + // private Page getRecommendPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { + // return Page.empty(); + // } + + // private Page getTagPlaylist(Long targetMediaId, Pageable pageable) { + // // 태그는 '기준 미디어(targetMediaId)'의 태그를 찾으면서, 동시에 해당 미디어를 결과에서 제외해야 함. + // return Page.empty(); + // } + + // private Page getBookmarkPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { + // return Page.empty(); + // } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java new file mode 100644 index 0000000..4296e44 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java @@ -0,0 +1,42 @@ +package com.ott.api_user.playlist.controller; + +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ott.common.web.exception.ErrorResponse; + +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; + +@Tag(name = "Playlist", description = "플레이리스트 API") +public interface PlaylistApi { + @Operation(summary = "플레이리스트 조회", description = "source 타입에 따라 동적으로 플레이리스트를 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "0", description = "조회 성공 - 플레이리스트 구성", content ={ + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = PlaylistResponse.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)) }) + }) + @GetMapping + ResponseEntity>> getPlaylists( + @Parameter(description = "플레이리스트 조회 조건 (source 필수)", required = true) PlaylistCondition condition, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java new file mode 100644 index 0000000..82eee30 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -0,0 +1,42 @@ +package com.ott.api_user.playlist.controller; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ott.api_user.common.dto.ContentListElement; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.service.PlaylistService; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/playlist") +public class PlaylistController implements PlaylistApi { + + private final PlaylistService playlistService; + + @Override + public ResponseEntity>> getPlaylists( + PlaylistCondition condition, + @RequestParam(value = "page", defaultValue = "0") Integer pageParam, + @RequestParam(value = "size", defaultValue = "10") Integer sizeParam, + @AuthenticationPrincipal Long memberId) { + + // 토큰에서 추출한 유저 ID를 Condition 객체에 세팅 + if (memberId != null) { + condition.setMemberId(memberId); + } + + Pageable pageable = PageRequest.of(pageParam, sizeParam); + + return ResponseEntity.ok(SuccessResponse.of(playlistService.getPlaylists(condition, pageable))); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java index 8a0bebd..c53906b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java @@ -14,7 +14,8 @@ import lombok.Setter; import lombok.ToString; -// 요청 DTO +// 플레이리스트 공통 요청 DTO +// 진입점, 플레이리스트 or 재생목록 (둘은 재사용, 현재 컨텐츠 id 에 따라) 에 따라 들어오는 파라미터를 공통으로 사용 @Getter @Setter @NoArgsConstructor @@ -27,7 +28,7 @@ public class PlaylistCondition { @Schema(description = "사용자 고유 ID", example = "1") private Long memberId; - @Schema(description = "현재 컨텐츠의 Id", example = "1") + @Schema(description = "현재 컨텐츠의 Id", example = "1") // 상세 페이지 진입 시 재생목록에서 제외 private Long excludeMediaId; @Schema(description = "태그 고유 ID", example = "1") diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java index 158e540..47c89e7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java @@ -2,7 +2,7 @@ import com.ott.domain.common.MediaType; import com.ott.domain.media.domain.Media; - +import lombok.AccessLevel; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -10,7 +10,7 @@ @Getter @Builder -@AllArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Schema(description = "플레이리스트 조회 응답 DTO") public class PlaylistResponse { @@ -23,14 +23,18 @@ public class PlaylistResponse { @Schema(description = "포스터 이미지 URL", example = "https://s3.../poster.jpg") private String posterUrl; + @Schema(description = "가로형 썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") + private String thumbnailUrl; + @Schema(description = "미디어 타입 (UI 분기 처리 및 라우팅용)", example = "SERIES") private MediaType mediaType; - + public static PlaylistResponse from(Media media) { return PlaylistResponse.builder() .mediaId(media.getId()) .title(media.getTitle()) .posterUrl(media.getPosterUrl()) + .thumbnailUrl(media.getThumbnailUrl()) .mediaType(media.getMediaType()) .build(); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java index 8452ebc..3bc8f61 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java @@ -1,33 +1,83 @@ package com.ott.api_user.playlist.service; -import java.util.List; -import java.util.stream.Collectors; - -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 com.ott.api_user.common.ContentSource; -import com.ott.api_user.common.dto.ContentListElement; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.service.strategy.PlaylistStrategy; 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.PublicStatus; -import com.ott.domain.common.Status; -import com.ott.domain.contents.domain.Contents; -import com.ott.domain.contents.repository.ContentsRepository; import com.ott.domain.media.domain.Media; - import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class PlaylistService { - private final ContentsRepository contentsRepository; + private final Map strategyMap; + + public PageResponse getPlaylists(PlaylistCondition condition, Pageable pageable) { + + if (condition.getContentSource() == null) { + throw new BusinessException(ErrorCode.INVALID_PLAYLIST_SOURCE); + } + + // 1. 전략 선택 + PlaylistStrategy strategy = getStrategy(condition); + + // 2. 데이터 조회 + Page mediaPage = strategy.getPlaylist(condition, pageable); + + // 3. Entity -> DTO 변환 + List contentList = mediaPage.getContent().stream() + .map(PlaylistResponse::from) + .toList(); + + // 4. PageInfo 생성 + PageInfo pageInfo = PageInfo.toPageInfo( + mediaPage.getNumber(), + mediaPage.getTotalPages(), + (int) mediaPage.getTotalElements() + ); + + return PageResponse.toPageResponse(pageInfo, contentList); + } + + + + private PlaylistStrategy getStrategy(PlaylistCondition condition) { + String strategyKey = determineStrategyKey(condition); + PlaylistStrategy strategy = strategyMap.get(strategyKey); + + if (strategy == null) { + strategy = strategyMap.get(ContentSource.RECOMMEND.name()); + } + + // 여전히 null이라면 시스템 설정 오류이므로 S001 에러 발생 + if (strategy == null) { + throw new BusinessException(ErrorCode.STRATEGY_NOT_FOUND); + } + return strategy; + } + + + + private String determineStrategyKey(PlaylistCondition condition) { + ContentSource source = condition.getContentSource(); + if (source == null) return ContentSource.RECOMMEND.name(); -} + // 검색 결과에서 진입 시 추천 리스트로 대체 + if (source == ContentSource.SEARCH && condition.getExcludeMediaId() != null) { + return ContentSource.RECOMMEND.name(); + } + return source.name(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java new file mode 100644 index 0000000..c108151 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java @@ -0,0 +1,27 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + +// 북마크 목록 - 특정 회원이 북마크한 미디어 리스트 +@Component("BOOKMARK") +@RequiredArgsConstructor +public class BookmarkPlaylistStrategy implements PlaylistStrategy { + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + if (condition.getMemberId() == null) return Page.empty(); + + return mediaRepository.findBookmarkedPlaylists( + condition.getMemberId(), + condition.getExcludeMediaId(), + pageable + ); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java new file mode 100644 index 0000000..c12c262 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java @@ -0,0 +1,28 @@ +package com.ott.api_user.playlist.service.strategy; + + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + + +//시청 이력 기반 플레이리스트 - playback (시청기록)을 토대로 중복 제거 후 최근날짜 순 리스트 +@Component("HISTORY") +@RequiredArgsConstructor +public class HistoryPlaylistStrategy implements PlaylistStrategy { + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + + return mediaRepository.findHistoryPlaylists( + condition.getMemberId(), + condition.getExcludeMediaId(), + pageable + ); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java index 396452e..92db5c7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java @@ -1,6 +1,21 @@ package com.ott.api_user.playlist.service.strategy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; + //공통 인터페이스 -public interface PlaylistStrategy { +// 기존의 swtich 문으로 작성해둔 진입 시점에 따른 분기처리를 +// 전략 패턴을 도입하여 공통으로 +public interface PlaylistStrategy { + /** + * 조건에 맞는 미디어 목록을 찾아옵니다. + * @param condition 프론트엔드에서 넘어온 진입 시점 + * @param pageable 페이징 정보 + * @return DB에서 찾아온 Media 엔티티들의 페이지 객체 + */ + Page getPlaylist(PlaylistCondition condition, Pageable pageable); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java new file mode 100644 index 0000000..c28762c --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java @@ -0,0 +1,18 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + + +// // 개인화 추천 - 시청이력 + 좋아요 + 기존 선호 태그 +// @Component("RECOMMEND") +// @RequiredArgsConstructor +// public class RecommendPlaylistStrategy implements PlaylistStrategy { + + +// } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java new file mode 100644 index 0000000..084cd41 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java @@ -0,0 +1,37 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.stereotype.Component; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + +/** + * 태그별 플레이리스트 전략 + * 특정 태그 ID를 기준으로 관련 미디어 목록을 제공합니다. + */ +@Component("TAG") +@RequiredArgsConstructor +public class TagPlaylistStrategy implements PlaylistStrategy { + + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + + // 1. 핵심 조건인 tagId가 없는 경우 (예외 상황) + if (condition.getTagId() == null) { + return mediaRepository.findTrendingPlaylists(condition.getExcludeMediaId(), pageable); + } + + // 2. 태그 ID가 있다면 홈 화면이든 상세 페이지든 해당 태그 리스트를 반환합니다. + // 상세 페이지라면 리포지토리 내부 로직에 의해 excludeMediaId가 제외 처리됩니다. + return mediaRepository.findPlaylistsByTag( + condition.getTagId(), + condition.getExcludeMediaId(), + pageable + ); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java new file mode 100644 index 0000000..5de07b2 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java @@ -0,0 +1,28 @@ +package com.ott.api_user.playlist.service.strategy; + + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.domain.media.domain.Media; +import com.ott.domain.media.repository.MediaRepository; + +import lombok.RequiredArgsConstructor; + +// 인기 차트 플레이리스트 - 북마크 순으로 내림차순 정렬 리스트 +@Component("TRENDING") // ContentSource Enum 이름과 똑같이 맞춤 +@RequiredArgsConstructor +public class TrendingPlaylistStrategy implements PlaylistStrategy { + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + // 리포지토리 내부에서 excludeMediaId의 유무를 알아서 판단하여 처리합니다. + return mediaRepository.findTrendingPlaylists( + condition.getExcludeMediaId(), + pageable + ); + } +} diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index de3c1cb..ef805ed 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -41,28 +41,39 @@ public enum ErrorCode { DUPLICATE_EMAIL(HttpStatus.CONFLICT, "U002", "이미 존재하는 이메일입니다"), - // ========== Business (B) - 비즈니스 ========== - CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "콘텐츠를 찾을 수 없습니다"), - SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "시리즈를 찾을 수 없습니다"), - SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B003", "검색어는 최소 2글자 이상이어야 합니다"), - INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B004", "허용되지 않는 역할 변경입니다"), - CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "카테고리를 찾을 수 없습니다."), - TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "태그를 찾을 수 없습니다."), - MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B007", "미디어를 찾을 수 없습니다."), - COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B008", "댓글을 찾을 수 없습니다."), - COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "B009", "본인이 작성한 댓글만 수정/삭제할 수 있습니다."), - UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B009", "지원하지 않는 이미지 확장자입니다."), - UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B010", "지원하지 않는 동영상 확장자입니다."), - INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B011", "파일 확장자가 올바르지 않습니다."), - INVALID_TAG_CATEGORY(HttpStatus.NOT_FOUND, "B012", "유효한 카테고리를 찾을 수 없습니다."), - INVALID_TAG_SELECTION(HttpStatus.BAD_REQUEST, "B013", "카테고리에 맞지 않거나 존재하지 않는 태그가 포함되어 있습니다."), - DUPLICATE_TAG_IN_LIST(HttpStatus.BAD_REQUEST, "B014", "태그 목록에 중복된 값이 있습니다."), - INVALID_SHORTFORM_TARGET(HttpStatus.BAD_REQUEST, "B015", "seriesId와 contentsId 중 하나만 제공해야 합니다."), - INVALID_SHORTFORM_CONTENTS_TARGET(HttpStatus.BAD_REQUEST, "B016", "시리즈에 속한 콘텐츠는 숏폼 원본으로 선택할 수 없습니다."), - SHORTFORM_ORIGIN_MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B017", "숏폼 원본 미디어를 찾을 수 없습니다."), - BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B018", "북마크를 찾을 수 없습니다"), - INVALID_REQUEST_FOR_SERIES_PLAYLIST(HttpStatus.BAD_REQUEST, "B019", "해당 콘텐츠는 시리즈 전용 API를 사용해주세요"); + + // ========== Business (B) - 비즈니스 (조회 실패: 100번대) ========== + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B101", "콘텐츠를 찾을 수 없습니다"), + SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B102", "시리즈를 찾을 수 없습니다"), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B103", "카테고리를 찾을 수 없습니다"), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B104", "태그를 찾을 수 없습니다"), + MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B105", "미디어를 찾을 수 없습니다"), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B106", "댓글을 찾을 수 없습니다"), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B107", "북마크를 찾을 수 없습니다"), + // ========== Business (B) - 비즈니스 (정책/유효성: 200번대) ========== + SEARCH_KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "B201", "검색어는 최소 2글자 이상이어야 합니다"), + DUPLICATE_TAG_IN_LIST(HttpStatus.BAD_REQUEST, "B202", "태그 목록에 중복된 값이 있습니다"), + INVALID_TAG_SELECTION(HttpStatus.BAD_REQUEST, "B203", "카테고리에 맞지 않는 태그가 포함되어 있습니다"), + INVALID_ROLE_CHANGE(HttpStatus.BAD_REQUEST, "B204", "허용되지 않는 역할 변경입니다"), + COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "B205", "본인이 작성한 댓글만 수정/삭제할 수 있습니다"), + INVALID_PLAYLIST_SOURCE(HttpStatus.BAD_REQUEST, "B206", "재생목록 소스(source)는 필수값입니다"), + + // ========== Business (B) - 비즈니스 (미디어/파일 전용: 300번대) ========== + UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B301", "지원하지 않는 이미지 확장자입니다"), + UNSUPPORTED_VIDEO_EXTENSION(HttpStatus.BAD_REQUEST, "B302", "지원하지 않는 동영상 확장자입니다"), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "B303", "파일 확장자가 올바르지 않습니다"), + + // ========== Business (B) - 비즈니스 (특수 도메인 규칙/태그/숏폼: 400번대) ========== + INVALID_TAG_CATEGORY(HttpStatus.BAD_REQUEST, "B401", "유효하지 않은 태그 카테고리입니다."), + SHORTFORM_ORIGIN_MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B402", "쇼츠의 원본 미디어를 찾을 수 없습니다."), + INVALID_SHORTFORM_TARGET(HttpStatus.BAD_REQUEST, "B403", "seriesId와 contentsId 중 하나만 제공해야 합니다"), + INVALID_SHORTFORM_CONTENTS_TARGET(HttpStatus.BAD_REQUEST, "B404", "시리즈에 속한 콘텐츠는 숏폼 원본으로 선택할 수 없습니다"), + INVALID_REQUEST_FOR_SERIES_PLAYLIST(HttpStatus.BAD_REQUEST, "B405", "해당 콘텐츠는 시리즈 전용 API를 사용해주세요"), + + // ========== Server (S) - 서버/시스템 ========== + STRATEGY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "적절한 재생목록 전략을 찾을 수 없습니다"); + private final HttpStatus status; private final String code; diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java index 8520a1a..e8bfd3d 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java @@ -2,7 +2,14 @@ import com.ott.domain.media.domain.Media; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + public interface MediaRepository extends JpaRepository, MediaRepositoryCustom { + } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index be6bab8..7854415 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -17,4 +17,16 @@ Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pa MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); -} + + // 인기 차트 통합 조회 메서드 + Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable); + + // 시청 이력 조회 (최근 시청 순) + Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); + + // 북마크 목록 조회 (최근 찜한 순) + Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); + + // 특정 태그 기반 미디어 목록 조회 + Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable); +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index abf9963..207a8c8 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -2,6 +2,7 @@ import com.ott.domain.common.MediaType; import com.ott.domain.common.PublicStatus; +import com.ott.domain.common.Status; import com.ott.domain.media.domain.Media; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; @@ -11,13 +12,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.util.StringUtils; - +import static com.ott.domain.playback.domain.QPlayback.playback; import java.util.List; - +import static com.ott.domain.media.domain.QMedia.media; import com.querydsl.jpa.JPAExpressions; - +import static com.ott.domain.bookmark.domain.QBookmark.bookmark; +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; import static com.ott.domain.contents.domain.QContents.contents; -import static com.ott.domain.media.domain.QMedia.media; @RequiredArgsConstructor public class MediaRepositoryImpl implements MediaRepositoryCustom { @@ -128,6 +129,123 @@ public Page findOriginMediaListBySearchWord(Pageable pageable, String sea return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); } + + /* + * 플레이리스트 전략패턴 관련 로직 + */ + + @Override + public Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable){ + List content = queryFactory + .selectFrom(media) + .where( + isActiveAndPublic(), // 활성 및 공개 상태 필터링 + excludeId(excludeMediaId) // 현재 미디어 제외 (null이면 무시됨) + ) + .orderBy(media.bookmarkCount.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(media.count()) + .from(media) + .where( + isActiveAndPublic(), + excludeId(excludeMediaId) + ); + + // PageableExecutionUtils를 사용하여 첫 페이지 조회 시 불필요한 카운트 쿼리 방지 + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + @Override + public Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .select(media) + .from(playback) + .join(playback.contents.media, media) // 시청 기록과 미디어 정보 조인 + .where( + playback.member.id.eq(memberId), // 특정 사용자 필터링 + isActiveAndPublic(), // 활성/공개 상태 확인 + excludeId(excludeMediaId) // 현재 재생 중인 영상 제외 + ) + .orderBy(playback.modifiedDate.desc()) // 최근 시청 시점 순 정렬 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(playback.count()) + .from(playback) + .where( + playback.member.id.eq(memberId), + excludeId(excludeMediaId) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + + @Override + public Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .select(media) + .from(bookmark) + .join(bookmark.media, media) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(Status.ACTIVE), + isActiveAndPublic(), + excludeId(excludeMediaId) + ) + .orderBy(bookmark.createdDate.desc()) // 최근 북마크한 순서 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(bookmark.count()) + .from(bookmark) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(Status.ACTIVE), + excludeId(excludeMediaId) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + + @Override + public Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable) { + List content = queryFactory + .select(media) + .from(mediaTag) + .join(mediaTag.media, media) + .where( + mediaTag.tag.id.eq(tagId), // 요청된 태그 ID 필터링 + isActiveAndPublic(), // 활성/공개 상태 확인 + excludeId(excludeMediaId) // 현재 재생 중인 영상 제외 + ) + .orderBy(media.createdDate.desc()) // 최신 등록 순 정렬 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(mediaTag.count()) + .from(mediaTag) + .where( + mediaTag.tag.id.eq(tagId), + excludeId(excludeMediaId) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + + // --- 동적 쿼리 헬퍼 메서드 --- private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) return media.title.contains(searchWord); @@ -151,4 +269,15 @@ private BooleanExpression uploaderIdEq(Long uploaderId) { return media.uploader.id.eq(uploaderId); return null; } + + private BooleanExpression isActiveAndPublic() { + // Status.ACTIVE와 PublicStatus.PUBLIC 조건을 결합 + return media.status.eq(com.ott.domain.common.Status.ACTIVE) + .and(media.publicStatus.eq(com.ott.domain.common.PublicStatus.PUBLIC)); + } + + private BooleanExpression excludeId(Long excludeMediaId) { + // 전달된 ID가 있을 때만 '해당 ID 제외' 조건을 추가, 없으면 null 반환하여 무시 + return excludeMediaId != null ? media.id.ne(excludeMediaId) : null; + } } From 1c032a58bcba3b2e8b2fdb0538358440f221d088 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Mon, 2 Mar 2026 03:43:33 +0900 Subject: [PATCH 199/257] =?UTF-8?q?[FIX]:=20=EC=BB=A8=ED=85=90=EC=B8=A0/?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=A1=B0=ED=9A=8C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=AF=B8=EB=94=94=EC=96=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentsApi.java | 21 +----- .../controller/ContentsController.java | 17 +---- .../content/dto/ContentsDetailResponse.java | 4 +- .../content/service/ContentsService.java | 67 +------------------ .../strategy/SeriesPlaylistStrategy.java | 11 +++ .../api_user/series/controller/SeriesApi.java | 8 +-- .../series/controller/SeriesController.java | 8 +-- .../series/dto/SeriesContentsResponse.java | 4 +- .../series/dto/SeriesDetailResponse.java | 4 +- .../series/service/SeriesService.java | 20 +++--- .../repository/ContentsRepository.java | 18 ++++- .../series/repository/SeriesRepository.java | 10 ++- 12 files changed, 65 insertions(+), 127 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java index 18574f0..a94d7cb 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java @@ -34,26 +34,9 @@ public interface ContentsApi { @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) - @GetMapping("/{contentsId}") + @GetMapping("/{mediaId}") ResponseEntity> getContentDetail( - @Parameter(description = "콘텐츠 ID", required = true, example = "1") @PathVariable("contentsId") Long contentsId, + @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); - // // 맥락 (진입점) 기반 플레이리스트 조회 - 해당 API 는 홈화면의 플레이리스트 조회 API 와 별도로 작성한다?? 아니면 재사용 - // @Operation(summary = "진입점 기반 재생 목록 조회", description = "재생 화면 하단/우측에 노출되는 맞춤형 추천 리스트(재생 목록)를 조회합니다.") - // @ApiResponses(value = { - // @ApiResponse(responseCode = "0", description = "조회 성공 - 재생 목록 구성", content = { - // @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ContentListElement.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)) }) - // }) - // @GetMapping("/{contentsId}/playlist") - // ResponseEntity>> getContentPlayList( - // @Parameter(description = "현재 재생 중인 콘텐츠 ID", required = true, example = "10") @PathVariable("contentsId") Long contentsId, - // @Parameter(description = "진입 맥락 (TRENDING, HISTORY, TAG 등)", example = "TAG") @RequestParam(value = "source", required = false) ContentSource source, - // @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer pageParam, - // @Parameter(description = "페이지 크기", example = "20") @RequestParam(value = "size", defaultValue = "20") Integer sizeParam, - // @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java index d445cb5..7478de7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -28,23 +28,10 @@ public class ContentsController implements ContentsApi { @Override public ResponseEntity> getContentDetail( - @PathVariable(value = "contentsId") Long contentsId, + @PathVariable(value = "mediaId") Long mediaId, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( - SuccessResponse.of(contentService.getContentDetail(contentsId, memberId))); + SuccessResponse.of(contentService.getContentDetail(mediaId, memberId))); } - - // // 재생목록(플레이리스트) 리스트 API (/contents/{contentsId}/playlist?source={SOURCE}) - // @Override - // public ResponseEntity>> getContentPlayList( - // @PathVariable(value = "contentsId") Long contentsId, - // @RequestParam(value = "source", required = false) ContentSource source, - // @RequestParam(value = "page") Integer pageParam, - // @RequestParam(value = "size") Integer sizeParam, - // @AuthenticationPrincipal Long memberId) { - // return ResponseEntity.ok( - // SuccessResponse.of(contentService.getContentPlayList(contentsId, source, pageParam, - // sizeParam, memberId))); - // } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java index f6f7408..9204470 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java @@ -15,7 +15,7 @@ @AllArgsConstructor @Schema(description = "컨텐츠 상세(재생) 조회 응답 DTO") public class ContentsDetailResponse { - @Schema(description = "콘텐츠 ID", example = "1") + @Schema(description = "미디어 고유 ID", example = "1") private Long id; @Schema(description = "콘텐츠 제목", example = "비밀의 숲") @@ -53,7 +53,7 @@ public static ContentsDetailResponse from(Contents contents, List tags, Integer positionSec) { return ContentsDetailResponse.builder() - .id(contents.getId()) + .id(contents.getMedia().getId()) .title(contents.getMedia().getTitle()) .description(contents.getMedia().getDescription()) .actors(contents.getActors()) diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java index d39096f..07f8988 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java @@ -42,12 +42,11 @@ public class ContentsService { // 재생 상세 - public ContentsDetailResponse getContentDetail(Long contentsId, Long memberId) { - Contents contents = contentsRepository.findByIdAndStatusAndMedia_PublicStatus(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) + public ContentsDetailResponse getContentDetail(Long mediaId, Long memberId) { + + Contents contents = contentsRepository.findByMediaIdAndStatusAndMedia_PublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - Long mediaId = contents.getMedia().getId(); - List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); @@ -59,66 +58,6 @@ public ContentsDetailResponse getContentDetail(Long contentsId, Long memberId) { Integer positionSec = 0; return ContentsDetailResponse.from(contents, tags, categories, isBookmarked, isLiked,masterPlaylistUrl, positionSec); - } - // // 해당 콘텐츠를 어디서 진입했는지에 따라 - // // 콘텐츠의 재생목록이 달라짐. - // public PageResponse getContentPlayList(Long contentsId, ContentSource source, int page, - // int size, Long memberId) { - - // Contents currentContents = contentsRepository.findByIdAndStatusAndMedia_PublicStatus(contentsId, Status.ACTIVE, PublicStatus.PUBLIC) - // .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); - - // if (currentContents.getSeries() != null) { - // throw new BusinessException(ErrorCode.INVALID_REQUEST_FOR_SERIES_PLAYLIST); - // } - - // Pageable pageable = PageRequest.of(page, size); - // ContentSource currentSource = (source != null) ? source : ContentSource.TRENDING; - // Long mediaId = currentContents.getMedia().getId(); - - // Page playListPage = switch (currentSource) { - // // 진입점에 따른 재생 목록 노출 리스트 분기 로직 - // case TRENDING -> getTrendingPlaylist(pageable, mediaId); // 1. 북마크 많은 순 - // case HISTORY -> getHistoryPlaylist(memberId, pageable, mediaId); // 2. 최근 시청한 순 (신규) - // case RECOMMEND -> getRecommendPlaylist(memberId, pageable, mediaId); // 3. OO 님이 좋아하실만한 리스트 - // case TAG -> getTagPlaylist(mediaId, pageable); // 4. 같은 태그 가진 영상 - // case BOOKMARK -> getBookmarkPlaylist(memberId, pageable, mediaId); // 5. 내 북마크 목록 - // default -> getRecommendPlaylist(memberId, pageable, mediaId); // 기본값은 OO님이 좋아하실만한 콘텐츠 - // }; - - // List contentList = playListPage.getContent().stream().map(ContentListElement::from) - // .collect(Collectors.toList()); - - // PageInfo pageInfo = PageInfo.toPageInfo(page, page, size); - - // return PageResponse.toPageResponse(pageInfo, contentList); - // } - - // // 현재는 switch 문을 활용해 직관적 분기 처리를 구현하였지만 - // // 새로운 추천 로직이 추가될때마다 서비스코드가 길어질 것을 우려해서 - // // Strategy Pattern 을 적용해 객체 지향적 코드로 리팩토링해야함! - - // private Page getTrendingPlaylist(Pageable pageable, Long excludeMediaId) { - // return Page.empty(); - // } - - // private Page getHistoryPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { - // return Page.empty(); - // } - - // private Page getRecommendPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { - // return Page.empty(); - // } - - // private Page getTagPlaylist(Long targetMediaId, Pageable pageable) { - // // 태그는 '기준 미디어(targetMediaId)'의 태그를 찾으면서, 동시에 해당 미디어를 결과에서 제외해야 함. - // return Page.empty(); - // } - - // private Page getBookmarkPlaylist(Long memberId, Pageable pageable, Long excludeMediaId) { - // return Page.empty(); - // } - } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java new file mode 100644 index 0000000..5ad8a5a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java @@ -0,0 +1,11 @@ +package com.ott.api_user.playlist.service.strategy; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +// @Component("SERIRES") +// @RequiredArgsConstructor +// public class SeriesPlaylistStrategy implements PlaylistStrategy { + +// } diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java index edafce8..0aaaed5 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java @@ -32,9 +32,9 @@ public interface SeriesApi { @ApiResponse(responseCode = "404", description = "시리즈를 찾을 수 없음", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) - @GetMapping("/{seriesId}") + @GetMapping("/{mediaId}") ResponseEntity> getSeriesDetail( - @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("seriesId") Long seriesId, + @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId // 토큰에서 추출 (스웨거에서는 숨김) ); @@ -50,9 +50,9 @@ ResponseEntity> getSeriesDetail( @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) - @GetMapping("/{seriesId}/contents") + @GetMapping("/{mediaId}/contents") ResponseEntity>> getSeriesContents( - @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("seriesId") Long seriesId, + @Parameter(description = "시리즈 ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page, @Parameter(description = "페이지 크기", example = "24") @RequestParam(defaultValue = "24") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId); diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java index 5db559a..2b44f92 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java @@ -24,21 +24,21 @@ public class SeriesController implements SeriesApi { @Override public ResponseEntity> getSeriesDetail( - @PathVariable(value = "seriesId") Long seriesId, + @PathVariable(value = "mediaId") Long mediaId, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( - SuccessResponse.of(seriesService.getSeriesDetail(seriesId, memberId))); + SuccessResponse.of(seriesService.getSeriesDetail(mediaId, memberId))); } @Override public ResponseEntity>> getSeriesContents( - @PathVariable(value = "seriesId") Long seriesId, + @PathVariable(value = "mediaId") Long mediaId, @RequestParam(value = "page") Integer pageParam, @RequestParam(value = "size") Integer sizeParam, @AuthenticationPrincipal Long memberId) { return ResponseEntity.ok( - SuccessResponse.of(seriesService.getSeriesContents(seriesId, pageParam, sizeParam, memberId))); + SuccessResponse.of(seriesService.getSeriesContents(mediaId, pageParam, sizeParam, memberId))); } } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java index 748506e..83d98d2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java @@ -12,7 +12,7 @@ @AllArgsConstructor @Schema(description = "시리즈 내 콘텐츠(에피소드) 목록 아이템 응답 DTO") public class SeriesContentsResponse { - @Schema(type = "Long", example = "1", description = "콘텐츠 고유 ID") + @Schema(type = "Long", example = "1", description = "미디어 고유 ID") private Long id; @Schema(type = "String", example = "더 글로리 시즌 1: 1화", description = "콘텐츠 제목") @@ -36,7 +36,7 @@ public static SeriesContentsResponse from(Contents content) { Integer positionSec = 0; return SeriesContentsResponse.builder() - .id(content.getId()) + .id(content.getMedia().getId()) .duration(content.getDuration()) .title(content.getMedia().getTitle()) .description(content.getMedia().getDescription()) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java index f28fb8d..5f39152 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java @@ -15,7 +15,7 @@ @Schema(description = "시리즈 상세 조회 응답 DTO") public class SeriesDetailResponse { - @Schema(description = "시리즈 고유 ID", example = "101") + @Schema(description = "미디어 고유 ID", example = "101") private Long id; @Schema(description = "시리즈 제목", example = "비밀의 숲") @@ -46,7 +46,7 @@ public class SeriesDetailResponse { public static SeriesDetailResponse of(Series series, List tags, List categories, Boolean isBookmarked, Boolean isLiked) { return SeriesDetailResponse.builder() - .id(series.getId()) + .id(series.getMedia().getId()) .actors(series.getActors()) .title(series.getMedia().getTitle()) .description(series.getMedia().getDescription()) diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java index 6cdb0a1..952e30a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java @@ -43,11 +43,10 @@ public class SeriesService { // private final PlaybackRepository playbackRepository; // 시리즈 상세 조회 - public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { - Series series = seriesRepository.findByIdWithMedia(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + public SeriesDetailResponse getSeriesDetail(Long mediaId, Long memberId) { - Long mediaId = series.getMedia().getId(); + Series series = seriesRepository.findByMediaIdAndStatusAndPublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); List tags = tagRepository.findTagNamesByMediaId(mediaId, Status.ACTIVE); List categories = categoryRepository.findCategoryNamesByMediaId(mediaId, Status.ACTIVE); @@ -61,20 +60,21 @@ public SeriesDetailResponse getSeriesDetail(Long seriesId, Long memberId) { // 시리즈 콘텐츠 목록 조회 (페이징) // 반환 타입 제네릭으로 수정 - public PageResponse getSeriesContents(Long seriesId, int page, int size, + public PageResponse getSeriesContents(Long mediaId, int page, int size, Long memberId) { - seriesRepository.findByIdWithMedia(seriesId, Status.ACTIVE, PublicStatus.PUBLIC) + Series series = seriesRepository.findByMediaIdAndStatusAndPublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Long targetSeriesId = series.getId(); Pageable pageable = PageRequest.of(page, size); + Page contentsPage = contentsRepository - .findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc( - seriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); + .findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(targetSeriesId, Status.ACTIVE, PublicStatus.PUBLIC, pageable); - List contentsList = contentsPage.getContent().stream() - .map(SeriesContentsResponse::from).collect(Collectors.toList()); + List contentsList = contentsPage.getContent().stream().map(SeriesContentsResponse::from).collect(Collectors.toList()); PageInfo pageInfo = PageInfo.builder() .currentPage(contentsPage.getNumber()) diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index d1fa735..46be0ff 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -54,15 +54,27 @@ Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long ser @EntityGraph(attributePaths = {"media"}) Optional findByIdAndStatus(Long id, Status status); + // @Query(""" + // SELECT c FROM Contents c + // JOIN FETCH c.media m + // WHERE c.id = :contentsId + // AND c.status = :status + // AND m.publicStatus = :publicStatus + // """) + // Optional findByIdAndStatusAndMedia_PublicStatus( + // @Param("contentsId") Long contentsId, + // @Param("status") Status status, + // @Param("publicStatus") PublicStatus publicStatus); + @Query(""" SELECT c FROM Contents c JOIN FETCH c.media m - WHERE c.id = :contentsId + WHERE m.id = :mediaId AND c.status = :status AND m.publicStatus = :publicStatus """) - Optional findByIdAndStatusAndMedia_PublicStatus( - @Param("contentsId") Long contentsId, + Optional findByMediaIdAndStatusAndMedia_PublicStatus( + @Param("mediaId") Long mediaId, @Param("status") Status status, @Param("publicStatus") PublicStatus publicStatus); diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java index 20138c9..53587ea 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java @@ -16,8 +16,14 @@ public interface SeriesRepository extends JpaRepository, SeriesRep // Optional findByIdAndStatusAndMedia_PublicStatus(Long id, Status // status, PublicStatus publicStatus); - @Query("SELECT s FROM Series s JOIN FETCH s.media m WHERE s.id = :id AND s.status = :status AND m.publicStatus = :publicStatus") - Optional findByIdWithMedia(@Param("id") Long id, + // @Query("SELECT s FROM Series s JOIN FETCH s.media m WHERE s.id = :id AND s.status = :status AND m.publicStatus = :publicStatus") + // Optional findByIdWithMedia(@Param("id") Long id, + // @Param("status") Status status, + // @Param("publicStatus") PublicStatus publicStatus); + + @Query("SELECT s FROM Series s JOIN FETCH s.media m WHERE m.id = :mediaId AND s.status = :status AND m.publicStatus = :publicStatus") + Optional findByMediaIdAndStatusAndPublicStatus( + @Param("mediaId") Long mediaId, @Param("status") Status status, @Param("publicStatus") PublicStatus publicStatus); From ab3f069d138415872dc720986c47642740e7d16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 2 Mar 2026 11:14:34 +0900 Subject: [PATCH 200/257] [GIT]: Merge Develop --- .../auth/controller/AdminAuthApi.java | 38 +++++- .../auth/controller/AdminAuthController.java | 32 ++--- .../auth/dto/request/AdminLoginRequest.java | 4 +- .../auth/dto/response/AdminLoginResponse.java | 4 +- .../auth/dto/response/AdminTokenResponse.java | 5 + .../ott/api_admin/config/SecurityConfig.java | 40 ++++-- .../auth/controller/AuthController.java | 54 ++------ .../auth/oauth2/CustomOAuth2UserService.java | 2 +- .../oauth2/handler/OAuth2SuccessHandler.java | 32 ++--- .../auth/service/KakaoAuthService.java | 7 +- .../bookmark/controller/BookmarkAPI.java | 2 +- .../ott/api_user/config/SecurityConfig.java | 41 +++--- .../member/service/MemberService.java | 1 + .../transcoder/{transcode => ffmpeg}/.gitkeep | 0 .../transcoder/transcode/FfmpegExecutor.java | 29 +++++ .../ProcessBuilderFfmpegExecutor.java | 117 ++++++++++++++++++ .../java/com/ott/transcoder/validate/.gitkeep | 0 .../java/com/ott/transcoder/worker/.gitkeep | 0 .../ott/common/security/util/CookieUtil.java | 34 +++++ .../com/ott/domain/member/domain/Member.java | 8 ++ .../V3__member_onboarding_completed.sql | 6 + 21 files changed, 320 insertions(+), 136 deletions(-) rename apps/transcoder/src/main/java/com/ott/transcoder/{transcode => ffmpeg}/.gitkeep (100%) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep create mode 100644 modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java create mode 100644 modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java index 686b105..38d14fe 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java @@ -22,11 +22,24 @@ public interface AdminAuthApi { @Operation( summary = "관리자 로그인", - description = "이메일/비밀번호로 로그인합니다. " - + "Access Token과 Refresh Token은 HttpOnly 쿠키로 세팅됩니다." + description = """ + 이메일/비밀번호로 관리자 로그인을 수행합니다. + + - ADMIN or EDITOR 권한을 가진 계정만 로그인이 가능합니다. + - 응답 Body에는 memberId와 role만 포함됩니다. + """ + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = AdminLoginResponse.class))), + + @ApiResponse( + responseCode = "400", + description = "요청 값 유효성 검증 실패 (이메일 형식 오류, 필드 누락 등)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), @ApiResponse( responseCode = "401", description = "이메일 또는 비밀번호 불일치", @@ -44,7 +57,13 @@ ResponseEntity> login( @Operation( summary = "토큰 재발급", - description = "refreshToken 쿠키를 사용해 Access Token과 Refresh Token을 재발급합니다." + description = """ + 쿠키의 refreshToken을 검증하여 Access Token과 Refresh Token을 재발급합니다. + + - 요청 시 refreshToken 쿠키가 반드시 포함되어야 합니다. + - 보안을 위해 Access Token과 Refresh Token을 모두 재발급합니다. (Refresh Token Rotation) + - 재발급된 토큰은 기존 쿠키를 덮어씁니다. + """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "재발급 성공"), @@ -54,11 +73,18 @@ ResponseEntity> login( content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ) }) - ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + ResponseEntity reissue( + HttpServletRequest request, HttpServletResponse response); @Operation( summary = "로그아웃", - description = "DB의 refreshToken을 삭제하고 accessToken/refreshToken 쿠키를 제거합니다." + description = """ + 로그인된 관리자를 로그아웃 처리합니다. + + - DB에 저장된 refreshToken을 삭제합니다. + - accessToken, refreshToken 쿠키를 즉시 만료시킵니다. + - 이후 해당 토큰으로는 인증이 불가능합니다. + """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "로그아웃 성공"), diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java index 127aca8..2207929 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -4,6 +4,7 @@ import com.ott.api_admin.auth.dto.response.AdminLoginResponse; import com.ott.api_admin.auth.dto.response.AdminTokenResponse; import com.ott.api_admin.auth.service.AdminAuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.SuccessResponse; @@ -27,6 +28,7 @@ public class AdminAuthController implements AdminAuthApi { private final AdminAuthService adminAuthService; + private final CookieUtil cookie; @Value("${jwt.access-token-expiry}") private int accessTokenExpiry; @@ -43,8 +45,8 @@ public ResponseEntity> login( AdminLoginResponse loginResponse = adminAuthService.login(request); // 둘 다 쿠키로 - addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); + cookie.addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); // Body에는 memberId, role만 (토큰은 @JsonIgnore) return SuccessResponse.of(loginResponse).asHttp(HttpStatus.OK); @@ -63,8 +65,8 @@ public ResponseEntity reissue( AdminTokenResponse tokenResponse = adminAuthService.reissue(refreshToken); - addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + cookie.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); return ResponseEntity.noContent().build(); } @@ -78,8 +80,8 @@ public ResponseEntity logout( Long memberId = (Long) authentication.getPrincipal(); adminAuthService.logout(memberId); - deleteCookie(response, "accessToken"); - deleteCookie(response, "refreshToken"); + cookie.deleteCookie(response, "accessToken"); + cookie.deleteCookie(response, "refreshToken"); return ResponseEntity.noContent().build(); } @@ -93,22 +95,4 @@ private String extractCookie(HttpServletRequest request, String name) { } return null; } - - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); - } } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java index b840b35..a68bd06 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java @@ -13,10 +13,10 @@ public class AdminLoginRequest { @Email @NotBlank - @Schema(description = "관리자 이메일", example = "admin@ott.com") + @Schema(type= "String",description = "관리자 이메일", example = "admin@ott.com") private String email; @NotBlank - @Schema(description = "비밀번호", example = "password123") + @Schema(type="String", description = "비밀번호", example = "password123") private String password; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java index e0b6d70..413bb6e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java @@ -16,9 +16,9 @@ public class AdminLoginResponse { @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 private String refreshToken; - @Schema(description = "회원 ID", example = "1") + @Schema(type = "Long", description = "회원 ID", example = "1") private Long memberId; - @Schema(description = "회원 역할", example = "ADMIN") + @Schema(type= "String", description = "회원 역할", example = "ADMIN") private String role; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java index 4f20127..dda0cf4 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java @@ -1,5 +1,6 @@ package com.ott.api_admin.auth.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,6 +10,10 @@ @Getter @AllArgsConstructor public class AdminTokenResponse { + + @Schema(type = "String", description = "accessToken", example = "122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") private String accessToken; + + @Schema(type = "String", description = "refreshToken", example = "eym122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") private String refreshToken; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index c9dc2be..481a727 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -14,6 +14,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @RequiredArgsConstructor @@ -30,20 +35,25 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .cors(AbstractHttpConfigurer::disable) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> e - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - .accessDeniedHandler(jwtAccessDeniedHandler)) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + .authorizeHttpRequests(auth -> auth + // 인증 불필요 .requestMatchers( "/actuator/health/**", "/actuator/info", "/back-office/login", "/back-office/reissue", - "/back-office/swagger-ui/**", - "/back-office/v3/api-docs/**", - "/back-office/swagger-resources/**" + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") @@ -56,4 +66,20 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java index 598ba87..e65fe57 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -1,7 +1,9 @@ package com.ott.api_user.auth.controller; + import com.ott.api_user.auth.dto.TokenResponse; import com.ott.api_user.auth.service.AuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import jakarta.servlet.http.Cookie; @@ -11,17 +13,15 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController implements AuthApi { private final AuthService authService; + private final CookieUtil cookieUtil; @Value("${jwt.access-token-expiry}") private int accessTokenExpiry; @@ -44,8 +44,8 @@ public ResponseEntity reissue( TokenResponse tokenResponse = authService.reissue(refreshToken); // 쿠키에 저장 - addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + cookieUtil.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); return ResponseEntity.noContent().build(); } @@ -63,32 +63,13 @@ public ResponseEntity logout( Long memberId = (Long) authentication.getPrincipal(); authService.logout(memberId); - deleteCookie(response, "accessToken"); - deleteCookie(response, "refreshToken"); + cookieUtil.deleteCookie(response, "accessToken"); + cookieUtil.deleteCookie(response, "refreshToken"); return ResponseEntity.noContent().build(); } - // 임시 테스트 코드 -> 추후 프론트 페이지로 변경 예정 - @GetMapping("logincheck") - public ResponseEntity> logincheck( - @RequestParam(value = "isNewMember") boolean isNewMember, - HttpServletRequest request) { - String accessToken = extractCookie(request, "accessToken"); - String refreshToken = extractCookie(request, "refreshToken"); - - return ResponseEntity.ok(Map.of( - "isNewMember", isNewMember, - "accessToken", accessToken, - "refreshToken", refreshToken)); - } - // 인가 테스트용 코드 -> 이렇게 @AuthenticationPrincipal로 쓰시면 됩니다. - // 추후 memberId -> UserDetails로 리팩토링 예정 - @GetMapping("/me") - public Long me(@AuthenticationPrincipal Long memberId) { - return memberId; - } // 쿠키에 대한 접근은 HTTP고 서비스로 내려가면 안되기 때문에 Controller에서 구현 private String extractCookie(HttpServletRequest request, String name) { @@ -96,30 +77,11 @@ private String extractCookie(HttpServletRequest request, String name) { return null; } - for (Cookie cookie : request.getCookies()) { + for (Cookie cookie: request.getCookies()) { if (name.equals(cookie.getName())) { return cookie.getValue(); } } return null; } - - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 삭제 - response.addCookie(cookie); - } - } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java index 5f4a890..5391498 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java @@ -42,7 +42,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic Member member = kakaoAuthService.findOrCreateMember(kakaoUserInfo); // 신규 회원 판별 - boolean isNewMember = kakaoAuthService.isNewMember(member.getId()); + boolean isNewMember = kakaoAuthService.isNewMember(member); // attribute에 memberId(PK)와 신규 유저 유무를 적재 // payload memberId, isNewMember만 들어감 -> 민감한 정보 적재 x diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java index a4817e7..6444125 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -1,8 +1,8 @@ package com.ott.api_user.auth.oauth2.handler; import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.security.jwt.JwtTokenProvider; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,7 +12,6 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; import java.util.List; @@ -31,6 +30,7 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JwtTokenProvider jwtTokenProvider; private final KakaoAuthService kakaoAuthService; + private final CookieUtil cookieUtil; @Value("${app.frontend-url}") private String frontedUrl; @@ -69,31 +69,17 @@ public void onAuthenticationSuccess( kakaoAuthService.saveRefreshToken(memberId, refreshToken); // 쿠키로 저장 - addCookie(response, "accessToken", accessToken, accessTokenExpiry); - addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); + cookieUtil.addCookie(response, "accessToken", accessToken, accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); - // 리다이렉트에는 쿼리 파라미터로 isNewMember만 전달 - String redirectUri = request.getParameter("redirect_uri"); - if (redirectUri == null || redirectUri.isBlank()) { - redirectUri = frontedUrl + "/auth/logincheck"; // 배포 후 변경 예정 - } - - String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) - .queryParam("isNewMember", isNewMember) - .build() - .toUriString(); + // 리다이렉트에는 isNewMember에 따라서 경로 변경 + String targetUrl = isNewMember + ? frontedUrl + "/auth/userinfo" + : frontedUrl + "/"; getRedirectStrategy().sendRedirect(request, response, targetUrl); } - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - cookie.setSecure(false); // 배포 서버에서는 true로 변경 - cookie.setPath("/"); // 모든 경로에서 전송 - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java index 38daac7..e9b3c36 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -24,7 +24,6 @@ public class KakaoAuthService { private final MemberRepository memberRepository; - private final PreferredTagRepository preferredTagRepository; // 카카오 사용자 정보로 회원 조회 or 신규 생성 // 기존 회원일 경우 프로필 동기화 필요 @@ -44,9 +43,9 @@ public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { kakaoUserInfo.getNickname()))); } - // 신규 회원 판별 -> 태그 소유 유무로 판단 - public boolean isNewMember(Long memberId) { - return !preferredTagRepository.existsByMemberId(memberId); + // 신규 회원 판별 -> 컬럼으로 판별 + public boolean isNewMember(Member member) { + return !member.isOnboardingCompleted(); } // refresh token 저장 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java index fc46e4d..b6bb4b9 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; -@Tag(name = "Bookmark API", description = "북마크 관련 API입니다.") +@Tag(name = "Bookmark API", description = "북마크 API") public interface BookmarkAPI { @Operation(summary = "북마크 수정", description = "미디어에 대한 북마크를 수정합니다.") @ApiResponses(value = { diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java index 8bc5552..2373df7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -44,13 +44,11 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) // csrf 비활성화, Authorization 헤더로 보냄 - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - -// .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .cors(AbstractHttpConfigurer::disable) - - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증 + .formLogin(AbstractHttpConfigurer::disable) // 카카오 OAtuh2 + JWT기반이라 기본 로그인 폼 안씀 + .httpBasic(AbstractHttpConfigurer::disable) // 카카오 OAtuh2 + JWT기반이라 Basic 인증 안씀 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증이라 세션 유지 x .exceptionHandling(e -> e .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 @@ -61,18 +59,18 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health/**", "/actuator/info", - "/auth/**", "/oauth2/**", "/login/oauth2/**", "/auth/reissue", - "/auth/logout", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" - ).permitAll() + ).permitAll() - // 나머지 url에 대해서는 인증 필요 - .anyRequest().authenticated() + /* + 역할이 MEMBER인 유저만 그 외 EndPoint 접근 가능하도록 설정 + */ + .anyRequest().hasRole("MEMBER") ) // OAuth2 카카오 로그인 @@ -83,7 +81,9 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .failureHandler(oAuth2FailureHandler) ) - // UsernamePasswordAuthenticationFilter 보다 먼저 실행 + // Spring Security보다 먼저 실행 + // 쿠키에서 AccessToken을 꺼내와서 검증 이후 SecurityContext에 인증 정보 박제 + // 해당 과정에서 memberId, ROLE을 context에 넣어줌 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); @@ -93,19 +93,20 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - // allowedOrigins -> 허용할 Origin 내역 + // allowedOrigins -> 허용할 도메인 내역 // allowCredentials -> 브라우저가 요청에 인증정보를 포함하는 것을 허용하겠냐 // credentials가 true일 경우 Allow-origin의 경우 구체적인 경로를 명시해야됨 - config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); -// config.setAllowedOrigins(List.of(frontedUrl)); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); + config.setAllowedHeaders(List.of("*")); // 모든 헤더 다 받는데 우리 서비스에서는 안씀 + config.setAllowCredentials(true); // 쿠키 요청을 포함 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); + source.registerCorsConfiguration("/**", config); // 위 설정을 모든 경로에 적용 return source; } -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index f35df85..bb48225 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -110,6 +110,7 @@ public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { .toList(); preferredTagRepository.saveAll(preferredTags); + findMember.completeOnboarding(); } } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep similarity index 100% rename from apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java new file mode 100644 index 0000000..1178e76 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java @@ -0,0 +1,29 @@ +package com.ott.transcoder.transcode; + +import com.ott.domain.video_profile.domain.Resolution; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg 실행 추상화 인터페이스 + * + * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 + * 단일 해상도에 대한 HLS 트랜스코딩을 수행한다. + * + * 구현체 전환: transcoder.ffmpeg.engine 프로퍼티로 선택 + * - processbuilder: ProcessBuilderFfmpegExecutor (CLI 직접 호출) + * - jaffree: (향후) JaffreeFfmpegExecutor (라이브러리 호출) + */ +public interface FfmpegExecutor { + + /** + * 단일 해상도에 대해 HLS 트랜스코딩을 수행한다. + * + * @param inputFile 원본 영상 파일 경로 + * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) + * @param resolution 대상 해상도 + * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 + */ + Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 0000000..4ef1dc5 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,117 @@ +package com.ott.transcoder.transcode.processbuilder; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.transcode.FfmpegExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * ProcessBuilder 기반 FFmpeg CLI 래퍼 + * + * 시스템에 설치된 FFmpeg 바이너리를 ProcessBuilder로 직접 호출 + * 단일 해상도에 대해 HLS 트랜스코딩을 수행하며, + * 결과물로 media.m3u8 (미디어 플레이리스트) + segment_XXX.ts (세그먼트 파일)를 생성 + * + * FFmpeg 내부 처리 흐름: + * Demux(컨테이너 분리) → Decode(디코딩) → Filter(스케일링) → Encode(재인코딩) → Mux(HLS 패키징) + * 이 전체가 하나의 FFmpeg 명령어로 실행됨 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + /** 해상도별 출력 높이 (너비는 -2로 자동 계산, 짝수 보장) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** 해상도별 비디오 비트레이트 */ + private static final Map VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, "800k", + Resolution.P720, "2400k", + Resolution.P1080, "4800k" + ); + + /** 해상도별 오디오 비트레이트 */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + /** HLS 세그먼트 하나의 길이 (초) */ + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException { + int height = HEIGHT_MAP.get(resolution); + String videoBitrate = VIDEO_BITRATE_MAP.get(resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + + // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolution.getKey().toLowerCase()); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg 명령어 조립 + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + height, + "-c:v", "libx264", "-preset", "fast", + "-c:a", "aac", "-b:a", audioBitrate, + "-b:v", videoBitrate, + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg 실행 - resolution: {}, command: {}", resolution.getKey(), String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + // FFmpeg 출력을 읽어야 프로세스가 블로킹되지 않는다 (버퍼 가득 참 방지) + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolution.getKey()); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); + } + + log.info("FFmpeg 완료 - resolution: {}, output: {}", resolution.getKey(), playlistPath); + return playlistPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java new file mode 100644 index 0000000..8802ab0 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java @@ -0,0 +1,34 @@ +package com.ott.common.security.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 + .secure(false) // HTTPS 요청만 허용 + .path("/") // 모든 경로로 전송 + .maxAge(maxAge) + .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 08b759c..62d0c80 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -50,6 +50,9 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + @Column(name = "onboarding_completed", nullable = false) + private boolean onboardingCompleted; + public static Member createKakaoMember(String providerId, String email, String nickname) { return Member.builder() .provider(Provider.KAKAO) @@ -84,4 +87,9 @@ public void changeRole(Role targetRole) { public void updateNickname(String nickname) { this.nickname = nickname; } + + // 온보딩 여부 + public void completeOnboarding() { + this.onboardingCompleted = true; + } } diff --git a/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql new file mode 100644 index 0000000..a062f21 --- /dev/null +++ b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql @@ -0,0 +1,6 @@ +ALTER TABLE member + ADD COLUMN onboarding_completed BOOLEAN NOT NULL DEFAULT FALSE; + +-- Existing users should not be forced back into onboarding after deployment. +UPDATE member +SET onboarding_completed = TRUE; From 58617b690811659eb7fef117818b050b2aa08113 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 15:06:16 +0900 Subject: [PATCH 201/257] =?UTF-8?q?[FEAT]:=20=EC=8B=9C=EC=B2=AD=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EA=B8=B0=EB=B0=98=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 14 ++++- .../dto/response/TagRankingResponse.java | 55 +++++++++++++++++++ .../member/service/MemberService.java | 53 +++++++++++++++++- .../repository/TagRankingProjection.java | 17 ++++++ .../WatchHistoryRepositoryCustom.java | 3 + .../WatchHistoryRepositoryImpl.java | 30 ++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 66a3be8..8cc2f44 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -3,11 +3,11 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.TagRankingResponse; import com.ott.api_user.member.service.MemberService; import com.ott.common.web.response.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -19,12 +19,14 @@ public class MemberController implements MemberApi { private final MemberService memberService; + @Override @GetMapping("/me") public ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId) { MyPageResponse response = memberService.getMyPage(memberId); return ResponseEntity.ok(SuccessResponse.of(response)); } + @Override @PatchMapping("/me") public ResponseEntity> updateMyInfo( @AuthenticationPrincipal Long memberId, @@ -34,6 +36,7 @@ public ResponseEntity> updateMyInfo( } + @Override @PostMapping("/me/tags") public ResponseEntity> setPreferredTags( @AuthenticationPrincipal Long memberId, @@ -42,4 +45,13 @@ public ResponseEntity> setPreferredTags( memberService.setPreferredTags(memberId, request); return ResponseEntity.ok(SuccessResponse.of(null)); } + + // 유저 별 1달 간 상위 태그 조회 + @Override + @GetMapping("/me/tag/ranking") + public ResponseEntity> getTagRanking( + @AuthenticationPrincipal Long memberId + ) { + return ResponseEntity.ok(SuccessResponse.of(memberService.getTagRanking(memberId))); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java new file mode 100644 index 0000000..dfcf378 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java @@ -0,0 +1,55 @@ +package com.ott.api_user.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "시청이력 기반 태그 랭킹 응답 DTO") +public class TagRankingResponse { + + @Schema(description = "태그 랭킹 목록 (상위 4개 + 기타 1개, 최대 5개)") + private List rankings; + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "태그 랭킹 아이템") + public static class TagRankItem { + + @Schema(type = "Long", example = "3", description = "태그 ID (기타 항목은 null)") + private Long tagId; + + @Schema(type = "String", example = "스릴러", description = "태그명 (기타 항목은 '기타')") + private String tagName; + + @Schema(type = "Long", example = "12", description = "시청 횟수") + private Long count; + + @Schema(type = "boolean", example = "false", description = "기타 항목 여부") + private boolean isEtc; + + public static TagRankItem of(Long tagId, String tagName, Long count) { + return TagRankItem.builder() + .tagId(tagId) + .tagName(tagName) + .count(count) + .isEtc(false) + .build(); + } + + public static TagRankItem ofEtc(Long totalCount) { + return TagRankItem.builder() + .tagId(null) + .tagName("기타") + .count(totalCount) + .isEtc(true) + .build(); + } + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index bb48225..6f86d54 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -3,6 +3,8 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.TagRankingResponse; +import com.ott.api_user.member.dto.response.TagRankingResponse.TagRankItem; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.domain.common.Status; @@ -12,10 +14,14 @@ import com.ott.domain.preferred_tag.repository.PreferredTagRepository; import com.ott.domain.tag.domain.Tag; import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.TagRankingProjection; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Service @@ -26,6 +32,7 @@ public class MemberService { private final MemberRepository memberRepository; private final PreferredTagRepository preferredTagRepository; private final TagRepository tagRepository; + private final WatchHistoryRepository watchHistoryRepository; /** * 마이 페이지 조회 : 닉네임, 선호태그 List 반환 @@ -87,7 +94,7 @@ public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { } /** - * 온보딩 화면 : 초기 1회만 노출되며 + * 온보딩 화면 : 초기 1회만 노출됨 */ @Transactional public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { @@ -113,4 +120,48 @@ public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { findMember.completeOnboarding(); } + /** + * 마이페이지 - 시청이력 기반 상위 태그 랭킹 조회 1달 + * - 상위 4개: 개별 태그 항목 + * - 나머지: count 합산하여 기타 항목으로 반환 + */ + @Transactional(readOnly = true) + public TagRankingResponse getTagRanking(Long memberId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 집계일과 마감일 선정 1일~말일까지 + LocalDateTime endDate = LocalDateTime.now(); + LocalDateTime startDate = endDate.minusMonths(1); + + List tagRankingProjections = + watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween(memberId, startDate, endDate); + + List rankItems = new ArrayList<>(); + + // 시청이력이 없을 경우 빈 리스트가 전달됨 + if (tagRankingProjections.isEmpty()) { + return TagRankingResponse.builder().rankings(rankItems).build(); + } + + int topN = Math.min(4, tagRankingProjections.size()); + + // 상위 4개 추가 + for (int i = 0; i < topN; i++) { + TagRankingProjection projection = tagRankingProjections.get(i); + rankItems.add(TagRankItem.of(projection.getTagId(), projection.getTagName(), projection.getCount())); + } + + // 나머지 → 기타로 합산 + if (tagRankingProjections.size() > 4) { + long etcCount = tagRankingProjections.subList(4, tagRankingProjections.size()) + .stream() + .mapToLong(TagRankingProjection::getCount) + .sum(); + rankItems.add(TagRankItem.ofEtc(etcCount)); + } + + return TagRankingResponse.builder().rankings(rankItems).build(); } + +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java new file mode 100644 index 0000000..99a9513 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java @@ -0,0 +1,17 @@ +package com.ott.domain.watch_history.repository; + +import lombok.Getter; + +@Getter +public class TagRankingProjection { + + private final Long tagId; + private final String tagName; + private final Long count; + + public TagRankingProjection(Long tagId, String tagName, Long count) { + this.tagId = tagId; + this.tagName = tagName; + this.count = count; + } +} diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java index 25a1749..f6dae41 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java @@ -6,4 +6,7 @@ public interface WatchHistoryRepositoryCustom { List countByTagAndCategoryIdAndWatchedBetween(Long categoryId, LocalDateTime startDate, LocalDateTime endDate); + + //특정 회원의 최근 1달 시청이력 기반 태그 집계 (count 내림차순) + List findTopTagsByMemberIdAndWatchedBetween(Long memberId, LocalDateTime startDate, LocalDateTime endDate); } diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java index 4192abd..6b9ff95 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -7,6 +7,7 @@ import java.time.LocalDateTime; import java.util.List; +import static com.ott.domain.common.Status.ACTIVE; import static com.ott.domain.contents.domain.QContents.contents; import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; import static com.ott.domain.tag.domain.QTag.tag; @@ -36,4 +37,33 @@ public List countByTagAndCategoryIdAndWatchedBetween(Lon .groupBy(tag.id, tag.name) .fetch(); } + + // 특정 회원의 1달 시청이력 기반 태그 집계 + @Override + public List findTopTagsByMemberIdAndWatchedBetween( + Long memberId, + LocalDateTime startDate, + LocalDateTime endDate + ) { + return queryFactory + .select(Projections.constructor(TagRankingProjection.class, + tag.id, + tag.name, + watchHistory.count() + )) + .from(watchHistory) + .join(contents).on(watchHistory.contents.id.eq(contents.id)) + .join(mediaTag).on(mediaTag.media.id.eq(contents.media.id) + .and(mediaTag.status.eq(ACTIVE))) + .join(tag).on(tag.id.eq(mediaTag.tag.id) + .and(tag.status.eq(ACTIVE))) + .where( + watchHistory.member.id.eq(memberId), + watchHistory.lastWatchedAt.goe(startDate), + watchHistory.lastWatchedAt.lt(endDate) + ) + .groupBy(tag.id, tag.name) + .orderBy(watchHistory.count().desc()) + .fetch(); + } } From 601a81174817e3cdd7989278fe2857ebbf7fef73 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 15:50:05 +0900 Subject: [PATCH 202/257] =?UTF-8?q?[FEAT]:=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B3=84=20=EA=B8=88=EC=9B=94,=20=EC=A0=84=EC=9B=94count=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 12 ++++++ .../response/TagMonthlyCompareResponse.java | 38 ++++++++++++++++ .../member/service/MemberService.java | 43 +++++++++++++++++++ .../WatchHistoryRepositoryCustom.java | 3 ++ .../WatchHistoryRepositoryImpl.java | 28 ++++++++++++ 5 files changed, 124 insertions(+) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 8cc2f44..014f802 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -3,10 +3,12 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse; import com.ott.api_user.member.dto.response.TagRankingResponse; import com.ott.api_user.member.service.MemberService; import com.ott.common.web.response.SuccessResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -54,4 +56,14 @@ public ResponseEntity> getTagRanking( ) { return ResponseEntity.ok(SuccessResponse.of(memberService.getTagRanking(memberId))); } + + // 유저 별 2달 간 특정 태그 조회 + @Override + @GetMapping("/me/tag/ranking/{tagId}") + public ResponseEntity> getTagMonthlyCompare( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ) { + return ResponseEntity.ok(SuccessResponse.of(memberService.getTagMonthlyCompare(memberId, tagId))); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java new file mode 100644 index 0000000..60aa945 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java @@ -0,0 +1,38 @@ +package com.ott.api_user.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "태그 월별 시청 count 비교 응답 DTO") +public class TagMonthlyCompareResponse { + + @Schema(type = "Long", example = "3", description = "태그 ID") + private Long tagId; + + @Schema(type = "String", example = "스릴러", description = "태그명") + private String tagName; + + @Schema(description = "이번 달 시청 count") + private MonthlyCount currentMonth; + + @Schema(description = "저번 달 시청 count") + private MonthlyCount previousMonth; + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "월별 시청 count 아이템") + public static class MonthlyCount { + + @Schema(type = "String", example = "2026-03", description = "연월 (yyyy-MM)") + private String yearMonth; + + @Schema(type = "Long", example = "12", description = "시청 횟수") + private Long count; + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 6f86d54..8068ece 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -3,6 +3,8 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse.MonthlyCount; import com.ott.api_user.member.dto.response.TagRankingResponse; import com.ott.api_user.member.dto.response.TagRankingResponse.TagRankItem; import com.ott.common.web.exception.BusinessException; @@ -21,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -164,4 +168,43 @@ public TagRankingResponse getTagRanking(Long memberId) { return TagRankingResponse.builder().rankings(rankItems).build(); } + + /** + * 마이페이지 - 특정 태그의 이번 달 vs 저번 달 시청 count 비교 + */ + public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Tag findTag = tagRepository.findById(tagId) + .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); + + // 이번 달 범위 + YearMonth currentYearMonth = YearMonth.now(); + LocalDateTime currentStart = currentYearMonth.atDay(1).atStartOfDay(); + LocalDateTime currentEnd = currentYearMonth.atEndOfMonth().atTime(23, 59, 59); + + // 저번 달 범위 + YearMonth prevYearMonth = currentYearMonth.minusMonths(1); + LocalDateTime prevStart = prevYearMonth.atDay(1).atStartOfDay(); + LocalDateTime prevEnd = prevYearMonth.atEndOfMonth().atTime(23, 59, 59); + + Long currentCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, currentStart, currentEnd); + Long previousCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, prevStart, prevEnd); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); + + return TagMonthlyCompareResponse.builder() + .tagId(findTag.getId()) + .tagName(findTag.getName()) + .currentMonth(MonthlyCount.builder() + .yearMonth(currentYearMonth.format(formatter)) + .count(currentCount) + .build()) + .previousMonth(MonthlyCount.builder() + .yearMonth(prevYearMonth.format(formatter)) + .count(previousCount) + .build()) + .build(); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java index f6dae41..4b25bb7 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java @@ -9,4 +9,7 @@ public interface WatchHistoryRepositoryCustom { //특정 회원의 최근 1달 시청이력 기반 태그 집계 (count 내림차순) List findTopTagsByMemberIdAndWatchedBetween(Long memberId, LocalDateTime startDate, LocalDateTime endDate); + + // 특정 태그의 2달 시청이력 기반 count 집계 + Long countByMemberIdAndTagIdAndWatchedBetween(Long memberId, Long tagId, LocalDateTime startDate, LocalDateTime endDate); } diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java index 6b9ff95..3378807 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -66,4 +66,32 @@ public List findTopTagsByMemberIdAndWatchedBetween( .orderBy(watchHistory.count().desc()) .fetch(); } + + // 특정 회원의 특정 태그에 대한 기간 내 시청 count + @Override + public Long countByMemberIdAndTagIdAndWatchedBetween( + Long memberId, + Long tagId, + LocalDateTime startDate, + LocalDateTime endDate + ) { + Long result = queryFactory + .select(watchHistory.count()) + .from(watchHistory) + .join(contents).on(watchHistory.contents.id.eq(contents.id)) + .join(mediaTag).on(mediaTag.media.id.eq(contents.media.id) + .and(mediaTag.status.eq(ACTIVE))) + .join(tag).on(tag.id.eq(mediaTag.tag.id) + .and(tag.status.eq(ACTIVE))) + .where( + watchHistory.member.id.eq(memberId), + tag.id.eq(tagId), + watchHistory.lastWatchedAt.goe(startDate), + watchHistory.lastWatchedAt.lt(endDate) + ) + .fetchOne(); + + return result != null ? result : 0L; + } + } From 88a0969950f056359cc0264f955e00e8df6c505a Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 16:28:11 +0900 Subject: [PATCH 203/257] =?UTF-8?q?[FEAT]:=20=EC=A0=84=EC=9B=94=20?= =?UTF-8?q?=EC=8B=9C=EC=B2=AD=20=EC=9D=B4=EB=A0=A5=20=EC=97=86=EC=9D=84=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20null=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/member/service/MemberService.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 8068ece..acdfe71 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -194,6 +194,14 @@ public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); + // 저번 달 시청 기록이 없으면 null + MonthlyCount previousMonth = previousCount > 0 + ? MonthlyCount.builder() + .yearMonth(prevYearMonth.format(formatter)) + .count(previousCount) + .build() + : null; + return TagMonthlyCompareResponse.builder() .tagId(findTag.getId()) .tagName(findTag.getName()) @@ -201,10 +209,7 @@ public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) .yearMonth(currentYearMonth.format(formatter)) .count(currentCount) .build()) - .previousMonth(MonthlyCount.builder() - .yearMonth(prevYearMonth.format(formatter)) - .count(previousCount) - .build()) + .previousMonth(previousMonth) .build(); } } From ee4b402d902159b05e3ba3018a4c5e20c4bc6833 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 16:56:16 +0900 Subject: [PATCH 204/257] =?UTF-8?q?[FEAT]:=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B3=84=20=EC=B6=94=EC=B2=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 12 ++++++ .../dto/response/TagContentResponse.java | 27 +++++++++++++ .../member/service/MemberService.java | 17 ++++++++ .../repository/MediaRepositoryCustom.java | 4 ++ .../media/repository/MediaRepositoryImpl.java | 40 ++++++++++++++++++- .../repository/TagContentProjection.java | 15 +++++++ 6 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 014f802..b588d0a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -3,6 +3,7 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.TagContentResponse; import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse; import com.ott.api_user.member.dto.response.TagRankingResponse; import com.ott.api_user.member.service.MemberService; @@ -14,6 +15,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/member") @RequiredArgsConstructor @@ -66,4 +69,13 @@ public ResponseEntity> getTagMonthlyC ) { return ResponseEntity.ok(SuccessResponse.of(memberService.getTagMonthlyCompare(memberId, tagId))); } + + // 태그 별 추천 리스트 조회 + @GetMapping("/me/taglist/{tagId}") + public ResponseEntity>> getRecommendContentsByTag( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ) { + return ResponseEntity.ok(SuccessResponse.of(memberService.getRecommendContentsByTag(memberId, tagId))); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java new file mode 100644 index 0000000..fc44c35 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java @@ -0,0 +1,27 @@ +package com.ott.api_user.member.dto.response; + +import com.ott.domain.media.repository.TagContentProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "태그별 추천 콘텐츠 아이템") +public class TagContentResponse { + + @Schema(type = "Long", example = "5", description = "미디어 ID") + private Long mediaId; + + @Schema(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "포스터 URL") + private String posterUrl; + + public static TagContentResponse from(TagContentProjection projection) { + return TagContentResponse.builder() + .mediaId(projection.getMediaId()) + .posterUrl(projection.getPosterUrl()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index acdfe71..b281667 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -3,6 +3,7 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.TagContentResponse; import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse; import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse.MonthlyCount; import com.ott.api_user.member.dto.response.TagRankingResponse; @@ -10,6 +11,7 @@ import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.domain.common.Status; +import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.member.domain.Member; import com.ott.domain.member.repository.MemberRepository; import com.ott.domain.preferred_tag.domain.PreferredTag; @@ -37,6 +39,7 @@ public class MemberService { private final PreferredTagRepository preferredTagRepository; private final TagRepository tagRepository; private final WatchHistoryRepository watchHistoryRepository; + private final MediaRepository mediaRepository; /** * 마이 페이지 조회 : 닉네임, 선호태그 List 반환 @@ -212,4 +215,18 @@ public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) .previousMonth(previousMonth) .build(); } + + // 마이페이지에서의 추천 태그 별 추천 콘텐츠 제공 + public List getRecommendContentsByTag(Long memberId, Long tagId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + tagRepository.findById(tagId) + .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); + + return mediaRepository.findRecommendContentsByTagId(tagId, 20) + .stream() + .map(TagContentResponse::from) + .toList(); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index be6bab8..71f65b5 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; + public interface MediaRepositoryCustom { Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); @@ -17,4 +19,6 @@ Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pa MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); + + List findRecommendContentsByTagId(Long tagId, int limit); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index abf9963..294a38b 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -3,6 +3,7 @@ import com.ott.domain.common.MediaType; import com.ott.domain.common.PublicStatus; import com.ott.domain.media.domain.Media; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -16,8 +17,13 @@ import com.querydsl.jpa.JPAExpressions; +import static com.ott.domain.common.MediaType.CONTENTS; +import static com.ott.domain.common.MediaType.SERIES; +import static com.ott.domain.common.PublicStatus.PUBLIC; +import static com.ott.domain.common.Status.ACTIVE; import static com.ott.domain.contents.domain.QContents.contents; import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; @RequiredArgsConstructor public class MediaRepositoryImpl implements MediaRepositoryCustom { @@ -101,7 +107,7 @@ public Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUpload @Override public Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord) { - BooleanExpression condition = media.mediaType.in(List.of(MediaType.SERIES, MediaType.CONTENTS)) + BooleanExpression condition = media.mediaType.in(List.of(SERIES, CONTENTS)) .and( JPAExpressions.selectOne() .from(contents) @@ -128,6 +134,38 @@ public Page findOriginMediaListBySearchWord(Pageable pageable, String sea return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); } + /** + * 특정 태그에 속하는 추천 콘텐츠 조회 + */ + @Override + public List findRecommendContentsByTagId(Long tagId, int limit) { + return queryFactory + .select(Projections.constructor(TagContentProjection.class, + media.id, + media.posterUrl + )) + .from(media) + .join(mediaTag).on( + mediaTag.media.id.eq(media.id), + mediaTag.tag.id.eq(tagId), + mediaTag.status.eq(ACTIVE) + ) + .leftJoin(contents).on( + contents.media.id.eq(media.id), + contents.series.isNull() + ) + .where( + media.status.eq(ACTIVE), + media.publicStatus.eq(PUBLIC), + // 시리즈 자체 OR 단편 콘텐츠 (시리즈 에피소드 제외) + media.mediaType.eq(SERIES) + .or(media.mediaType.eq(CONTENTS).and(contents.id.isNotNull())) + ) + .orderBy(media.bookmarkCount.desc()) // 북마크 많은 순 정렬 + .limit(limit) + .fetch(); + } + private BooleanExpression titleContains(String searchWord) { if (StringUtils.hasText(searchWord)) return media.title.contains(searchWord); diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java b/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java new file mode 100644 index 0000000..3667511 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java @@ -0,0 +1,15 @@ +package com.ott.domain.media.repository; + +import lombok.Getter; + +@Getter +public class TagContentProjection { + + private final Long mediaId; + private final String posterUrl; + + public TagContentProjection(Long mediaId, String posterUrl) { + this.mediaId = mediaId; + this.posterUrl = posterUrl; + } +} \ No newline at end of file From 3a94668e146b446ce91280a396281d25a869a407 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 17:30:12 +0900 Subject: [PATCH 205/257] =?UTF-8?q?[FEAT]:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C,=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EA=B0=92=EC=97=90=20Type=20=EC=B6=94=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/member/dto/response/TagContentResponse.java | 5 +++++ .../com/ott/domain/media/repository/MediaRepositoryImpl.java | 3 ++- .../ott/domain/media/repository/TagContentProjection.java | 5 ++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java index fc44c35..57ab1b4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java @@ -1,5 +1,6 @@ package com.ott.api_user.member.dto.response; +import com.ott.domain.common.MediaType; import com.ott.domain.media.repository.TagContentProjection; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -18,10 +19,14 @@ public class TagContentResponse { @Schema(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "포스터 URL") private String posterUrl; + @Schema(type = "String", example = "SERIES", description = "미디어 타입 (SERIES / CONTENTS)") + private MediaType mediaType; + public static TagContentResponse from(TagContentProjection projection) { return TagContentResponse.builder() .mediaId(projection.getMediaId()) .posterUrl(projection.getPosterUrl()) + .mediaType(projection.getMediaType()) .build(); } } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 294a38b..d9ee1bf 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -142,7 +142,8 @@ public List findRecommendContentsByTagId(Long tagId, int l return queryFactory .select(Projections.constructor(TagContentProjection.class, media.id, - media.posterUrl + media.posterUrl, + media.mediaType )) .from(media) .join(mediaTag).on( diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java b/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java index 3667511..59ccde9 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java @@ -1,5 +1,6 @@ package com.ott.domain.media.repository; +import com.ott.domain.common.MediaType; import lombok.Getter; @Getter @@ -7,9 +8,11 @@ public class TagContentProjection { private final Long mediaId; private final String posterUrl; + private final MediaType mediaType; - public TagContentProjection(Long mediaId, String posterUrl) { + public TagContentProjection(Long mediaId, String posterUrl, MediaType mediaType) { this.mediaId = mediaId; this.posterUrl = posterUrl; + this.mediaType = mediaType; } } \ No newline at end of file From b9a2bcfa7e67c3695d3af80d53745af9f99d97dd Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 19:19:42 +0900 Subject: [PATCH 206/257] =?UTF-8?q?[FEAT]:=20=EA=B3=BC=EA=B1=B0=20?= =?UTF-8?q?=EC=8B=9C=EC=B2=AD=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 18 +++++++-- .../dto/response/RecentWatchResponse.java | 35 ++++++++++++++++ .../member/service/MemberService.java | 40 ++++++++++++++++--- .../repository/RecentWatchProjection.java | 19 +++++++++ .../WatchHistoryRepositoryCustom.java | 6 +++ .../WatchHistoryRepositoryImpl.java | 37 +++++++++++++++++ 6 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java create mode 100644 modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index b588d0a..3e6fa99 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -2,14 +2,13 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; -import com.ott.api_user.member.dto.response.MyPageResponse; -import com.ott.api_user.member.dto.response.TagContentResponse; -import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse; -import com.ott.api_user.member.dto.response.TagRankingResponse; +import com.ott.api_user.member.dto.response.*; import com.ott.api_user.member.service.MemberService; +import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -71,6 +70,7 @@ public ResponseEntity> getTagMonthlyC } // 태그 별 추천 리스트 조회 + @Override @GetMapping("/me/taglist/{tagId}") public ResponseEntity>> getRecommendContentsByTag( @AuthenticationPrincipal Long memberId, @@ -78,4 +78,14 @@ public ResponseEntity>> getRecommendCon ) { return ResponseEntity.ok(SuccessResponse.of(memberService.getRecommendContentsByTag(memberId, tagId))); } + + // 과거 시청 이력 조회, 10개씩 조회 + @Override + @GetMapping("/me/history/playlist") + public ResponseEntity>> getWatchHistoryPlaylist( + @AuthenticationPrincipal Long memberId, + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page + ) { + return ResponseEntity.ok(SuccessResponse.of(memberService.getWatchHistoryPlaylist(memberId, page))); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java new file mode 100644 index 0000000..3f2a762 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java @@ -0,0 +1,35 @@ +package com.ott.api_user.member.dto.response; + +import com.ott.domain.watch_history.repository.RecentWatchProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "시청이력 플레이리스트 DTO") +public class RecentWatchResponse { + + @Schema(type = "Long", example = "3", description = "콘텐츠 ID (콘텐츠 상세 조회용)") + private Long contentsId; + + @Schema(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "포스터 URL") + private String posterUrl; + + @Schema(type = "Integer", example = "150", description = "이어보기 시점 (초), 없으면 0") + private Integer positionSec; + + @Schema(type = "Integer", example = "3600", description = "전체 재생 시간 (초)") + private Integer duration; + + public static RecentWatchResponse from(RecentWatchProjection projection) { + return RecentWatchResponse.builder() + .contentsId(projection.getContentsId()) + .posterUrl(projection.getPosterUrl()) + .positionSec(projection.getPositionSec()) + .duration(projection.getDuration()) + .build(); + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index b281667..80945d4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -2,14 +2,13 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; -import com.ott.api_user.member.dto.response.MyPageResponse; -import com.ott.api_user.member.dto.response.TagContentResponse; -import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.member.dto.response.*; import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse.MonthlyCount; -import com.ott.api_user.member.dto.response.TagRankingResponse; import com.ott.api_user.member.dto.response.TagRankingResponse.TagRankItem; 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.Status; import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.member.domain.Member; @@ -18,9 +17,13 @@ import com.ott.domain.preferred_tag.repository.PreferredTagRepository; import com.ott.domain.tag.domain.Tag; import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.RecentWatchProjection; import com.ott.domain.watch_history.repository.TagRankingProjection; import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -175,6 +178,7 @@ public TagRankingResponse getTagRanking(Long memberId) { /** * 마이페이지 - 특정 태그의 이번 달 vs 저번 달 시청 count 비교 */ + @Transactional(readOnly = true) public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) { memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); @@ -216,7 +220,8 @@ public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) .build(); } - // 마이페이지에서의 추천 태그 별 추천 콘텐츠 제공 + // 태그별 추천 콘텐츠 목록 조회 (최대 20개) + @Transactional(readOnly = true) public List getRecommendContentsByTag(Long memberId, Long tagId) { memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); @@ -229,4 +234,29 @@ public List getRecommendContentsByTag(Long memberId, Long ta .map(TagContentResponse::from) .toList(); } + + // 전체 시청이력 플레이리스트 페이징 조회 (최신순, 10개씩) + @Transactional(readOnly = true) + public PageResponse getWatchHistoryPlaylist(Long memberId, Integer page) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + PageRequest pageable = PageRequest.of(page, 10); + + Page watchPage = + watchHistoryRepository.findWatchHistoryByMemberId(memberId, pageable); + + List dataList = watchPage.getContent() + .stream() + .map(RecentWatchResponse::from) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + watchPage.getNumber(), + watchPage.getTotalPages(), + watchPage.getSize() + ); + + return PageResponse.toPageResponse(pageInfo, dataList); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java new file mode 100644 index 0000000..582e7d6 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java @@ -0,0 +1,19 @@ +package com.ott.domain.watch_history.repository; + +import lombok.Getter; + +@Getter +public class RecentWatchProjection { + + private final Long contentsId; + private final String posterUrl; + private final Integer positionSec; + private final Integer duration; + + public RecentWatchProjection(Long contentsId, String posterUrl, Integer positionSec, Integer duration) { + this.contentsId = contentsId; + this.posterUrl = posterUrl; + this.positionSec = positionSec; + this.duration = duration; + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java index 4b25bb7..50a232d 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java @@ -1,5 +1,8 @@ package com.ott.domain.watch_history.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.time.LocalDateTime; import java.util.List; @@ -12,4 +15,7 @@ public interface WatchHistoryRepositoryCustom { // 특정 태그의 2달 시청이력 기반 count 집계 Long countByMemberIdAndTagIdAndWatchedBetween(Long memberId, Long tagId, LocalDateTime startDate, LocalDateTime endDate); + + // 특정 회원의 전체 시청이력 페이징 조회 (최신순) + Page findWatchHistoryByMemberId(Long memberId, Pageable pageable); } diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java index 3378807..aa687a6 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -1,8 +1,12 @@ package com.ott.domain.watch_history.repository; import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; import java.time.LocalDateTime; import java.util.List; @@ -10,6 +14,7 @@ import static com.ott.domain.common.Status.ACTIVE; import static com.ott.domain.contents.domain.QContents.contents; import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; +import static com.ott.domain.playback.domain.QPlayback.playback; import static com.ott.domain.tag.domain.QTag.tag; import static com.ott.domain.watch_history.domain.QWatchHistory.watchHistory; @@ -94,4 +99,36 @@ public Long countByMemberIdAndTagIdAndWatchedBetween( return result != null ? result : 0L; } + // 특정 회원의 전체 시청이력 페이징 조회 (최신순) + @Override + public Page findWatchHistoryByMemberId(Long memberId, Pageable pageable) { + + List content = queryFactory + .select(Projections.constructor(RecentWatchProjection.class, + contents.id, + contents.media.posterUrl, + playback.positionSec.coalesce(0), + contents.duration + )) + .from(watchHistory) + .join(contents).on(watchHistory.contents.id.eq(contents.id)) + .leftJoin(playback).on( + playback.contents.id.eq(contents.id) + .and(playback.member.id.eq(memberId)) + .and(playback.status.eq(ACTIVE)) + ) + .where(watchHistory.member.id.eq(memberId)) + .orderBy(watchHistory.lastWatchedAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(watchHistory.count()) + .from(watchHistory) + .where(watchHistory.member.id.eq(memberId)); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + } From f503efb0e27119ecdce4cb4e996fa907e708ff4a Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 21:29:02 +0900 Subject: [PATCH 207/257] =?UTF-8?q?[FEAT]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20soft=20delet?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bookmark/repository/BookmarkRepository.java | 7 +++++++ .../ott/domain/comment/repository/CommentRepository.java | 9 ++++++--- .../com/ott/domain/likes/repository/LikesRepository.java | 8 ++++++++ .../domain/playback/repository/PlaybackRepository.java | 8 ++++++++ .../preferred_tag/repository/PreferredTagRepository.java | 6 ++++++ .../watch_history/repository/WatchHistoryRepository.java | 8 ++++++++ 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index 9765c39..28ef765 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -8,6 +8,9 @@ import com.ott.domain.bookmark.domain.Bookmark; import com.ott.domain.common.Status; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -27,4 +30,8 @@ Page findByMemberIdAndStatusAndMedia_MediaTypeInOrderByCreatedDateDesc Page findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( Long memberId, Status status, MediaType mediaType, Pageable pageable); + // 회원탈퇴 soft delete + @Modifying(clearAutomatically = true) + @Query("UPDATE Bookmark b SET b.status = 'DELETE' WHERE b.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java index 766324d..033f4a7 100644 --- a/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java @@ -2,12 +2,10 @@ import com.ott.domain.comment.domain.Comment; import com.ott.domain.common.Status; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.*; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.Optional; import com.ott.domain.comment.domain.Comment; @@ -32,4 +30,9 @@ Page findByContents_IdAndStatusWithSpoilerCondition( Pageable pageable ); + + // 회원 탈퇴 + @Modifying(clearAutomatically = true) + @Query("UPDATE Comment c SET c.status = 'DELETE' WHERE c.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index 1e01854..6ead6d7 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -4,6 +4,9 @@ import com.ott.domain.common.Status; import com.ott.domain.likes.domain.Likes; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -14,4 +17,9 @@ public interface LikesRepository extends JpaRepository { Optional findByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); + + // 회원 탈퇴 soft delete + @Modifying(clearAutomatically = true) + @Query("UPDATE Likes l SET l.status = 'DELETE' WHERE l.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java index d5395cf..d86119a 100644 --- a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -6,9 +6,17 @@ import com.ott.domain.common.Status; import com.ott.domain.playback.domain.Playback; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PlaybackRepository extends JpaRepository { // 가장 최신으로 하나만 가져오기 // Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, // Long contentsId, Status status); + + // 회원 탈퇴 + @Modifying(clearAutomatically = true) + @Query("UPDATE WatchHistory w SET w.status = 'DELETE' WHERE w.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java index 6db768e..21da74a 100644 --- a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -31,4 +31,10 @@ List findAllWithTagAndCategoryByMemberIdAndStatus(@Param("memberId @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM PreferredTag pt WHERE pt.member = :member") void deleteAllByMember(@Param("member") Member member); + + + // 회원 탈퇴 시 soft delete 사용 + @Modifying(clearAutomatically = true) + @Query("UPDATE PreferredTag pt SET pt.status = 'DELETE' WHERE pt.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java index efe3a9b..1e9f1a3 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java @@ -2,6 +2,14 @@ import com.ott.domain.watch_history.domain.WatchHistory; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface WatchHistoryRepository extends JpaRepository, WatchHistoryRepositoryCustom { + + // 회원 탈퇴 + @Modifying(clearAutomatically = true) + @Query("UPDATE WatchHistory w SET w.status = 'DELETE' WHERE w.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); } From c072df36cf4c9679079bd7e46b1a356a0ef39a40 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 21:30:13 +0900 Subject: [PATCH 208/257] =?UTF-8?q?[FEAT]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=84=A4=EC=A0=95=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/KakaoUnlinkClient.java | 53 +++++++++++++++++++ .../api_user/config/RestTemplateConfig.java | 14 +++++ .../ott/common/web/exception/ErrorCode.java | 2 +- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java b/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java new file mode 100644 index 0000000..9fd7c9a --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java @@ -0,0 +1,53 @@ +package com.ott.api_user.auth.client; + +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoUnlinkClient { + + private final RestTemplate restTemplate; + + @Value("${kakao.unlink-url}") + private String unlinkUrl; + + @Value("${kakao.admin-key}") + private String adminKey; + + /** + * 카카오 연결 끊기 (어드민 키 방식) + * 탈퇴 시 카카오 서버에서 해당 유저와의 연결을 끊음 + */ + public void unlink(String providerId) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", "KakaoAK " + adminKey); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("target_id_type", "user_id"); + body.add("target_id", providerId); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(unlinkUrl, request, String.class); + log.info("카카오 연결 끊기 성공 - providerId: {}, status: {}", providerId, response.getStatusCode()); + } catch (Exception e) { + log.error("카카오 연결 끊기 실패 - providerId: {}", providerId, e); + throw new BusinessException(ErrorCode.KAKAO_UNLINK_FAILED); + } + } +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java new file mode 100644 index 0000000..97769cb --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.ott.api_user.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index de3c1cb..92c1583 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -33,8 +33,8 @@ public enum ErrorCode { UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "인증이 필요합니다"), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰입니다"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "만료된 토큰입니다"), - FORBIDDEN(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없습니다"), + KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "A004", "카카오 인증 서버에 접근할 수 없습니다"), // ========== User (U) - 사용자 ========== USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다"), From 0e464c95a37e2c4173d468d9c0de4b9d22bd69f2 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 21:31:24 +0900 Subject: [PATCH 209/257] =?UTF-8?q?[FEAT]:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API,=20=ED=9A=8C=EC=9B=90=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20Status=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/KakaoAuthService.java | 1 + .../member/controller/MemberController.java | 10 +++++ .../member/service/MemberService.java | 45 +++++++++++++++++++ docker-compose.yml | 2 + .../com/ott/domain/member/domain/Member.java | 14 ++++++ 5 files changed, 72 insertions(+) diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java index e9b3c36..9e7cd81 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -31,6 +31,7 @@ public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { return memberRepository .findByProviderAndProviderId(Provider.KAKAO, kakaoUserInfo.getProviderId()) .map(existingMember -> { + existingMember.reactivate(); existingMember.updateKakaoProfile( kakaoUserInfo.getEmail(), kakaoUserInfo.getNickname()); diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 3e6fa99..cfe491b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -88,4 +88,14 @@ public ResponseEntity>> getWat ) { return ResponseEntity.ok(SuccessResponse.of(memberService.getWatchHistoryPlaylist(memberId, page))); } + + // 회원 탈퇴 - 현재 soft delete + // 현재 회원 탈퇴를 진행해도 JWT가 현재 stateless라서 만료 시간 까지 API 호출이 가능함 + // 추후 redis 블랙리스트 같은 기술을 도입해야됨 + @DeleteMapping("/me") + public ResponseEntity withdraw( + @AuthenticationPrincipal Long memberId) { + memberService.withdraw(memberId); + return ResponseEntity.noContent().build(); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 80945d4..7d9733e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -1,5 +1,6 @@ package com.ott.api_user.member.service; +import com.ott.api_user.auth.client.KakaoUnlinkClient; import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.*; @@ -9,10 +10,15 @@ 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.bookmark.repository.BookmarkRepository; +import com.ott.domain.comment.repository.CommentRepository; import com.ott.domain.common.Status; +import com.ott.domain.likes.repository.LikesRepository; import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.member.domain.Member; +import com.ott.domain.member.domain.Provider; import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.playback.repository.PlaybackRepository; import com.ott.domain.preferred_tag.domain.PreferredTag; import com.ott.domain.preferred_tag.repository.PreferredTagRepository; import com.ott.domain.tag.domain.Tag; @@ -44,6 +50,13 @@ public class MemberService { private final WatchHistoryRepository watchHistoryRepository; private final MediaRepository mediaRepository; + // 회원 탈퇴 시 soft delete + private final KakaoUnlinkClient kakaoUnlinkClient; + private final BookmarkRepository bookmarkRepository; + private final LikesRepository likesRepository; + private final PlaybackRepository playbackRepository; + private final CommentRepository commentRepository; + /** * 마이 페이지 조회 : 닉네임, 선호태그 List 반환 */ @@ -259,4 +272,36 @@ public PageResponse getWatchHistoryPlaylist(Long memberId, return PageResponse.toPageResponse(pageInfo, dataList); } + + + /** + * 회원 탈퇴 + * 1. 카카오 회원인 경우 카카오 연결 끊기 + * 2. 연관 데이터 Soft Delete + * 3. 회원 Soft Delete + refreshToken 초기화 + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 1. 카카오 연결 끊기 + if (member.getProvider() == Provider.KAKAO) { + kakaoUnlinkClient.unlink(member.getProviderId()); + } + + // 벌크 쿼리 이후 영속성 컨텍스트가 초기화 되기 때문에 member.withdraw 먼저 수행 + // JPA가 변경 감지를 못해서 순서 변경 + // 2. 회원 Soft Delete + member.withdraw(); + + // 3. 연관 데이터 Soft Delete + preferredTagRepository.softDeleteAllByMemberId(memberId); + bookmarkRepository.softDeleteAllByMemberId(memberId); + likesRepository.softDeleteAllByMemberId(memberId); + watchHistoryRepository.softDeleteAllByMemberId(memberId); + playbackRepository.softDeleteAllByMemberId(memberId); + commentRepository.softDeleteAllByMemberId(memberId); + + } } diff --git a/docker-compose.yml b/docker-compose.yml index 3cca0ea..1c74fca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + KAKAO_UNLINK_URL: ${KAKAO_UNLINK_URL} + KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} FRONTEND_URL: ${FRONTEND_URL} depends_on: mysql: diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 62d0c80..cb533ba 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -1,6 +1,7 @@ package com.ott.domain.member.domain; import com.ott.domain.common.BaseEntity; +import com.ott.domain.common.Status; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -92,4 +93,17 @@ public void updateNickname(String nickname) { public void completeOnboarding() { this.onboardingCompleted = true; } + + // 회원 탈퇴 - Soft Delete (refreshToken 초기화 + status DELETE) + public void withdraw() { + this.refreshToken = null; + this.updateStatus(Status.DELETE); + } + + // 탈퇴(DELETE) 상태인 경우에만 ACTIVE로 복구 + public void reactivate() { + if (this.getStatus() == Status.DELETE) { + this.updateStatus(Status.ACTIVE); + } + } } From 1f2c3c76f2b5541f43e9b51fef666a7c872df5bb Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 21:43:13 +0900 Subject: [PATCH 210/257] =?UTF-8?q?[FEAT]:=20=EC=BF=BC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=20Status=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/WatchHistoryRepositoryImpl.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java index aa687a6..55ccc54 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -64,6 +64,7 @@ public List findTopTagsByMemberIdAndWatchedBetween( .and(tag.status.eq(ACTIVE))) .where( watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE), // delete 된거 조회 x watchHistory.lastWatchedAt.goe(startDate), watchHistory.lastWatchedAt.lt(endDate) ) @@ -90,6 +91,7 @@ public Long countByMemberIdAndTagIdAndWatchedBetween( .and(tag.status.eq(ACTIVE))) .where( watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE), // delete 된거 조회 x tag.id.eq(tagId), watchHistory.lastWatchedAt.goe(startDate), watchHistory.lastWatchedAt.lt(endDate) @@ -117,7 +119,10 @@ public Page findWatchHistoryByMemberId(Long memberId, Pag .and(playback.member.id.eq(memberId)) .and(playback.status.eq(ACTIVE)) ) - .where(watchHistory.member.id.eq(memberId)) + .where( + watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE) // delete 된거 조회 x + ) .orderBy(watchHistory.lastWatchedAt.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -126,7 +131,10 @@ public Page findWatchHistoryByMemberId(Long memberId, Pag JPAQuery countQuery = queryFactory .select(watchHistory.count()) .from(watchHistory) - .where(watchHistory.member.id.eq(memberId)); + .where( + watchHistory.member.id.eq(memberId), + watchHistory.status.eq(ACTIVE) + ); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } From 04bc9b8963a6a7fd8857d35d3cba714270cbfdb6 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 21:44:55 +0900 Subject: [PATCH 211/257] =?UTF-8?q?[CHORE]:=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20API=20=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-user/src/main/resources/application.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index a274367..f0847ae 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -1,6 +1,10 @@ app: frontend-url: ${FRONTEND_URL:http://localhost:8080} +kakao: + unlink-url: ${KAKAO_UNLINK_URL} # 카카오 회원탈퇴 할 링크 + admin-key: ${KAKAO_ADMIN_KEY} # 회원탈퇴, 서비스 앱의 어드민 키 + server: port: 8080 From 0b871483749480600d9e58aef65f402b128121d4 Mon Sep 17 00:00:00 2001 From: marulog Date: Mon, 2 Mar 2026 21:45:20 +0900 Subject: [PATCH 212/257] =?UTF-8?q?[DOCS]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/member/controller/MemberApi.java | 230 +++++++++++++----- 1 file changed, 167 insertions(+), 63 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java index 07d5863..9bc9150 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -2,10 +2,12 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; -import com.ott.api_user.member.dto.response.MyPageResponse; +import com.ott.api_user.member.dto.response.*; import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; @@ -14,113 +16,92 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; + +import java.util.List; @RequestMapping("/member") @Tag(name = "Member", description = "마이페이지 API") @SecurityRequirement(name = "BearerAuth") // 인증인가 확인 public interface MemberApi { - @Operation( - summary = "마이페이지 조회", - description = "로그인한 회원의 닉네임과 선호 태그 목록을 조회합니다." + // ------------------------------------------------------- + // 마이페이지 조회 + // ------------------------------------------------------- + @Operation(summary = "마이페이지 조회", description = "로그인한 회원의 닉네임과 선호 태그 목록을 조회합니다." ) @ApiResponses({ @ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = MyPageResponse.class)) + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MyPageResponse.class)) ), @ApiResponse( - responseCode = "401", - description = "인증 실패 (토큰 없음 또는 만료)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ), @ApiResponse( - responseCode = "404", - description = "회원을 찾을 수 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ) }) ResponseEntity> getMyPage(@AuthenticationPrincipal Long memberId); - @Operation( - summary = "내 정보 수정", - description = "닉네임, 선호 태그를 수정합니다. 각 필드는 선택적으로 변경 가능하며 null이면 변경되지 않습니다." + + // ------------------------------------------------------- + // 내 정보 수정 + // ------------------------------------------------------- + @Operation(summary = "내 정보 수정", description = "닉네임, 선호 태그를 수정합니다. 각 필드는 선택적으로 변경 가능하며 null이면 변경되지 않습니다." ) @ApiResponses({ @ApiResponse( - responseCode = "200", - description = "수정 성공 - 변경된 마이페이지 정보 반환", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = MyPageResponse.class)) + responseCode = "200", description = "수정 성공 - 변경된 마이페이지 정보 반환", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MyPageResponse.class)) ), - @ApiResponse(responseCode = "401", - description = "인증 실패", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + @ApiResponse( + responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ), - @ApiResponse(responseCode = "404", - description = "회원 또는 태그를 찾을 수 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + @ApiResponse( + responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ) }) ResponseEntity> updateMyInfo( @AuthenticationPrincipal Long memberId, - @RequestBody UpdateMemberRequest request + @Valid @RequestBody UpdateMemberRequest request ); - /** - * - * POST /member/me/tags : 온보딩 화면 선호 태그 수집 API - */ - @Operation( - summary = "온보딩에서 선호 태그 저장", - description = "온보딩 화면에서 처음 선호 태그를 수집합니다. 유저마다 1회만 실행" + + // ------------------------------------------------------- + // 온보딩 선호 태그 저장 + // ------------------------------------------------------- + @Operation(summary = "온보딩에서 선호 태그 저장", description = "온보딩 화면에서 처음 선호 태그를 수집합니다. 유저마다 1회만 실행" ) @ApiResponses({ @ApiResponse( - responseCode = "200", - description = "선호 태그 저장 성공" + responseCode = "200", description = "선호 태그 저장 성공" ), @ApiResponse( - responseCode = "400", - description = "잘못된 요청 (빈 태그 목록, 중복 태그 ID 등)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + responseCode = "400", description = "잘못된 요청 (빈 태그 목록, 중복 태그 ID 등)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ), @ApiResponse( - responseCode = "401", - description = "인증 실패 (토큰 없음 또는 만료)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ), @ApiResponse( - responseCode = "404", - description = "회원 또는 태그를 찾을 수 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class) + responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) ) ) }) @@ -130,4 +111,127 @@ ResponseEntity> setPreferredTags( @Valid @RequestBody SetPreferredTagRequest request ); + + // ------------------------------------------------------- + // 시청이력 기반 태그 랭킹 조회 + // ------------------------------------------------------- + @Operation(summary = "시청이력 기반 태그 랭킹 조회", description = "최근 1달간 시청이력을 기반으로 상위 4개 태그 + 기타 항목을 반환" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagRankingResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/tag/ranking") + ResponseEntity> getTagRanking( + @AuthenticationPrincipal Long memberId); + + + // ------------------------------------------------------- + // 태그 월별 시청 count 비교 + // ------------------------------------------------------- + @Operation(summary = "태그 월별 시청 count 비교", description = "특정 태그의 이번 달 vs 저번 달 시청 횟수를 반환" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagMonthlyCompareResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/tag/ranking/{tagId}") + ResponseEntity> getTagMonthlyCompare( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ); + + + // ------------------------------------------------------- + // 태그별 추천 콘텐츠 목록 조회 + // ------------------------------------------------------- + @Operation(summary = "태그별 추천 콘텐츠 목록 조회", description = "해당 태그에 속하는 콘텐츠를 최대 20개 반환" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagContentResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/taglist/{tagId}") + ResponseEntity>> getRecommendContentsByTag( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ); + + + // ------------------------------------------------------- + // 전체 시청이력 플레이리스트 페이징 조회 + // ------------------------------------------------------- + @Operation(summary = "시청이력 플레이리스트 조회", description = "전체 시청이력을 최신순으로 10개씩 페이징 조회합니다. 이어보기 시점 포함.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))), + @ApiResponse( + responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me/playlist") + ResponseEntity>> getWatchHistoryPlaylist( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page + ); + + // ============================================================ + // 회원 탈퇴 + // ============================================================ + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 처리합니다. 카카오 연결 끊기 및 모든 데이터 Soft Delete.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "탈퇴 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/me") + ResponseEntity withdraw( + @AuthenticationPrincipal Long memberId); + } \ No newline at end of file From 91721256996080ba49023d7e03765a0be342b879 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:26:50 +0900 Subject: [PATCH 213/257] =?UTF-8?q?[FEAT]:=20=EA=B0=9C=EC=9D=B8=ED=99=94?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=EA=B0=80=EC=A4=91=EC=B9=98=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=97=94=EC=A7=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/common/ContentSource.java | 3 +- .../common/dto/ContentListElement.java | 41 ---- .../content/controller/ContentsApi.java | 2 - .../controller/ContentsController.java | 1 - .../content/dto/ContentsDetailResponse.java | 15 ++ .../content/service/ContentsService.java | 2 - .../controller/PlaylistController.java | 2 - .../dto/request/PlaylistCondition.java | 14 +- .../service/PlaylistPreferenceService.java | 104 +++++++++ .../playlist/service/PlaylistService.java | 3 +- .../strategy/BookmarkPlaylistStrategy.java | 7 +- .../strategy/HistoryPlaylistStrategy.java | 2 - .../strategy/RecommendPlaylistStrategy.java | 50 ++++- .../strategy/SeriesPlaylistStrategy.java | 11 - .../service/strategy/TagPlaylistStrategy.java | 54 ++++- .../series/dto/SeriesContentsResponse.java | 7 +- .../src/main/resources/application.yml | 4 + .../likes/repository/LikesRepository.java | 21 +- .../repository/MediaRepositoryCustom.java | 37 ++-- .../media/repository/MediaRepositoryImpl.java | 200 +++++++++++------- .../member/repository/MemberRepository.java | 8 +- .../repository/PlaybackRepository.java | 23 +- .../repository/PreferredTagRepository.java | 10 + 23 files changed, 431 insertions(+), 190 deletions(-) delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java index ce69500..66d078b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java +++ b/apps/api-user/src/main/java/com/ott/api_user/common/ContentSource.java @@ -11,8 +11,7 @@ public enum ContentSource { HISTORY("HISTORY"), // 최근 시청 중인 콘텐츠에서 진입 시 TAG("TAG"), // 특정 태그(예: #스릴러) 클릭 시 RECOMMEND("RECOMMEND"), // "OO님이 좋아할 만한 리스트"에서 진입 시 - SEARCH("SEARCH"), // 검색 결과에서 진입 시 - SERIES("SERISE"); // 시리즈 상세 페이지에서 진입 시 + SEARCH("SEARCH"); // 검색 결과에서 진입 시 private final String value; } diff --git a/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java b/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java deleted file mode 100644 index 56a2dc4..0000000 --- a/apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.ott.api_user.common.dto; - -import com.ott.domain.common.MediaType; -import com.ott.domain.media.domain.Media; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -@AllArgsConstructor -@Schema(description = "콘텐츠 리스트 공통 요소") // 홈화면 플레이리스트 요소들(인기차트 , 태그별 리스트, 최근 시청 리스트, 검색 결과 등등 ) -public class ContentListElement { - @Schema(description = "미디어 ID", example = "1") - private Long id; - - @Schema(description = "미디어 타입", example = "SERIES") - private MediaType mediaType; - - @Schema(description = "미디어 제목", example = "비밀의 숲") - private String title; - - @Schema(description = "포스터 이미지 URL", example = "https://cdn.ott.com/posters/101.jpg") - private String posterUrl; - - @Schema(description = "가로형 썸네일 이미지 URL", example = "https://cdn.ott.com/thumbnails/101.jpg") - private String thumbnailUrl; - - public static ContentListElement from(Media media) { - return ContentListElement.builder() - .id(media.getId()) - .mediaType(media.getMediaType()) - .title(media.getTitle()) - .posterUrl(media.getPosterUrl()) - .thumbnailUrl(media.getThumbnailUrl()) - .build(); - } - -} diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java index a94d7cb..8e7e2f4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java @@ -5,9 +5,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; - import com.ott.api_user.common.ContentSource; -import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java index 7478de7..92cfa72 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.ott.api_user.common.ContentSource; -import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.api_user.content.service.ContentsService; import com.ott.common.web.response.PageResponse; diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java index 9204470..fa66be2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java @@ -18,6 +18,10 @@ public class ContentsDetailResponse { @Schema(description = "미디어 고유 ID", example = "1") private Long id; + @Schema(description = "시리즈 본체의 미디어 ID (단편이면 null)", example = "101") + private Long seriesMediaId; + + @Schema(description = "콘텐츠 제목", example = "비밀의 숲") private String title; @@ -45,6 +49,10 @@ public class ContentsDetailResponse { @Schema(description = "마스터 재생목록 URL(HLS)", example = "https://example.com/master.m3u8") private String masterPlaylistUrl; + + @Schema(description= "재생 시간 (초)", example = "3600") + private Integer duration; + @Schema(description = "기존 이어보기 지점(없으면 0)", example = "150") private Integer positionSec; @@ -52,8 +60,14 @@ public static ContentsDetailResponse from(Contents contents, List tags, List categories, Boolean isBookmarked, Boolean isLiked, String masterPlaylistUrl, Integer positionSec) { + Long seriesMediaId = null; + if (contents.getSeries() != null && contents.getSeries().getMedia() != null) { + seriesMediaId = contents.getSeries().getMedia().getId(); + } + return ContentsDetailResponse.builder() .id(contents.getMedia().getId()) + .seriesMediaId(seriesMediaId) .title(contents.getMedia().getTitle()) .description(contents.getMedia().getDescription()) .actors(contents.getActors()) @@ -63,6 +77,7 @@ public static ContentsDetailResponse from(Contents contents, List tags, .isBookmarked(isBookmarked) .isLiked(isLiked) .masterPlaylistUrl(masterPlaylistUrl) + .duration(contents.getDuration()) .positionSec(positionSec) .build(); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java index 07f8988..abe50cd 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java @@ -7,7 +7,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import com.ott.api_user.common.ContentSource; -import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; @@ -59,5 +58,4 @@ public ContentsDetailResponse getContentDetail(Long mediaId, Long memberId) { return ContentsDetailResponse.from(contents, tags, categories, isBookmarked, isLiked,masterPlaylistUrl, positionSec); } - } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java index 82eee30..1de75e2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -7,8 +7,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; - -import com.ott.api_user.common.dto.ContentListElement; import com.ott.api_user.playlist.dto.request.PlaylistCondition; import com.ott.api_user.playlist.dto.response.PlaylistResponse; import com.ott.api_user.playlist.service.PlaylistService; diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java index c53906b..46b5343 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java @@ -3,9 +3,9 @@ import org.hibernate.annotations.SourceType; import com.ott.api_user.common.ContentSource; +import com.ott.domain.common.MediaType; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.models.media.MediaType; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -22,9 +22,12 @@ @Schema(description = "플레이 리스트 공통 요청 DTO") public class PlaylistCondition { - @Schema(description = "진입점 소스 타입", example = "USER, RECOMMEND, SEARCH 등 ..") + @Schema(description = "진입점 소스 타입", example = "TRENDING, RECOMMEND, HISTORY 등 ..") private ContentSource contentSource; + @Schema(description = "미디어 타입", example = "SERIES, CONTENTS") + private MediaType mediaType; + @Schema(description = "사용자 고유 ID", example = "1") private Long memberId; @@ -34,7 +37,6 @@ public class PlaylistCondition { @Schema(description = "태그 고유 ID", example = "1") private Long tagId; - @Schema(description = "미디어 타입", example = "SERIES, CONTENTS") - private MediaType mediaType; - -} + @Schema(description = "태그 랭킹 인덱스 (0, 1, 2)", example = "0") + private Integer index; +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java new file mode 100644 index 0000000..ef3e14c --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java @@ -0,0 +1,104 @@ +package com.ott.api_user.playlist.service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ott.domain.common.Status; +import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.playback.repository.PlaybackRepository; +import com.ott.domain.preferred_tag.repository.PreferredTagRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; + +import lombok.RequiredArgsConstructor; + + +// 유저의 행동(선호태그, 시청 - 태그 , 선호 태그)를 수집하여 +// Top3 태그와 oo 님이 좋아하실만한 콘텐츠 +// 종합 점수표 계산 +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlaylistPreferenceService { + + private final PreferredTagRepository preferredTagRepository; + private final PlaybackRepository playbackRepository; + private final LikesRepository likesRepository; + private final TagRepository tagRepository; + + /* + * [TAG 전략용] 시청 이력(+3) + 선호 태그(+5) 점수만 합산하여 Top 3 태그 추출 + */ + public List getTopTags(Long memberId){ + // 태그별 합산 점수를 담을 점수표 + Map tagScores = new HashMap<>(); + + // 최근 100개까지만 가져옴 + Pageable limit100 = PageRequest.of(0, 100); + + + // 1. 온보딩 선호 태그 가중치 반영 (+5점) + // Map.merge 를 통해 누적 점수 계산 + // ex) + preferredTagRepository.findTagIdsByMemberId(memberId, Status.ACTIVE) + .forEach(id -> tagScores.merge(id, 5, Integer::sum)); + + + // 2. 최근 시청 이력 가중치 반영 (+3점) + playbackRepository.findRecentTagIdsByMemberId(memberId, Status.ACTIVE, limit100) + .forEach(id -> tagScores.merge(id, 3, Integer::sum)); + + + // 3. 점수가 가장 높은 순(내림차순)으로 정렬한 뒤, 상위 3개의 태그 ID만 추출 + List topTagIds = tagScores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(3) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + // [Fallback 처리] 정보가 아예 없는 신규 유저라면? -> 시스템 전체 태그 중 무작위 3개를 던져줌 + if (topTagIds.isEmpty()) { + List allTags = tagRepository.findAll(); + Collections.shuffle(allTags); + return allTags.stream().limit(3).collect(Collectors.toList()); + } + + // 최종적으로 추출된 3개의 ID로 실제 Tag 엔티티들을 DB에서 가져와 반환 + return tagRepository.findAllById(topTagIds); + + } + + + /** + * [RECOMMEND 전략용] + * 선호 태그(+5) + 시청 이력(+3) + 좋아요(+2) - 종합 점수표 반환 + */ + public Map getTotalTagScores(Long memberId) { + Map totalScores = new HashMap<>(); + Pageable limit100 = PageRequest.of(0, 100); + + // 1. 고정 취향: 온보딩 선호 태그 (+5점) + preferredTagRepository.findTagIdsByMemberId(memberId, Status.ACTIVE) + .forEach(id -> totalScores.merge(id, 5, Integer::sum)); + + // 2. 최근 관심사: 최근 시청 이력 (+3점) + playbackRepository.findRecentTagIdsByMemberId(memberId, Status.ACTIVE, limit100) + .forEach(id -> totalScores.merge(id, 3, Integer::sum)); + + // 3. 강한 선호도: 최근 좋아요 누른 이력 (+2점) + likesRepository.findRecentTagIdsByMemberId(memberId, Status.ACTIVE, limit100) + .forEach(id -> totalScores.merge(id, 2, Integer::sum)); + + // 만들어진 유저의 최종 점수를 반환 + return totalScores; + } + +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java index 3bc8f61..b712b67 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java @@ -72,9 +72,8 @@ private PlaylistStrategy getStrategy(PlaylistCondition condition) { private String determineStrategyKey(PlaylistCondition condition) { ContentSource source = condition.getContentSource(); - if (source == null) return ContentSource.RECOMMEND.name(); - // 검색 결과에서 진입 시 추천 리스트로 대체 + // 검색 결과에서 상세로 진입한 시 재생목록은 추천으로 대체 if (source == ContentSource.SEARCH && condition.getExcludeMediaId() != null) { return ContentSource.RECOMMEND.name(); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java index c108151..0cbf11d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java @@ -16,12 +16,11 @@ public class BookmarkPlaylistStrategy implements PlaylistStrategy { @Override public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { - if (condition.getMemberId() == null) return Page.empty(); return mediaRepository.findBookmarkedPlaylists( - condition.getMemberId(), - condition.getExcludeMediaId(), - pageable + condition.getMemberId(), + condition.getExcludeMediaId(), + pageable ); } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java index c12c262..9584b1a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java @@ -1,6 +1,5 @@ package com.ott.api_user.playlist.service.strategy; - import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -9,7 +8,6 @@ import com.ott.domain.media.domain.Media; import com.ott.domain.media.repository.MediaRepository; - //시청 이력 기반 플레이리스트 - playback (시청기록)을 토대로 중복 제거 후 최근날짜 순 리스트 @Component("HISTORY") @RequiredArgsConstructor diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java index c28762c..719942b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java @@ -1,18 +1,58 @@ package com.ott.api_user.playlist.service.strategy; +import java.util.Collections; +import java.util.List; +import java.util.Map; + import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.service.PlaylistPreferenceService; import com.ott.domain.media.domain.Media; import com.ott.domain.media.repository.MediaRepository; -// // 개인화 추천 - 시청이력 + 좋아요 + 기존 선호 태그 -// @Component("RECOMMEND") -// @RequiredArgsConstructor -// public class RecommendPlaylistStrategy implements PlaylistStrategy { +// 개인화 추천 - 시청이력 + 좋아요 + 기존 선호 태그 +@Component("RECOMMEND") +@RequiredArgsConstructor +public class RecommendPlaylistStrategy implements PlaylistStrategy { + + private final PlaylistPreferenceService preferenceService; + private final MediaRepository mediaRepository; + + @Override + public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + + boolean isHomeScreen = (condition.getExcludeMediaId() == null); + + // 유저의 모든 행동(+5, +3, +2)이 합산된 종합 점수표(Map)를 가져옴 + Map tagScores = preferenceService.getTotalTagScores(condition.getMemberId()); + + int fetchLimit = (pageable.getPageNumber() == 0 && isHomeScreen) ? 50 : pageable.getPageSize(); + + long fetchOffset = (isHomeScreen) ? 0 : pageable.getOffset(); + + + // QueryDSL CaseBuilder 쿼리 실행 -> DB 내부에서 점수 합산 후 내림차순 정렬된 리스트 반환 + List mediaPool = mediaRepository.findRecommendedMedias( + tagScores, + condition.getExcludeMediaId(), + fetchLimit, + fetchOffset + ); + + if (pageable.getPageNumber() == 0 && isHomeScreen) { + Collections.shuffle(mediaPool); + } + + + int limit = Math.min(mediaPool.size(), pageable.getPageSize()); + long totalCount = isHomeScreen ? mediaPool.size() : 1000L; + return new PageImpl<>(mediaPool.subList(0, limit), pageable, totalCount); + } -// } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java deleted file mode 100644 index 5ad8a5a..0000000 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/SeriesPlaylistStrategy.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ott.api_user.playlist.service.strategy; - -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -// @Component("SERIRES") -// @RequiredArgsConstructor -// public class SeriesPlaylistStrategy implements PlaylistStrategy { - -// } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java index 084cd41..fbd99ae 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java @@ -1,12 +1,19 @@ package com.ott.api_user.playlist.service.strategy; import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import lombok.RequiredArgsConstructor; import com.ott.api_user.playlist.dto.request.PlaylistCondition; +import com.ott.api_user.playlist.service.PlaylistPreferenceService; import com.ott.domain.media.domain.Media; import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.tag.domain.Tag; /** * 태그별 플레이리스트 전략 @@ -16,22 +23,49 @@ @RequiredArgsConstructor public class TagPlaylistStrategy implements PlaylistStrategy { + private final PlaylistPreferenceService preferenceService; private final MediaRepository mediaRepository; @Override public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { + Long targetTagId = condition.getTagId(); + + // 홈화면인지 재생목록인지 구분함 + boolean isHomeScreen = (condition.getExcludeMediaId() == null); + + // 1. 명시적인 tagId 없이 index만 넘어온 경우 (특정 태그별 리스트를 홈 화면에 노출 시키고 싶을 때 재사용) + if (targetTagId == null && condition.getIndex() != null) { + + // 유저의 취향 Top 3 태그를 계산해서 가져옴 + List topTags = preferenceService.getTopTags(condition.getMemberId()); + + // 프론트가 요청한 순위(index)의 태그 ID를 타겟으로 설정 + if (condition.getIndex() < topTags.size()) { + targetTagId = topTags.get(condition.getIndex()).getId(); + } else { + return Page.empty(pageable); // 범위 초과 시 빈 리스트 반환 + } + } + + // 2. 모수 풀링: 홈 화면(page=0)은 섞기 위해 50개를 넉넉히 가져오고, 상세 페이지는 요구한 만큼만 가져옴 + int fetchLimit = (pageable.getPageNumber() == 0 && isHomeScreen) ? 50 : pageable.getPageSize(); + + // 홈 화면은 항상 무작위로 섞을 거니까 0으로 고정, 상세 페이지는 페이지에 맞게 건너뜀 + long fetchOffset = (isHomeScreen) ? 0 : pageable.getOffset(); - // 1. 핵심 조건인 tagId가 없는 경우 (예외 상황) - if (condition.getTagId() == null) { - return mediaRepository.findTrendingPlaylists(condition.getExcludeMediaId(), pageable); + List mediaPool = mediaRepository.findMediasByTagId(targetTagId, condition.getExcludeMediaId(), fetchLimit, fetchOffset); + + // 3. 디스커버리 UX: 홈 화면일 경우에만 매번 새로운 콘텐츠를 발견하도록 리스트를 무작위로 섞음 + if (pageable.getPageNumber() == 0 && isHomeScreen) { + Collections.shuffle(mediaPool); } - // 2. 태그 ID가 있다면 홈 화면이든 상세 페이지든 해당 태그 리스트를 반환합니다. - // 상세 페이지라면 리포지토리 내부 로직에 의해 excludeMediaId가 제외 처리됩니다. - return mediaRepository.findPlaylistsByTag( - condition.getTagId(), - condition.getExcludeMediaId(), - pageable - ); + // 4. 프론트가 요구한 사이즈(예: 20개)만큼만 잘라서 Page 객체로 포장 후 반환 + int limit = Math.min(mediaPool.size(), pageable.getPageSize()); + + // 상세 페이지 무한 스크롤이 끊기지 않도록 total 값(세 번째 파라미터)을 더미 값(1000L) 으로 세팅 + long totalCount = isHomeScreen ? mediaPool.size() : 1000L; + + return new PageImpl<>(mediaPool.subList(0, limit), pageable, totalCount); } } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java index 83d98d2..68a77fb 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java @@ -12,9 +12,13 @@ @AllArgsConstructor @Schema(description = "시리즈 내 콘텐츠(에피소드) 목록 아이템 응답 DTO") public class SeriesContentsResponse { - @Schema(type = "Long", example = "1", description = "미디어 고유 ID") + @Schema(type = "Long", example = "1", description = "에피소드의 미디어 ID") private Long id; + @Schema(type = "Long" , example = "101", description = "시리즈 본체의 미디어 ID") + private Long seriesMediaId; + + @Schema(type = "String", example = "더 글로리 시즌 1: 1화", description = "콘텐츠 제목") private String title; @@ -37,6 +41,7 @@ public static SeriesContentsResponse from(Contents content) { return SeriesContentsResponse.builder() .id(content.getMedia().getId()) + .seriesMediaId(content.getSeries().getMedia().getId()) .duration(content.getDuration()) .title(content.getMedia().getTitle()) .description(content.getMedia().getDescription()) diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index a274367..366ebfa 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -64,3 +64,7 @@ jwt: secret: ${JWT_SECRET_BASE64:} access-token-expiry: 1800000 # 30분 refresh-token-expiry: 1209600000 # 14일 + +springdoc: + api-docs: + version: OPENAPI_3_0 diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index 1e01854..85d219e 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -1,17 +1,32 @@ package com.ott.domain.likes.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ott.domain.common.Status; import com.ott.domain.likes.domain.Likes; import java.util.Optional; +import org.springframework.data.domain.Pageable; + public interface LikesRepository extends JpaRepository { - boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + Optional findByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); - Optional findByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); + Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); - Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); + // 최근 좋아요한 미디어의 태그 ID 조회 + @Query(""" + SELECT mt.tag.id FROM Likes l + JOIN MediaTag mt ON l.media.id = mt.media.id + WHERE l.member.id = :memberId AND l.status = :status + ORDER BY l.createdDate DESC + """) + List findRecentTagIdsByMemberId(@Param("memberId") Long memberId, @Param("status") Status status, + Pageable pageable); } diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java index 7854415..a77e1d4 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java @@ -3,30 +3,39 @@ import com.ott.domain.common.MediaType; import com.ott.domain.common.PublicStatus; import com.ott.domain.media.domain.Media; + +import java.util.List; +import java.util.Map; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface MediaRepositoryCustom { - Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); + Page findMediaListByMediaTypeAndSearchWord(Pageable pageable, MediaType mediaType, String searchWord); + + Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, + String searchWord, PublicStatus publicStatus); + + Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, + MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); + + Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); - Page findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType, - String searchWord, PublicStatus publicStatus); + // 인기 차트 통합 조회 메서드 + Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable); - Page findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable, - MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId); + // 시청 이력 조회 (최근 시청 순) + Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); - Page findOriginMediaListBySearchWord(Pageable pageable, String searchWord); + // 북마크 목록 조회 (최근 찜한 순) + Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); - // 인기 차트 통합 조회 메서드 - Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable); + // 특정 태그 기반 미디어 목록 조회 + Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable); - // 시청 이력 조회 (최근 시청 순) - Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); - // 북마크 목록 조회 (최근 찜한 순) - Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable); + List findMediasByTagId(Long tagId, Long excludeMediaId, int limit , long offset); - // 특정 태그 기반 미디어 목록 조회 - Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable); + List findRecommendedMedias(Map tagScores, Long excludeMediaId, int limit, long offset); } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 207a8c8..470de41 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -5,6 +5,8 @@ import com.ott.domain.common.Status; import com.ott.domain.media.domain.Media; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -13,12 +15,14 @@ import org.springframework.data.support.PageableExecutionUtils; import org.springframework.util.StringUtils; import static com.ott.domain.playback.domain.QPlayback.playback; +import static com.ott.domain.contents.domain.QContents.contents; import java.util.List; -import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.media.domain.QMedia.media; import com.querydsl.jpa.JPAExpressions; -import static com.ott.domain.bookmark.domain.QBookmark.bookmark; -import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; -import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.bookmark.domain.QBookmark.bookmark; +import static com.ott.domain.media_tag.domain.QMediaTag.mediaTag; + +import java.util.Map; @RequiredArgsConstructor public class MediaRepositoryImpl implements MediaRepositoryCustom { @@ -129,18 +133,17 @@ public Page findOriginMediaListBySearchWord(Pageable pageable, String sea return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne); } - /* - * 플레이리스트 전략패턴 관련 로직 - */ + * 플레이리스트 전략패턴 관련 로직 + */ @Override - public Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable){ + public Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable) { List content = queryFactory .selectFrom(media) .where( - isActiveAndPublic(), // 활성 및 공개 상태 필터링 - excludeId(excludeMediaId) // 현재 미디어 제외 (null이면 무시됨) + isActiveAndPublic(), // 활성 및 공개 상태 필터링 + excludeId(excludeMediaId) // 현재 미디어 제외 (null이면 무시됨) ) .orderBy(media.bookmarkCount.desc()) .offset(pageable.getOffset()) @@ -148,12 +151,11 @@ public Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable) .fetch(); JPAQuery countQuery = queryFactory - .select(media.count()) - .from(media) - .where( - isActiveAndPublic(), - excludeId(excludeMediaId) - ); + .select(media.count()) + .from(media) + .where( + isActiveAndPublic(), + excludeId(excludeMediaId)); // PageableExecutionUtils를 사용하여 첫 페이지 조회 시 불필요한 카운트 쿼리 방지 return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); @@ -162,88 +164,136 @@ public Page findTrendingPlaylists(Long excludeMediaId, Pageable pageable) @Override public Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable) { List content = queryFactory - .select(media) - .from(playback) - .join(playback.contents.media, media) // 시청 기록과 미디어 정보 조인 - .where( - playback.member.id.eq(memberId), // 특정 사용자 필터링 - isActiveAndPublic(), // 활성/공개 상태 확인 - excludeId(excludeMediaId) // 현재 재생 중인 영상 제외 - ) - .orderBy(playback.modifiedDate.desc()) // 최근 시청 시점 순 정렬 - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); + .select(media) + .from(playback) + .join(playback.contents.media, media) // 시청 기록과 미디어 정보 조인 + .where( + playback.member.id.eq(memberId), // 특정 사용자 필터링 + isActiveAndPublic(), // 활성/공개 상태 확인 + excludeId(excludeMediaId) // 현재 재생 중인 영상 제외 + ) + .orderBy(playback.modifiedDate.desc()) // 최근 시청 시점 순 정렬 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); JPAQuery countQuery = queryFactory - .select(playback.count()) - .from(playback) - .where( - playback.member.id.eq(memberId), - excludeId(excludeMediaId) - ); + .select(playback.count()) + .from(playback) + .where( + playback.member.id.eq(memberId), + excludeId(excludeMediaId)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } - @Override public Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable) { List content = queryFactory - .select(media) - .from(bookmark) - .join(bookmark.media, media) - .where( - bookmark.member.id.eq(memberId), - bookmark.status.eq(Status.ACTIVE), - isActiveAndPublic(), - excludeId(excludeMediaId) - ) - .orderBy(bookmark.createdDate.desc()) // 최근 북마크한 순서 - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); + .select(media) + .from(bookmark) + .join(bookmark.media, media) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(Status.ACTIVE), + isActiveAndPublic(), + excludeId(excludeMediaId)) + .orderBy(bookmark.createdDate.desc()) // 최근 북마크한 순서 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); JPAQuery countQuery = queryFactory - .select(bookmark.count()) - .from(bookmark) - .where( - bookmark.member.id.eq(memberId), - bookmark.status.eq(Status.ACTIVE), - excludeId(excludeMediaId) - ); + .select(bookmark.count()) + .from(bookmark) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(Status.ACTIVE), + excludeId(excludeMediaId)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } - @Override public Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable) { List content = queryFactory - .select(media) - .from(mediaTag) - .join(mediaTag.media, media) - .where( - mediaTag.tag.id.eq(tagId), // 요청된 태그 ID 필터링 - isActiveAndPublic(), // 활성/공개 상태 확인 - excludeId(excludeMediaId) // 현재 재생 중인 영상 제외 - ) - .orderBy(media.createdDate.desc()) // 최신 등록 순 정렬 - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); + .select(media) + .from(mediaTag) + .join(mediaTag.media, media) + .where( + mediaTag.tag.id.eq(tagId), // 요청된 태그 ID 필터링 + isActiveAndPublic(), // 활성/공개 상태 확인 + excludeId(excludeMediaId) // 현재 재생 중인 영상 제외 + ) + .orderBy(media.createdDate.desc()) // 최신 등록 순 정렬 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); JPAQuery countQuery = queryFactory - .select(mediaTag.count()) - .from(mediaTag) - .where( - mediaTag.tag.id.eq(tagId), - excludeId(excludeMediaId) - ); + .select(mediaTag.count()) + .from(mediaTag) + .where( + mediaTag.tag.id.eq(tagId), + excludeId(excludeMediaId)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } + // 특정 태그를 가진 영상 조회 + @Override + public List findMediasByTagId(Long tagId, Long excludeMediaId, int limit, long offset) { + return queryFactory.selectFrom(media) + .join(mediaTag).on(mediaTag.media.id.eq(media.id)) + .where( + media.status.eq(Status.ACTIVE), + media.publicStatus.eq(PublicStatus.PUBLIC), + mediaTag.tag.id.eq(tagId), + excludeMediaId != null ? media.id.ne(excludeMediaId) : null) + .orderBy(media.id.desc()) + .limit(limit) + .offset(offset) + .fetch(); + } + + // PlaylistPrefereceService 에서 받아온 tagScores 로 추천 종합 쿼리 + @Override + public List findRecommendedMedias(Map tagScores, Long excludeMediaId, int limit, long offset) { + // 선호태그 조차 고르지 않은 백지 상태의 유저 + // 이때는 가장 최근 신작 노출 + if (tagScores.isEmpty()) { + return queryFactory.selectFrom(media) + .where(media.status.eq(Status.ACTIVE), + media.publicStatus.eq(PublicStatus.PUBLIC)) + .orderBy(media.id.desc()) + .limit(limit) + .offset(offset) + .fetch(); + } + + // 개인화 추천을 위한 점수 계산기 + NumberExpression scoreExpression = new CaseBuilder() + .when(mediaTag.tag.id.isNotNull()).then(0).otherwise(0); + + for (Map.Entry entry : tagScores.entrySet()) { + scoreExpression = scoreExpression.add( + new CaseBuilder() + .when(mediaTag.tag.id.eq(entry.getKey())).then(entry.getValue()) + .otherwise(0)); + } + + return queryFactory.selectFrom(media) + .join(mediaTag).on(mediaTag.media.id.eq(media.id)) + .where( + media.status.eq(Status.ACTIVE), + media.publicStatus.eq(PublicStatus.PUBLIC), + excludeMediaId != null ? media.id.ne(excludeMediaId) : null) + .groupBy(media.id) + .orderBy(scoreExpression.sum().desc(), media.id.desc()) + .limit(limit) + .offset(offset) + .fetch(); + } // --- 동적 쿼리 헬퍼 메서드 --- private BooleanExpression titleContains(String searchWord) { @@ -273,7 +323,7 @@ private BooleanExpression uploaderIdEq(Long uploaderId) { private BooleanExpression isActiveAndPublic() { // Status.ACTIVE와 PublicStatus.PUBLIC 조건을 결합 return media.status.eq(com.ott.domain.common.Status.ACTIVE) - .and(media.publicStatus.eq(com.ott.domain.common.PublicStatus.PUBLIC)); + .and(media.publicStatus.eq(com.ott.domain.common.PublicStatus.PUBLIC)); } private BooleanExpression excludeId(Long excludeMediaId) { diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java index 478f208..6efca0c 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -1,10 +1,11 @@ package com.ott.domain.member.repository; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; import com.ott.domain.member.domain.Member; import com.ott.domain.member.domain.Provider; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { @@ -13,4 +14,5 @@ public interface MemberRepository extends JpaRepository, MemberRep // 관리자&에디터용 조회 Optional findByEmailAndProvider(String email, Provider provider); + } diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java index d5395cf..c615a68 100644 --- a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -1,14 +1,29 @@ package com.ott.domain.playback.repository; -import java.util.Optional; +import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ott.domain.common.Status; import com.ott.domain.playback.domain.Playback; public interface PlaybackRepository extends JpaRepository { - // 가장 최신으로 하나만 가져오기 - // Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, - // Long contentsId, Status status); + // 가장 최신으로 하나만 가져오기 + // Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, + // Long contentsId, Status status); + + // 최신 시청 이력 100개 가져오기 - 선호 태그 조사용 + @Query(""" + SELECT mt.tag.id FROM Playback p + JOIN MediaTag mt ON p.contents.media.id = mt.media.id + WHERE p.member.id = :memberId AND p.status = :status + ORDER BY p.modifiedDate DESC + """) + List findRecentTagIdsByMemberId( + @Param("memberId") Long memberId, + @Param("status") Status status, + Pageable pageable); // pageable로 100개로 리미트 제한 } diff --git a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java index 6db768e..be256a1 100644 --- a/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java @@ -31,4 +31,14 @@ List findAllWithTagAndCategoryByMemberIdAndStatus(@Param("memberId @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM PreferredTag pt WHERE pt.member = :member") void deleteAllByMember(@Param("member") Member member); + + + // 사용자의 선호 태그 ID만 조회 + @Query(""" + SELECT pt.tag.id + FROM PreferredTag pt + WHERE pt.member.id = :memberId AND pt.status = :status + """) + List findTagIdsByMemberId(@Param("memberId") Long memberId, @Param("status") Status status); + } From a1c3f9e8dda9869387fdf67da6b70d889a4f1589 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 14:15:13 +0900 Subject: [PATCH 214/257] =?UTF-8?q?[feat]:=20=EC=BF=A0=ED=82=A4=20secure?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/common/security/util/CookieUtil.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java index 8802ab0..fb8931b 100644 --- a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java +++ b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java @@ -10,24 +10,24 @@ public class CookieUtil { public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { ResponseCookie cookie = ResponseCookie.from(name, value) -// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - .secure(false) // HTTPS 요청만 허용 + .secure(true) // HTTPS 요청만 허용 .path("/") // 모든 경로로 전송 .maxAge(maxAge) - .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 + .sameSite("None") // 크로스 사이트에 대해서 쿠키 전송 허용 .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } public void deleteCookie(HttpServletResponse response, String name) { ResponseCookie cookie = ResponseCookie.from(name, "") -// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) - .secure(false) + .secure(true) .path("/") .maxAge(0) - .sameSite("Lax") + .sameSite("None") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } From 645bb501af427f2397c6def459d4db496f07844d Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:39:03 +0900 Subject: [PATCH 215/257] =?UTF-8?q?[REFACTOR]:=20source=20=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=EB=90=9C=20API=20->=20=EA=B0=81=EA=B0=81?= =?UTF-8?q?=EC=9D=98=20=EA=B0=9C=EB=B3=84=20API=20=EB=A1=9C=20restful=20?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/controller/PlaylistApi.java | 73 +++++++++-- .../controller/PlaylistController.java | 119 ++++++++++++++++-- .../dto/response/TopTagPlaylistResponse.java | 40 ++++++ .../playlist/service/PlaylistService.java | 39 ++++++ 4 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java index 4296e44..1a9efa2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java @@ -1,17 +1,17 @@ package com.ott.api_user.playlist.controller; -import com.ott.api_user.playlist.dto.request.PlaylistCondition; import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import com.ott.common.web.exception.ErrorResponse; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; -import com.ott.common.web.exception.ErrorResponse; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -21,9 +21,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "Playlist", description = "플레이리스트 API") +@Tag(name = "Playlist", description = "플레이리스트 API") public interface PlaylistApi { - @Operation(summary = "플레이리스트 조회", description = "source 타입에 따라 동적으로 플레이리스트를 반환합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "0", description = "조회 성공 - 플레이리스트 구성", content ={ @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = PlaylistResponse.class))) }), @@ -32,11 +32,60 @@ public interface PlaylistApi { @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) - @GetMapping - ResponseEntity>> getPlaylists( - @Parameter(description = "플레이리스트 조회 조건 (source 필수)", required = true) PlaylistCondition condition, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + + @Operation(summary = "OO 님이 좋아하실만한 콘텐츠", description = "유저 취향을 합산하여 추천합니다. (홈 화면 셔플 지원)") + @GetMapping("/recommend") + ResponseEntity>> getRecommendPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "선호 태그 순위별 리스트", description = "유저의 Top 3 태그 순위를 기반으로 제공합니다.") + @GetMapping("/tags/top") + ResponseEntity> getTopTagPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @Parameter(description = "유저 취향 순위 (0, 1, 2)", required = true) @RequestParam(value = "index") Integer index, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "상세 페이지 - 특정 해시태그 리스트", description = "해당 태그의 영상만 제공합니다.") + @GetMapping("/tags/{tagId}") + ResponseEntity>> getTagPlaylists( + @Parameter(description = "태그 ID", required = true) @PathVariable(value = "tagId") Long tagId, + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "인기 차트 (Trending)", description = "북마크가 많은 인기 순서대로 제공합니다.") + @GetMapping("/trending") + ResponseEntity>> getTrendingPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "시청 이력 (History)", description = "유저가 최근 시청한 영상 목록을 제공합니다.") + @GetMapping("/history") + ResponseEntity>> getHistoryPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "북마크 목록 (Bookmark)", description = "유저가 북마크한 영상 목록을 제공합니다.") + @GetMapping("/bookmarks") + ResponseEntity>> getBookmarkPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java index 1de75e2..d8d5a85 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -4,31 +4,136 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +import com.ott.api_user.common.ContentSource; import com.ott.api_user.playlist.dto.request.PlaylistCondition; import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; import com.ott.api_user.playlist.service.PlaylistService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; + import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor -@RequestMapping("/playlist") +@RequestMapping("/playlists") public class PlaylistController implements PlaylistApi { private final PlaylistService playlistService; + // 1. 종합 추천 + @Override + public ResponseEntity>> getRecommendPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.RECOMMEND); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + // 2. Top 3 태그별 리스트 + @Override + public ResponseEntity> getTopTagPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "index") Integer index, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.TAG); + condition.setIndex(index); + condition.setExcludeMediaId(excludeMediaId); + + if (memberId != null) { + condition.setMemberId(memberId); + } + + Pageable pageable = PageRequest.of(page, size); + + return ResponseEntity.ok(SuccessResponse.of(playlistService.getTopTagPlaylistWithMetadata(condition, pageable))); + } + + // 3. 특정 태그 단건 리스트 + @Override + public ResponseEntity>> getTagPlaylists( + @PathVariable(value = "tagId") Long tagId, + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.TAG); + condition.setTagId(tagId); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + // 4. 인기 차트 @Override - public ResponseEntity>> getPlaylists( - PlaylistCondition condition, - @RequestParam(value = "page", defaultValue = "0") Integer pageParam, - @RequestParam(value = "size", defaultValue = "10") Integer sizeParam, + public ResponseEntity>> getTrendingPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, @AuthenticationPrincipal Long memberId) { - - // 토큰에서 추출한 유저 ID를 Condition 객체에 세팅 + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.TRENDING); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + + + // 5. 시청 이력 + @Override + public ResponseEntity>> getHistoryPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.HISTORY); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + + // 6. 북마크 + @Override + public ResponseEntity>> getBookmarkPlaylists( + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.BOOKMARK); + condition.setExcludeMediaId(excludeMediaId); + + return execute(condition, page, size, memberId); + } + + + // 공통 응답 메서드 + private ResponseEntity>> execute( + PlaylistCondition condition, Integer pageParam, Integer sizeParam, Long memberId) { + if (memberId != null) { condition.setMemberId(memberId); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java new file mode 100644 index 0000000..7149d72 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java @@ -0,0 +1,40 @@ +package com.ott.api_user.playlist.dto.response; + +import com.ott.common.web.response.PageResponse; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "홈 화면 태그별 섹션 응답 DTO") +public class TopTagPlaylistResponse { + + // 유빈님이 좋아하는 #로맨스 영화 + // 유빈님이 좋아하는 #로맨스 드라마 + @Schema(description = "카테고리 정보") + private CategoryInfo category; + + @Schema(description = "태그 정보") + private TagInfo tag; + + @Schema(description = "해당 태그의 미디어 목록") + private PageResponse medias; + + @Getter + @Builder + public static class CategoryInfo { + private Long id; + private String name; + } + + @Getter + @Builder + public static class TagInfo { + private Long id; + private String name; + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java index b712b67..baec08b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java @@ -3,12 +3,15 @@ import com.ott.api_user.common.ContentSource; import com.ott.api_user.playlist.dto.request.PlaylistCondition; import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; import com.ott.api_user.playlist.service.strategy.PlaylistStrategy; 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.media.domain.Media; +import com.ott.domain.tag.domain.Tag; + import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -23,6 +26,7 @@ public class PlaylistService { private final Map strategyMap; + private final PlaylistPreferenceService preferenceService; public PageResponse getPlaylists(PlaylistCondition condition, Pageable pageable) { @@ -52,6 +56,41 @@ public PageResponse getPlaylists(PlaylistCondition condition, } + public TopTagPlaylistResponse getTopTagPlaylistWithMetadata(PlaylistCondition condition, Pageable pageable){ + // 위 메서드를 통해 미디어 리스트 추출 + PageResponse mediaPage = getPlaylists(condition, pageable); + + List topTags = preferenceService.getTopTags(condition.getMemberId()); + + TopTagPlaylistResponse.CategoryInfo categoryInfo = null; + TopTagPlaylistResponse.TagInfo tagInfo = null; + + if (condition.getIndex() != null && condition.getIndex() < topTags.size()) { + Tag targetTag = topTags.get(condition.getIndex()); + + // TagInfo 객체 조립 + tagInfo = TopTagPlaylistResponse.TagInfo.builder() + .id(targetTag.getId()) + .name(targetTag.getName()) + .build(); + + // CategoryInfo 객체 조립 + if (targetTag.getCategory() != null) { + categoryInfo = TopTagPlaylistResponse.CategoryInfo.builder() + .id(targetTag.getCategory().getId()) + .name(targetTag.getCategory().getName()) + .build(); + } + } + + return TopTagPlaylistResponse.builder() + .category(categoryInfo) + .tag(tagInfo) + .medias(mediaPage) // 위에서 가져온 PageResponse를 그대로 넣음 + .build(); + } + + private PlaylistStrategy getStrategy(PlaylistCondition condition) { String strategyKey = determineStrategyKey(condition); From 29a805206391d2d73249551599a332e24ec47012 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:01:27 +0900 Subject: [PATCH 216/257] =?UTF-8?q?[FIX]:=20=EA=B2=80=EC=83=89=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EB=8F=84=20=20swagger=20=EC=97=90=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/controller/PlaylistApi.java | 9 +++++++++ .../playlist/controller/PlaylistController.java | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java index 1a9efa2..2c53866 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java @@ -88,4 +88,13 @@ ResponseEntity>> getBookmarkPlayl @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); + + @Operation(summary = "검색 상세 페이지 재생목록", description = "검색 결과에서 진입 시 종합 추천 리스트로 대체하여 제공합니다.") + @GetMapping("/search") + ResponseEntity>> getSearchPlaylists( + @Parameter(description = "현재 영상 ID", required = true) @RequestParam(value = "excludeMediaId") Long excludeMediaId, + @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java index d8d5a85..45c8583 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -129,6 +129,22 @@ public ResponseEntity>> getBookma return execute(condition, page, size, memberId); } + // 8. 검색 페이지에서 진입 + @Override + public ResponseEntity>> getSearchPlaylists( + @RequestParam(value = "excludeMediaId") Long excludeMediaId, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "10") Integer size, + @AuthenticationPrincipal Long memberId) { + + PlaylistCondition condition = new PlaylistCondition(); + condition.setContentSource(ContentSource.SEARCH); + condition.setExcludeMediaId(excludeMediaId); + + // 서비스단에서 RECOMMEND로 우회됨! + return execute(condition, page, size, memberId); + } + // 공통 응답 메서드 private ResponseEntity>> execute( From b4920182e527d06fec0c854f909e879d643a8afd Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 16:26:08 +0900 Subject: [PATCH 217/257] =?UTF-8?q?[FIX]:categoryId=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../category/controller/CategoryApi.java | 20 +++++++------------ .../controller/CategoryController.java | 2 ++ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java index c012e5c..ba2869d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryApi.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; import org.springframework.http.ResponseEntity; import java.util.List; @@ -22,11 +23,8 @@ public interface CategoryApi { @Operation(summary = "카테고리 목록 조회", description = "전체 카테고리 목록을 조회합니다.") @ApiResponses({ @ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = CategoryResponse.class) + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = CategoryResponse.class) ) ) }) @@ -36,20 +34,16 @@ public interface CategoryApi { @Operation(summary = "카테고리별 태그 목록 조회", description = "특정 카테고리에 속한 태그 목록을 조회합니다.") @ApiResponses({ @ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = TagResponse.class) + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagResponse.class) ) ), @ApiResponse( - responseCode = "404", - description = "카테고리를 찾을 수 없음", + responseCode = "404", description = "카테고리를 찾을 수 없음", content = @Content(mediaType = "application/json") ) }) ResponseEntity>> getTagsByCategory( - @Parameter(description = "카테고리 ID", example = "1") Long categoryId + @Positive @Parameter(description = "카테고리 ID", example = "1") Long categoryId ); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java index 0543c9c..9e73fdd 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/category/controller/CategoryController.java @@ -23,11 +23,13 @@ public class CategoryController implements CategoryApi { private final CategoryService categoryService; + @Override @GetMapping public ResponseEntity>> getCategories() { return ResponseEntity.ok(SuccessResponse.of(categoryService.getCategories())); } + @Override @GetMapping("{categoryId}/tags") public ResponseEntity>> getTagsByCategory( @Positive @PathVariable Long categoryId From 20822d9b015f140fd9838b874bad8fd1c9a9275c Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Mon, 2 Mar 2026 15:18:26 +0900 Subject: [PATCH 218/257] =?UTF-8?q?[FEAT]:=20=EC=8B=9C=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20URL=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeSeriesApi.java | 30 +++++++++- .../BackOfficeSeriesController.java | 13 ++++- .../dto/request/SeriesUpdateRequest.java | 45 +++++++++++++++ .../dto/response/SeriesUpdateResponse.java | 22 ++++++++ .../series/mapper/BackOfficeSeriesMapper.java | 17 ++++++ .../service/BackOfficeSeriesService.java | 55 +++++++++++++++++++ .../com/ott/domain/media/domain/Media.java | 6 ++ .../repository/MediaTagRepository.java | 1 + .../com/ott/domain/series/domain/Series.java | 4 ++ 9 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index bcfcf27..320c2a8 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -1,9 +1,11 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUpdateRequest; import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; @@ -16,6 +18,7 @@ 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 jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -90,7 +93,7 @@ ResponseEntity>> getSeries ) }) ResponseEntity> getSeriesDetail( - @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable Long mediaId + @Parameter(description = "미디어 ID", required = true, example = "1") @PathVariable("mediaId") Long mediaId ); @Operation(summary = "시리즈 메타데이터 업로드", description = "시리즈 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") @@ -112,4 +115,27 @@ ResponseEntity> createSeriesUpload( @Parameter(description = "SeriesUploadRequest를 참고해주세요.", required = true) @RequestBody SeriesUploadRequest request ); -} + + @Operation(summary = "시리즈 수정", description = "시리즈 메타데이터를 수정하고 필요 시 포스터/썸네일 교체용 Presigned URL을 발급합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "시리즈 수정 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = SeriesUpdateResponse.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> updateSeriesUpload( + @Parameter(description = "수정 대상 미디어 ID", required = true, example = "1") + @PathVariable("mediaId") Long mediaId, + + @Parameter(description = "SeriesUpdateRequest를 참고해주세요.", required = true) + @Valid @RequestBody SeriesUpdateRequest request + ); +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 010e016..93ecd39 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -1,9 +1,11 @@ package com.ott.api_admin.series.controller; +import com.ott.api_admin.series.dto.request.SeriesUpdateRequest; import com.ott.api_admin.series.dto.request.SeriesUploadRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.service.BackOfficeSeriesService; import com.ott.common.web.response.PageResponse; @@ -11,9 +13,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -66,4 +68,13 @@ public ResponseEntity> createSeriesUpload( ) { return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.createSeriesUpload(request))); } + + @Override + @PatchMapping("/{mediaId}/upload") + public ResponseEntity> updateSeriesUpload( + @PathVariable("mediaId") Long mediaId, + @Valid @RequestBody SeriesUpdateRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.updateSeriesUpload(mediaId, request))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java new file mode 100644 index 0000000..fbdddd9 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java @@ -0,0 +1,45 @@ +package com.ott.api_admin.series.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +@Schema(description = "시리즈 수정 요청") +public record SeriesUpdateRequest( + @Schema(type = "String", description = "시리즈 제목", example = "수정된 시리즈 제목") + @NotBlank + String title, + + @Schema(type = "String", description = "시리즈 설명", example = "수정된 시리즈 설명") + @NotBlank + String description, + + @Schema(type = "String", description = "출연진", example = "배우A, 배우B") + @NotBlank + String actors, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Long", description = "카테고리 ID", example = "1") + @NotNull + @Positive + Long categoryId, + + @Schema(type = "List", description = "태그 ID 목록", example = "[1, 2]") + @NotEmpty + List<@NotNull @Positive Long> tagIdList, + + @Schema(type = "String", description = "새 포스터 파일명 (교체 시에만 입력)", example = "poster-new.jpg") + String posterFileName, + + @Schema(type = "String", description = "새 썸네일 파일명 (교체 시에만 입력)", example = "thumb-new.jpg") + String thumbnailFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java new file mode 100644 index 0000000..04ff7a7 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java @@ -0,0 +1,22 @@ +package com.ott.api_admin.series.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "시리즈 수정 응답") +public record SeriesUpdateResponse( + @Schema(type = "Long", description = "시리즈 ID", example = "10") + Long seriesId, + + @Schema(type = "String", description = "포스터 Object Key (교체하지 않으면 null)", example = "series/10/poster/poster-new.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "썸네일 Object Key (교체하지 않으면 null)", example = "series/10/thumbnail/thumb-new.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "포스터 업로드 URL (교체하지 않으면 null)", example = "series/10/thumbnail/poster-new-new.jpg/~") + String posterUploadUrl, + + @Schema(type = "String", description = "썸네일 업로드 URL (교체하지 않으면 null)", example = "series/10/thumbnail/thumb-new.jpg/~") + String thumbnailUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java index 29f6eba..ac6468b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/mapper/BackOfficeSeriesMapper.java @@ -3,6 +3,7 @@ import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.domain.media.domain.Media; import com.ott.domain.media_tag.domain.MediaTag; @@ -70,6 +71,22 @@ public SeriesUploadResponse toSeriesUploadResponse( ); } + public SeriesUpdateResponse toSeriesUpdateResponse( + Long seriesId, + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + return new SeriesUpdateResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 2499ac2..8d95a78 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -1,9 +1,11 @@ package com.ott.api_admin.series.service; import com.ott.api_admin.series.dto.request.SeriesUploadRequest; +import com.ott.api_admin.series.dto.request.SeriesUpdateRequest; import com.ott.api_admin.series.dto.response.SeriesDetailResponse; import com.ott.api_admin.series.dto.response.SeriesListResponse; import com.ott.api_admin.series.dto.response.SeriesTitleListResponse; +import com.ott.api_admin.series.dto.response.SeriesUpdateResponse; import com.ott.api_admin.series.dto.response.SeriesUploadResponse; import com.ott.api_admin.series.mapper.BackOfficeSeriesMapper; import com.ott.api_admin.upload.support.MediaTagLinker; @@ -27,6 +29,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.Collections; import java.util.stream.Collectors; @@ -151,4 +154,56 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)) ); } + + @Transactional + public SeriesUpdateResponse updateSeriesUpload(Long mediaId, SeriesUpdateRequest request) { + Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + + Media media = series.getMedia(); + media.updateMetadata(request.title(), request.description(), request.publicStatus()); + series.updateActors(request.actors()); + + Long seriesId = series.getId(); + String posterObjectKey = null; + String thumbnailObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + + String nextPosterUrl = media.getPosterUrl(); + String nextThumbnailUrl = media.getThumbnailUrl(); + + if (StringUtils.hasText(request.posterFileName())) { + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + posterObjectKey = uploadHelper.buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); + nextPosterUrl = s3PresignService.toObjectUrl(posterObjectKey); + posterUploadUrl = s3PresignService.createPutPresignedUrl( + posterObjectKey, + uploadHelper.resolveImageContentType(sanitizedPosterFileName) + ); + } + + if (StringUtils.hasText(request.thumbnailFileName())) { + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + thumbnailObjectKey = uploadHelper.buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); + nextThumbnailUrl = s3PresignService.toObjectUrl(thumbnailObjectKey); + thumbnailUploadUrl = s3PresignService.createPutPresignedUrl( + thumbnailObjectKey, + uploadHelper.resolveImageContentType(sanitizedThumbnailFileName) + ); + } + + media.updateImageKeys(nextPosterUrl, nextThumbnailUrl); + + mediaTagRepository.deleteAllByMedia_Id(media.getId()); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + + return backOfficeSeriesMapper.toSeriesUpdateResponse( + seriesId, + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl + ); + } } diff --git a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java index 30adc95..10ffd22 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java +++ b/modules/domain/src/main/java/com/ott/domain/media/domain/Media.java @@ -70,6 +70,12 @@ public void updateImageKeys(String posterUrl, String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; } + public void updateMetadata(String title, String description, PublicStatus publicStatus) { + this.title = title; + this.description = description; + this.publicStatus = publicStatus; + } + // 북마크 증가 메소드 public void increaseBookmarkCount() { this.bookmarkCount++; diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java index 9fd6e01..db2b791 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { + void deleteAllByMedia_Id(Long mediaId); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java index d2af923..69f48c7 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java +++ b/modules/domain/src/main/java/com/ott/domain/series/domain/Series.java @@ -35,4 +35,8 @@ public class Series extends BaseEntity { @Column(name = "actors", nullable = false) private String actors; + + public void updateActors(String actors) { + this.actors = actors; + } } From 5ef2404b6c34a0af04fcefb727593ba63a72f160 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Mon, 2 Mar 2026 15:51:10 +0900 Subject: [PATCH 219/257] =?UTF-8?q?[FEAT]:=20=EC=BD=98=ED=85=90=EC=B8=A0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20URL=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 30 +++++++- .../BackOfficeContentsController.java | 24 +++++- .../dto/request/ContentsUpdateRequest.java | 61 +++++++++++++++ .../dto/response/ContentsUpdateResponse.java | 31 ++++++++ .../mapper/BackOfficeContentsMapper.java | 23 ++++++ .../service/BackOfficeContentsService.java | 77 +++++++++++++++++++ .../dto/response/SeriesUpdateResponse.java | 6 +- .../ott/domain/contents/domain/Contents.java | 7 ++ 8 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index 2962629..fb7b965 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -1,8 +1,10 @@ package com.ott.api_admin.content.controller; +import com.ott.api_admin.content.dto.request.ContentsUpdateRequest; import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; @@ -16,6 +18,7 @@ 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 jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -65,7 +68,7 @@ ResponseEntity>> getContents( ) }) ResponseEntity> getContentsDetail( - @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable Long mediaId + @Parameter(description = "조회할 콘텐츠의 미디어 ID", required = true) @PathVariable("mediaId") Long mediaId ); @Operation(summary = "콘텐츠 메타데이터 업로드", description = "콘텐츠 메타데이터를 생성하고 S3 업로드용 Presigned URL을 반환합니다.") @@ -85,6 +88,29 @@ ResponseEntity> getContentsDetail( }) ResponseEntity> createContentsUpload( @Parameter(description = "ContentsUploadRequest를 참고해주세요.", required = true) - @RequestBody ContentsUploadRequest request + @Valid @RequestBody ContentsUploadRequest request + ); + + @Operation(summary = "콘텐츠 수정", description = "콘텐츠 메타데이터를 수정하고 필요 시 파일 교체용 Presigned URL을 발급합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "콘텐츠 수정 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ContentsUpdateResponse.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> updateContentsUpload( + @Parameter(description = "수정 대상 콘텐츠의 미디어 ID", required = true, example = "1") + @PathVariable("mediaId") Long mediaId, + + @Parameter(description = "ContentsUpdateRequest를 참고해주세요.", required = true) + @Valid @RequestBody ContentsUpdateRequest request ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index ee7116e..7edfdac 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -1,8 +1,10 @@ package com.ott.api_admin.content.controller; +import com.ott.api_admin.content.dto.request.ContentsUpdateRequest; import com.ott.api_admin.content.dto.request.ContentsUploadRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.service.BackOfficeContentsService; import com.ott.common.web.response.PageResponse; @@ -11,8 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/back-office/admin/contents") @@ -37,7 +45,7 @@ public ResponseEntity>> getCo @Override @GetMapping("/{mediaId}") public ResponseEntity> getContentsDetail( - @PathVariable Long mediaId + @PathVariable("mediaId") Long mediaId ) { return ResponseEntity.ok( SuccessResponse.of(backOfficeContentsService.getContentsDetail(mediaId)) @@ -46,10 +54,18 @@ public ResponseEntity> getContentsDetail @Override @PostMapping("/upload") - // ADMIN 권한으로 콘텐츠 업로드 초기화를 수행합니다. public ResponseEntity> createContentsUpload( @Valid @RequestBody ContentsUploadRequest request ) { return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.createContentsUpload(request))); } + + @Override + @PatchMapping("/{mediaId}/upload") + public ResponseEntity> updateContentsUpload( + @PathVariable("mediaId") Long mediaId, + @Valid @RequestBody ContentsUpdateRequest request + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.updateContentsUpload(mediaId, request))); + } } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java new file mode 100644 index 0000000..d75a599 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java @@ -0,0 +1,61 @@ +package com.ott.api_admin.content.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +import java.util.List; + +@Schema(type = "Object", description = "콘텐츠 수정 요청") +public record ContentsUpdateRequest( + @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") + @Positive + Long seriesId, + + @Schema(type = "String", description = "콘텐츠 제목", example = "응답하라 1988 1화") + @NotBlank + String title, + + @Schema(type = "String", description = "콘텐츠 설명", example = "가족과 이웃의 따뜻한 이야기") + @NotBlank + String description, + + @Schema(type = "String", description = "출연진", example = "성동일, 이일화") + @NotBlank + String actors, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Long", description = "카테고리 ID", example = "1") + @NotNull + @Positive + Long categoryId, + + @Schema(type = "List", description = "태그 ID 목록", example = "[1, 2]") + @NotEmpty + List<@NotNull @Positive Long> tagIdList, + + @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "영상 크기(KB)", example = "512000") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "포스터 원본 파일명(교체 시에만 입력)", example = "poster-new.jpg") + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명(교체 시에만 입력)", example = "thumb-new.jpg") + String thumbnailFileName, + + @Schema(type = "String", description = "원본 영상 파일명(교체 시에만 입력)", example = "origin-new.mp4") + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java new file mode 100644 index 0000000..8b4bb63 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.content.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(type = "Object", description = "콘텐츠 수정 응답") +public record ContentsUpdateResponse( + @Schema(type = "Long", description = "콘텐츠 ID", example = "10") + Long contentsId, + + @Schema(type = "String", description = "포스터 Object Key(교체하지 않으면 null)", example = "contents/10/poster/poster-new.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "썸네일 Object Key(교체하지 않으면 null)", example = "contents/10/thumbnail/thumb-new.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "원본 영상 Object Key(교체하지 않으면 null)", example = "contents/10/origin/origin-new.mp4") + String originObjectKey, + + @Schema(type = "String", description = "마스터 플레이리스트 Object Key", example = "contents/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "포스터 업로드 URL(교체하지 않으면 null)", example = "~/contents/10/poster/poster-new.jpg?X-Amz-...") + String posterUploadUrl, + + @Schema(type = "String", description = "썸네일 업로드 URL(교체하지 않으면 null)", example = "~/contents/10/thumbnail/thumb-new.jpg?X-Amz-...") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "원본 영상 업로드 URL(교체하지 않으면 null)", example = "~/contents/10/origin/origin-new.mp4?X-Amz-...") + String originUploadUrl +) { +} \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java index f0850d5..fdb3fc1 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/mapper/BackOfficeContentsMapper.java @@ -2,6 +2,7 @@ import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.domain.contents.domain.Contents; import com.ott.domain.media.domain.Media; @@ -68,6 +69,28 @@ public ContentsUploadResponse toContentsUploadResponse( ); } + public ContentsUpdateResponse toContentsUpdateResponse( + Long contentsId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ContentsUpdateResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index 3145580..c883dac 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -1,8 +1,10 @@ package com.ott.api_admin.content.service; import com.ott.api_admin.content.dto.request.ContentsUploadRequest; +import com.ott.api_admin.content.dto.request.ContentsUpdateRequest; import com.ott.api_admin.content.dto.response.ContentsDetailResponse; import com.ott.api_admin.content.dto.response.ContentsListResponse; +import com.ott.api_admin.content.dto.response.ContentsUpdateResponse; import com.ott.api_admin.content.dto.response.ContentsUploadResponse; import com.ott.api_admin.content.mapper.BackOfficeContentsMapper; import com.ott.api_admin.upload.support.MediaTagLinker; @@ -29,6 +31,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.List; @@ -163,6 +166,80 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request ); } + @Transactional + public ContentsUpdateResponse updateContentsUpload(Long mediaId, ContentsUpdateRequest request) { + Contents contents = contentsRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + Media media = contents.getMedia(); + Series series = resolveSeries(request.seriesId()); + + media.updateMetadata(request.title(), request.description(), request.publicStatus()); + contents.updateMetadata(series, request.actors(), request.duration(), request.videoSize()); + + Long contentsId = contents.getId(); + String posterObjectKey = null; + String thumbnailObjectKey = null; + String originObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + String originUploadUrl = null; + + String nextPosterUrl = media.getPosterUrl(); + String nextThumbnailUrl = media.getThumbnailUrl(); + String nextOriginUrl = contents.getOriginUrl(); + String nextMasterPlaylistUrl = contents.getMasterPlaylistUrl(); + String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; + + if (StringUtils.hasText(request.posterFileName())) { + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + posterObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); + nextPosterUrl = s3PresignService.toObjectUrl(posterObjectKey); + posterUploadUrl = s3PresignService.createPutPresignedUrl( + posterObjectKey, + uploadHelper.resolveImageContentType(sanitizedPosterFileName) + ); + } + + if (StringUtils.hasText(request.thumbnailFileName())) { + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + thumbnailObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); + nextThumbnailUrl = s3PresignService.toObjectUrl(thumbnailObjectKey); + thumbnailUploadUrl = s3PresignService.createPutPresignedUrl( + thumbnailObjectKey, + uploadHelper.resolveImageContentType(sanitizedThumbnailFileName) + ); + } + + if (StringUtils.hasText(request.originFileName())) { + String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); + originObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); + nextOriginUrl = s3PresignService.toObjectUrl(originObjectKey); + nextMasterPlaylistUrl = s3PresignService.toObjectUrl(masterPlaylistObjectKey); + originUploadUrl = s3PresignService.createPutPresignedUrl( + originObjectKey, + uploadHelper.resolveVideoContentType(sanitizedOriginFileName) + ); + } + + media.updateImageKeys(nextPosterUrl, nextThumbnailUrl); + contents.updateStorageKeys(nextOriginUrl, nextMasterPlaylistUrl); + + mediaTagRepository.deleteAllByMedia_Id(media.getId()); + mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); + + return backOfficeContentsMapper.toContentsUpdateResponse( + contentsId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private Series resolveSeries(Long seriesId) { if (seriesId == null) { return null; diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java index 04ff7a7..8b1c5fc 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUpdateResponse.java @@ -13,10 +13,10 @@ public record SeriesUpdateResponse( @Schema(type = "String", description = "썸네일 Object Key (교체하지 않으면 null)", example = "series/10/thumbnail/thumb-new.jpg") String thumbnailObjectKey, - @Schema(type = "String", description = "포스터 업로드 URL (교체하지 않으면 null)", example = "series/10/thumbnail/poster-new-new.jpg/~") + @Schema(type = "String", description = "포스터 업로드 URL(교체하지 않으면 null)", example = "https://oplust-content.s3.ap-northeast-2.amazonaws.com/series/10/poster/poster-new.jpg?X-Amz-.../~") String posterUploadUrl, - @Schema(type = "String", description = "썸네일 업로드 URL (교체하지 않으면 null)", example = "series/10/thumbnail/thumb-new.jpg/~") + @Schema(type = "String", description = "썸네일 업로드 URL(교체하지 않으면 null)", example = "https://oplust-content.s3.ap-northeast-2.amazonaws.com/series/10/thumbnail/thumb-new.jpg?X-Amz-.../~") String thumbnailUploadUrl ) { -} +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java index a1b8900..2f94c6e 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/domain/Contents.java @@ -60,4 +60,11 @@ public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { this.originUrl = originUrl; this.masterPlaylistUrl = masterPlaylistUrl; } + + public void updateMetadata(Series series, String actors, Integer duration, Integer videoSize) { + this.series = series; + this.actors = actors; + this.duration = duration; + this.videoSize = videoSize; + } } From aae295035ac7e10d94e295419550c05022a3be82 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Mon, 2 Mar 2026 16:43:36 +0900 Subject: [PATCH 220/257] =?UTF-8?q?[OT-156]=20[FEAT]:=20=EC=88=8F=ED=8F=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20URL=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeShortFormApi.java | 31 ++++++- .../BackOfficeShortFormController.java | 16 +++- .../dto/request/ShortFormUpdateRequest.java | 49 +++++++++++ .../dto/response/ShortFormUpdateResponse.java | 31 +++++++ .../mapper/BackOfficeShortFormMapper.java | 23 +++++ .../service/BackOfficeShortFormService.java | 83 +++++++++++++++++++ .../domain/short_form/domain/ShortForm.java | 7 ++ 7 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java create mode 100644 apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index a7a5ea6..8ab8d46 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -3,7 +3,9 @@ import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUpdateRequest; import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; @@ -17,6 +19,7 @@ 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 jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PathVariable; @@ -82,7 +85,7 @@ ResponseEntity>> getO ), }) ResponseEntity> getShortFormDetail( - @Parameter(description = "조회할 숏폼의 미디어 ID", required = true) @PathVariable Long mediaId, + @Parameter(description = "조회할 숏폼의 미디어 ID", required = true) @PathVariable("mediaId") Long mediaId, Authentication authentication ); @@ -105,4 +108,28 @@ ResponseEntity> createShortFormUpload( @Parameter(description = "ShortFormUploadRequest 참고해주세요.", required = true) @RequestBody ShortFormUploadRequest request ); -} \ No newline at end of file + + @Operation(summary = "숏폼 수정", description = "숏폼 메타데이터를 수정하고 필요 시 파일 교체용 Presigned URL을 발급합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "숏폼 수정 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ShortFormUpdateResponse.class))} + ), + @ApiResponse( + responseCode = "400", description = "숏폼 수정 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ), + @ApiResponse( + responseCode = "403", description = "접근 권한 없음 (ADMIN, EDITOR 접근 가능)", + content = {@Content(mediaType = "application/json")} + ) + }) + ResponseEntity> updateShortFormUpload( + @Parameter(description = "수정 대상 숏폼 미디어 ID", required = true, example = "1") + @PathVariable("mediaId") Long mediaId, + + @Parameter(description = "ShortFormUpdateRequest 참고해주세요.", required = true) + @Valid @RequestBody ShortFormUpdateRequest request, + Authentication authentication + ); +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index 4d6bc32..3bd2855 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -3,7 +3,9 @@ import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; +import com.ott.api_admin.shortform.dto.request.ShortFormUpdateRequest; import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; import com.ott.api_admin.shortform.service.BackOfficeShortFormService; import com.ott.common.web.response.PageResponse; @@ -12,9 +14,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; +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; @@ -58,7 +60,7 @@ public ResponseEntity @Override @GetMapping("/{mediaId}") public ResponseEntity> getShortFormDetail( - @PathVariable Long mediaId, + @PathVariable("mediaId") Long mediaId, Authentication authentication ) { return ResponseEntity.ok( @@ -73,4 +75,14 @@ public ResponseEntity> createShortFormU ) { return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.createShortFormUpload(request))); } + + @Override + @PatchMapping("/{mediaId}/upload") + public ResponseEntity> updateShortFormUpload( + @PathVariable("mediaId") Long mediaId, + @Valid @RequestBody ShortFormUpdateRequest request, + Authentication authentication + ) { + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.updateShortFormUpload(mediaId, request, authentication))); + } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java new file mode 100644 index 0000000..4facb02 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java @@ -0,0 +1,49 @@ +package com.ott.api_admin.shortform.dto.request; + +import com.ott.domain.common.PublicStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +@Schema(type = "Object", description = "숏폼 수정 요청") +public record ShortFormUpdateRequest( + @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") + @Positive + Long seriesId, + + @Schema(type = "Long", description = "연결할 콘텐츠 ID(선택)", example = "2") + @Positive + Long contentsId, + + @Schema(type = "String", description = "숏폼 제목", example = "하이라이트 수정") + @NotBlank + String title, + + @Schema(type = "String", description = "숏폼 설명", example = "명장면 하이라이트 수정") + @NotBlank + String description, + + @Schema(type = "String", description = "공개 상태", example = "PUBLIC") + @NotNull + PublicStatus publicStatus, + + @Schema(type = "Integer", description = "영상 길이(초)", example = "60") + @PositiveOrZero + Integer duration, + + @Schema(type = "Integer", description = "영상 크기(KB)", example = "10240") + @PositiveOrZero + Integer videoSize, + + @Schema(type = "String", description = "포스터 원본 파일명(교체 시에만 입력)", example = "poster-new.jpg") + String posterFileName, + + @Schema(type = "String", description = "썸네일 원본 파일명(교체 시에만 입력)", example = "thumb-new.jpg") + String thumbnailFileName, + + @Schema(type = "String", description = "원본 영상 파일명(교체 시에만 입력)", example = "origin-new.mp4") + String originFileName +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java new file mode 100644 index 0000000..b4ae423 --- /dev/null +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java @@ -0,0 +1,31 @@ +package com.ott.api_admin.shortform.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(type = "Object", description = "숏폼 수정 응답") +public record ShortFormUpdateResponse( + @Schema(type = "Long", description = "숏폼 ID", example = "10") + Long shortFormId, + + @Schema(type = "String", description = "포스터 Object Key(교체하지 않으면 null)", example = "short-forms/10/poster/poster-new.jpg") + String posterObjectKey, + + @Schema(type = "String", description = "썸네일 Object Key(교체하지 않으면 null)", example = "short-forms/10/thumbnail/thumb-new.jpg") + String thumbnailObjectKey, + + @Schema(type = "String", description = "원본 영상 Object Key(교체하지 않으면 null)", example = "short-forms/10/origin/origin-new.mp4") + String originObjectKey, + + @Schema(type = "String", description = "마스터 플레이리스트 Object Key", example = "short-forms/10/transcoded/master.m3u8") + String masterPlaylistObjectKey, + + @Schema(type = "String", description = "포스터 업로드 URL(교체하지 않으면 null)", example = "~/short-forms/10/poster/poster-new.jpg?X-Amz-...") + String posterUploadUrl, + + @Schema(type = "String", description = "썸네일 업로드 URL(교체하지 않으면 null)", example = "~/short-forms/10/thumbnail/thumb-new.jpg?X-Amz-...") + String thumbnailUploadUrl, + + @Schema(type = "String", description = "원본 영상 업로드 URL(교체하지 않으면 null)", example = "~/short-forms/10/origin/origin-new.mp4?X-Amz-...") + String originUploadUrl +) { +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java index c339cab..70eae26 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/mapper/BackOfficeShortFormMapper.java @@ -3,6 +3,7 @@ import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; import com.ott.domain.common.MediaType; import com.ott.domain.media.domain.Media; @@ -83,6 +84,28 @@ public ShortFormUploadResponse toShortFormUploadResponse( ); } + public ShortFormUpdateResponse toShortFormUpdateResponse( + Long shortFormId, + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + return new ShortFormUpdateResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl + ); + } + private String extractCategoryName(List mediaTagList) { return mediaTagList.stream() .findFirst() diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 73af56d..fc6c5e8 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -1,9 +1,11 @@ package com.ott.api_admin.shortform.service; import com.ott.api_admin.shortform.dto.request.ShortFormUploadRequest; +import com.ott.api_admin.shortform.dto.request.ShortFormUpdateRequest; import com.ott.api_admin.shortform.dto.response.OriginMediaTitleListResponse; import com.ott.api_admin.shortform.dto.response.ShortFormDetailResponse; import com.ott.api_admin.shortform.dto.response.ShortFormListResponse; +import com.ott.api_admin.shortform.dto.response.ShortFormUpdateResponse; import com.ott.api_admin.shortform.dto.response.ShortFormUploadResponse; import com.ott.api_admin.shortform.mapper.BackOfficeShortFormMapper; import com.ott.api_admin.upload.support.UploadHelper; @@ -33,6 +35,7 @@ import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.List; import java.util.Map; import java.util.Optional; @@ -218,6 +221,86 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ uploadHelper.resolveVideoContentType(sanitizedOriginFileName))); } + @Transactional + public ShortFormUpdateResponse updateShortFormUpload(Long mediaId, ShortFormUpdateRequest request, Authentication authentication) { + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + Media media = shortForm.getMedia(); + Long memberId = (Long) authentication.getPrincipal(); + boolean isEditor = authentication.getAuthorities().stream() + .anyMatch(authority -> Role.EDITOR.getKey().equals(authority.getAuthority())); + if (isEditor && !media.getUploader().getId().equals(memberId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + validateExclusiveTarget(request.seriesId(), request.contentsId()); + Series series = resolveSeries(request.seriesId()); + Contents contents = resolveContents(request.contentsId()); + + media.updateMetadata(request.title(), request.description(), request.publicStatus()); + shortForm.updateMetadata(series, contents, request.duration(), request.videoSize()); + + Long shortFormId = shortForm.getId(); + String posterObjectKey = null; + String thumbnailObjectKey = null; + String originObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + String originUploadUrl = null; + + String nextPosterUrl = media.getPosterUrl(); + String nextThumbnailUrl = media.getThumbnailUrl(); + String nextOriginUrl = shortForm.getOriginUrl(); + String nextMasterPlaylistUrl = shortForm.getMasterPlaylistUrl(); + String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; + + if (StringUtils.hasText(request.posterFileName())) { + String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); + posterObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); + nextPosterUrl = s3PresignService.toObjectUrl(posterObjectKey); + posterUploadUrl = s3PresignService.createPutPresignedUrl( + posterObjectKey, + uploadHelper.resolveImageContentType(sanitizedPosterFileName)); + } + + if (StringUtils.hasText(request.thumbnailFileName())) { + String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); + thumbnailObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); + nextThumbnailUrl = s3PresignService.toObjectUrl(thumbnailObjectKey); + thumbnailUploadUrl = s3PresignService.createPutPresignedUrl( + thumbnailObjectKey, + uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)); + } + + if (StringUtils.hasText(request.originFileName())) { + String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); + originObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); + nextOriginUrl = s3PresignService.toObjectUrl(originObjectKey); + nextMasterPlaylistUrl = s3PresignService.toObjectUrl(masterPlaylistObjectKey); + originUploadUrl = s3PresignService.createPutPresignedUrl( + originObjectKey, + uploadHelper.resolveVideoContentType(sanitizedOriginFileName)); + } + + media.updateImageKeys(nextPosterUrl, nextThumbnailUrl); + shortForm.updateStorageKeys(nextOriginUrl, nextMasterPlaylistUrl); + + Long originMediaId = resolveOriginMediaId(series, contents); + mediaTagRepository.deleteAllByMedia_Id(media.getId()); + inheritOriginMediaTags(media, originMediaId); + + return backOfficeShortFormMapper.toShortFormUpdateResponse( + shortFormId, + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl); + } + private void validateExclusiveTarget(Long seriesId, Long contentsId) { if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java index e8cddfe..d136c71 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/domain/ShortForm.java @@ -64,6 +64,13 @@ public void updateStorageKeys(String originUrl, String masterPlaylistUrl) { this.masterPlaylistUrl = masterPlaylistUrl; } + public void updateMetadata(Series series, Contents contents, Integer duration, Integer videoSize) { + this.series = series; + this.contents = contents; + this.duration = duration; + this.videoSize = videoSize; + } + public Optional findOriginMedia() { if (series != null) return Optional.of(series.getMedia()); if (contents != null) return Optional.of(contents.getMedia()); From 1636573d79381f598d54ef74ce602810921e8935 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 3 Mar 2026 09:50:52 +0900 Subject: [PATCH 221/257] =?UTF-8?q?[REFACTOR]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20API=20=EC=9D=B8=EC=A6=9D=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 4 +++- .../BackOfficeContentsController.java | 8 ++++--- .../service/BackOfficeContentsService.java | 4 ++-- .../controller/BackOfficeSeriesApi.java | 6 ++++-- .../BackOfficeSeriesController.java | 6 ++++-- .../service/BackOfficeSeriesService.java | 4 ++-- .../controller/BackOfficeShortFormApi.java | 4 +++- .../BackOfficeShortFormController.java | 6 ++++-- .../service/BackOfficeShortFormService.java | 4 ++-- .../upload/support/UploadHelper.java | 21 +------------------ 10 files changed, 30 insertions(+), 37 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index fb7b965..1923527 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -88,7 +89,8 @@ ResponseEntity> getContentsDetail( }) ResponseEntity> createContentsUpload( @Parameter(description = "ContentsUploadRequest를 참고해주세요.", required = true) - @Valid @RequestBody ContentsUploadRequest request + @Valid @RequestBody ContentsUploadRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @Operation(summary = "콘텐츠 수정", description = "콘텐츠 메타데이터를 수정하고 필요 시 파일 교체용 Presigned URL을 발급합니다.") diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index 7edfdac..aaf6a0b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -13,6 +13,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -55,9 +56,10 @@ public ResponseEntity> getContentsDetail @Override @PostMapping("/upload") public ResponseEntity> createContentsUpload( - @Valid @RequestBody ContentsUploadRequest request + @Valid @RequestBody ContentsUploadRequest request, + @AuthenticationPrincipal Long memberId ) { - return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.createContentsUpload(request))); + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.createContentsUpload(request, memberId))); } @Override @@ -68,4 +70,4 @@ public ResponseEntity> updateContentsUpl ) { return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.updateContentsUpload(mediaId, request))); } -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index c883dac..f996159 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -98,8 +98,8 @@ public ContentsDetailResponse getContentsDetail(Long mediaId) { @Transactional // 콘텐츠/미디어 레코드를 생성하고 S3 업로드용 Presigned URL을 발급합니다. - public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request) { - Member uploader = uploadHelper.resolveUploader(); + public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request, Long memberId) { + Member uploader = uploadHelper.resolveUploader(memberId); Series series = resolveSeries(request.seriesId()); // S3 object key 안정성을 위해 파일명을 정규화합니다. diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 320c2a8..29e7fbf 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -113,7 +114,8 @@ ResponseEntity> getSeriesDetail( }) ResponseEntity> createSeriesUpload( @Parameter(description = "SeriesUploadRequest를 참고해주세요.", required = true) - @RequestBody SeriesUploadRequest request + @RequestBody SeriesUploadRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @Operation(summary = "시리즈 수정", description = "시리즈 메타데이터를 수정하고 필요 시 포스터/썸네일 교체용 Presigned URL을 발급합니다.") @@ -138,4 +140,4 @@ ResponseEntity> updateSeriesUpload( @Parameter(description = "SeriesUpdateRequest를 참고해주세요.", required = true) @Valid @RequestBody SeriesUpdateRequest request ); -} \ No newline at end of file +} diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 93ecd39..705b26b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -13,6 +13,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PatchMapping; @@ -64,9 +65,10 @@ public ResponseEntity> getSeriesDetail(@Pa @Override @PostMapping("/upload") public ResponseEntity> createSeriesUpload( - @Valid @RequestBody SeriesUploadRequest request + @Valid @RequestBody SeriesUploadRequest request, + @AuthenticationPrincipal Long memberId ) { - return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.createSeriesUpload(request))); + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.createSeriesUpload(request, memberId))); } @Override diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 8d95a78..318bedf 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -111,8 +111,8 @@ public SeriesDetailResponse getSeriesDetail(Long mediaId) { } @Transactional - public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request) { - Member uploader = uploadHelper.resolveUploader(); + public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request, Long memberId) { + Member uploader = uploadHelper.resolveUploader(memberId); String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index 8ab8d46..8991291 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -22,6 +22,7 @@ import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -106,7 +107,8 @@ ResponseEntity> getShortFormDetail( }) ResponseEntity> createShortFormUpload( @Parameter(description = "ShortFormUploadRequest 참고해주세요.", required = true) - @RequestBody ShortFormUploadRequest request + @RequestBody ShortFormUploadRequest request, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @Operation(summary = "숏폼 수정", description = "숏폼 메타데이터를 수정하고 필요 시 파일 교체용 Presigned URL을 발급합니다.") diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index 3bd2855..dbb0ede 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -71,9 +72,10 @@ public ResponseEntity> getShortFormDeta @Override @PostMapping("/upload") public ResponseEntity> createShortFormUpload( - @Valid @RequestBody ShortFormUploadRequest request + @Valid @RequestBody ShortFormUploadRequest request, + @AuthenticationPrincipal Long memberId ) { - return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.createShortFormUpload(request))); + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.createShortFormUpload(request, memberId))); } @Override diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index fc6c5e8..98f526d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -154,10 +154,10 @@ public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication a } @Transactional - public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request) { + public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request, Long memberId) { validateExclusiveTarget(request.seriesId(), request.contentsId()); - Member uploader = uploadHelper.resolveUploader(); + Member uploader = uploadHelper.resolveUploader(memberId); Series series = resolveSeries(request.seriesId()); Contents contents = resolveContents(request.contentsId()); String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java index 48d967e..a0c3854 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -5,8 +5,6 @@ import com.ott.domain.member.domain.Member; import com.ott.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @RequiredArgsConstructor @@ -56,24 +54,7 @@ public String sanitizeFileName(String fileName) { return sanitizedBaseName + "." + sanitizedExtension; } - public Member resolveUploader() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Object principal = authentication.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Long memberId; - try { - memberId = Long.valueOf(String.valueOf(principal)); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - + public Member resolveUploader(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); } From c26e45504e23cfc4e0c39a5e022ecfc3698ad2d8 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 3 Mar 2026 09:50:52 +0900 Subject: [PATCH 222/257] =?UTF-8?q?[REFACTOR]:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A4=80=EB=B9=84=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?UploadHelper=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BackOfficeContentsService.java | 114 +++----- .../service/BackOfficeSeriesService.java | 72 ++---- .../service/BackOfficeShortFormService.java | 116 +++------ .../upload/support/UploadHelper.java | 244 ++++++++++++++++++ 4 files changed, 350 insertions(+), 196 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index f996159..d67f624 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -24,14 +24,12 @@ import com.ott.domain.member.domain.Member; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; -import com.ott.infra.s3.service.S3PresignService; 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 org.springframework.util.StringUtils; import java.util.List; @@ -45,7 +43,6 @@ public class BackOfficeContentsService { private final MediaTagRepository mediaTagRepository; private final ContentsRepository contentsRepository; private final SeriesRepository seriesRepository; - private final S3PresignService s3PresignService; private final UploadHelper uploadHelper; private final MediaTagLinker mediaTagLinker; @@ -103,10 +100,6 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request Series series = resolveSeries(request.seriesId()); // S3 object key 안정성을 위해 파일명을 정규화합니다. - String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); - String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); - Media media = mediaRepository.save( Media.builder() .uploader(uploader) @@ -138,31 +131,30 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request ); Long contentsId = contents.getId(); - String posterObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); - String originObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); - String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; + UploadHelper.MediaCreateUploadResult mediaCreateUploadResult = uploadHelper.prepareMediaCreate( + "contents", contentsId, request.posterFileName(), request.thumbnailFileName(), request.originFileName() + ); media.updateImageKeys( - s3PresignService.toObjectUrl(posterObjectKey), - s3PresignService.toObjectUrl(thumbnailObjectKey) + mediaCreateUploadResult.posterObjectUrl(), + mediaCreateUploadResult.thumbnailObjectUrl() ); contents.updateStorageKeys( - s3PresignService.toObjectUrl(originObjectKey), - s3PresignService.toObjectUrl(masterPlaylistObjectKey) + mediaCreateUploadResult.originObjectUrl(), + mediaCreateUploadResult.masterPlaylistObjectUrl() ); mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); return backOfficeContentsMapper.toContentsUploadResponse( contentsId, - posterObjectKey, - thumbnailObjectKey, - originObjectKey, - masterPlaylistObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)), - s3PresignService.createPutPresignedUrl(originObjectKey, uploadHelper.resolveVideoContentType(sanitizedOriginFileName)) + mediaCreateUploadResult.posterObjectKey(), + mediaCreateUploadResult.thumbnailObjectKey(), + mediaCreateUploadResult.originObjectKey(), + mediaCreateUploadResult.masterPlaylistObjectKey(), + mediaCreateUploadResult.posterUploadUrl(), + mediaCreateUploadResult.thumbnailUploadUrl(), + mediaCreateUploadResult.originUploadUrl() ); } @@ -178,65 +170,39 @@ public ContentsUpdateResponse updateContentsUpload(Long mediaId, ContentsUpdateR contents.updateMetadata(series, request.actors(), request.duration(), request.videoSize()); Long contentsId = contents.getId(); - String posterObjectKey = null; - String thumbnailObjectKey = null; - String originObjectKey = null; - String posterUploadUrl = null; - String thumbnailUploadUrl = null; - String originUploadUrl = null; - - String nextPosterUrl = media.getPosterUrl(); - String nextThumbnailUrl = media.getThumbnailUrl(); - String nextOriginUrl = contents.getOriginUrl(); - String nextMasterPlaylistUrl = contents.getMasterPlaylistUrl(); - String masterPlaylistObjectKey = "contents/" + contentsId + "/transcoded/master.m3u8"; - - if (StringUtils.hasText(request.posterFileName())) { - String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); - posterObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "poster", sanitizedPosterFileName); - nextPosterUrl = s3PresignService.toObjectUrl(posterObjectKey); - posterUploadUrl = s3PresignService.createPutPresignedUrl( - posterObjectKey, - uploadHelper.resolveImageContentType(sanitizedPosterFileName) - ); - } - - if (StringUtils.hasText(request.thumbnailFileName())) { - String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); - thumbnailObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "thumbnail", sanitizedThumbnailFileName); - nextThumbnailUrl = s3PresignService.toObjectUrl(thumbnailObjectKey); - thumbnailUploadUrl = s3PresignService.createPutPresignedUrl( - thumbnailObjectKey, - uploadHelper.resolveImageContentType(sanitizedThumbnailFileName) - ); - } - - if (StringUtils.hasText(request.originFileName())) { - String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); - originObjectKey = uploadHelper.buildObjectKey("contents", contentsId, "origin", sanitizedOriginFileName); - nextOriginUrl = s3PresignService.toObjectUrl(originObjectKey); - nextMasterPlaylistUrl = s3PresignService.toObjectUrl(masterPlaylistObjectKey); - originUploadUrl = s3PresignService.createPutPresignedUrl( - originObjectKey, - uploadHelper.resolveVideoContentType(sanitizedOriginFileName) - ); - } + UploadHelper.MediaUpdateUploadResult mediaUpdateUploadResult = uploadHelper.prepareMediaUpdate( + "contents", + contentsId, + request.posterFileName(), + request.thumbnailFileName(), + request.originFileName(), + media.getPosterUrl(), + media.getThumbnailUrl(), + contents.getOriginUrl(), + contents.getMasterPlaylistUrl() + ); - media.updateImageKeys(nextPosterUrl, nextThumbnailUrl); - contents.updateStorageKeys(nextOriginUrl, nextMasterPlaylistUrl); + media.updateImageKeys( + mediaUpdateUploadResult.nextPosterUrl(), + mediaUpdateUploadResult.nextThumbnailUrl() + ); + contents.updateStorageKeys( + mediaUpdateUploadResult.nextOriginUrl(), + mediaUpdateUploadResult.nextMasterPlaylistUrl() + ); mediaTagRepository.deleteAllByMedia_Id(media.getId()); mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); return backOfficeContentsMapper.toContentsUpdateResponse( contentsId, - posterObjectKey, - thumbnailObjectKey, - originObjectKey, - masterPlaylistObjectKey, - posterUploadUrl, - thumbnailUploadUrl, - originUploadUrl + mediaUpdateUploadResult.posterObjectKey(), + mediaUpdateUploadResult.thumbnailObjectKey(), + mediaUpdateUploadResult.originObjectKey(), + mediaUpdateUploadResult.masterPlaylistObjectKey(), + mediaUpdateUploadResult.posterUploadUrl(), + mediaUpdateUploadResult.thumbnailUploadUrl(), + mediaUpdateUploadResult.originUploadUrl() ); } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index 318bedf..a2e2e03 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -22,14 +22,12 @@ import com.ott.domain.member.domain.Member; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; -import com.ott.infra.s3.service.S3PresignService; 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 org.springframework.util.StringUtils; import java.util.Collections; import java.util.stream.Collectors; @@ -45,7 +43,6 @@ public class BackOfficeSeriesService { private final MediaRepository mediaRepository; private final MediaTagRepository mediaTagRepository; private final SeriesRepository seriesRepository; - private final S3PresignService s3PresignService; private final UploadHelper uploadHelper; private final MediaTagLinker mediaTagLinker; @@ -113,8 +110,6 @@ public SeriesDetailResponse getSeriesDetail(Long mediaId) { @Transactional public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request, Long memberId) { Member uploader = uploadHelper.resolveUploader(memberId); - String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); Media media = mediaRepository.save( Media.builder() @@ -138,20 +133,21 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request, Long ); Long seriesId = series.getId(); - String posterObjectKey = uploadHelper.buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); - String thumbnailObjectKey = uploadHelper.buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); + UploadHelper.ImageCreateUploadResult imageCreateUploadResult = uploadHelper.prepareImageCreate( + "series", seriesId, request.posterFileName(), request.thumbnailFileName() + ); media.updateImageKeys( - s3PresignService.toObjectUrl(posterObjectKey), - s3PresignService.toObjectUrl(thumbnailObjectKey) + imageCreateUploadResult.posterObjectUrl(), + imageCreateUploadResult.thumbnailObjectUrl() ); mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); return backOfficeSeriesMapper.toSeriesUploadResponse( seriesId, - posterObjectKey, - thumbnailObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, uploadHelper.resolveImageContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)) + imageCreateUploadResult.posterObjectKey(), + imageCreateUploadResult.thumbnailObjectKey(), + imageCreateUploadResult.posterUploadUrl(), + imageCreateUploadResult.thumbnailUploadUrl() ); } @@ -165,45 +161,29 @@ public SeriesUpdateResponse updateSeriesUpload(Long mediaId, SeriesUpdateRequest series.updateActors(request.actors()); Long seriesId = series.getId(); - String posterObjectKey = null; - String thumbnailObjectKey = null; - String posterUploadUrl = null; - String thumbnailUploadUrl = null; - - String nextPosterUrl = media.getPosterUrl(); - String nextThumbnailUrl = media.getThumbnailUrl(); - - if (StringUtils.hasText(request.posterFileName())) { - String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); - posterObjectKey = uploadHelper.buildObjectKey("series", seriesId, "poster", sanitizedPosterFileName); - nextPosterUrl = s3PresignService.toObjectUrl(posterObjectKey); - posterUploadUrl = s3PresignService.createPutPresignedUrl( - posterObjectKey, - uploadHelper.resolveImageContentType(sanitizedPosterFileName) - ); - } - - if (StringUtils.hasText(request.thumbnailFileName())) { - String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); - thumbnailObjectKey = uploadHelper.buildObjectKey("series", seriesId, "thumbnail", sanitizedThumbnailFileName); - nextThumbnailUrl = s3PresignService.toObjectUrl(thumbnailObjectKey); - thumbnailUploadUrl = s3PresignService.createPutPresignedUrl( - thumbnailObjectKey, - uploadHelper.resolveImageContentType(sanitizedThumbnailFileName) - ); - } - - media.updateImageKeys(nextPosterUrl, nextThumbnailUrl); + UploadHelper.ImageUpdateUploadResult imageUpdateUploadResult = uploadHelper.prepareImageUpdate( + "series", + seriesId, + request.posterFileName(), + request.thumbnailFileName(), + media.getPosterUrl(), + media.getThumbnailUrl() + ); + + media.updateImageKeys( + imageUpdateUploadResult.nextPosterUrl(), + imageUpdateUploadResult.nextThumbnailUrl() + ); mediaTagRepository.deleteAllByMedia_Id(media.getId()); mediaTagLinker.linkTags(media, request.categoryId(), request.tagIdList()); return backOfficeSeriesMapper.toSeriesUpdateResponse( seriesId, - posterObjectKey, - thumbnailObjectKey, - posterUploadUrl, - thumbnailUploadUrl + imageUpdateUploadResult.posterObjectKey(), + imageUpdateUploadResult.thumbnailObjectKey(), + imageUpdateUploadResult.posterUploadUrl(), + imageUpdateUploadResult.thumbnailUploadUrl() ); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 98f526d..3e83780 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -25,7 +25,6 @@ import com.ott.domain.member.domain.Role; import com.ott.domain.series.domain.Series; import com.ott.domain.series.repository.SeriesRepository; -import com.ott.infra.s3.service.S3PresignService; import com.ott.domain.short_form.domain.ShortForm; import com.ott.domain.short_form.repository.ShortFormRepository; import lombok.RequiredArgsConstructor; @@ -35,7 +34,6 @@ import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,7 +50,6 @@ public class BackOfficeShortFormService { private final SeriesRepository seriesRepository; private final ContentsRepository contentsRepository; private final ShortFormRepository shortFormRepository; - private final S3PresignService s3PresignService; private final UploadHelper uploadHelper; @Transactional(readOnly = true) @@ -160,9 +157,6 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ Member uploader = uploadHelper.resolveUploader(memberId); Series series = resolveSeries(request.seriesId()); Contents contents = resolveContents(request.contentsId()); - String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); - String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); - String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); Media media = mediaRepository.save( Media.builder() @@ -189,36 +183,29 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ .build()); Long shortFormId = shortForm.getId(); - String posterObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "poster", - sanitizedPosterFileName); - String thumbnailObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "thumbnail", - sanitizedThumbnailFileName); - String originObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "origin", - sanitizedOriginFileName); - String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; + UploadHelper.MediaCreateUploadResult mediaCreateUploadResult = uploadHelper.prepareMediaCreate( + "short-forms", shortFormId, request.posterFileName(), request.thumbnailFileName(), request.originFileName() + ); media.updateImageKeys( - s3PresignService.toObjectUrl(posterObjectKey), - s3PresignService.toObjectUrl(thumbnailObjectKey)); + mediaCreateUploadResult.posterObjectUrl(), + mediaCreateUploadResult.thumbnailObjectUrl()); shortForm.updateStorageKeys( - s3PresignService.toObjectUrl(originObjectKey), - s3PresignService.toObjectUrl(masterPlaylistObjectKey)); + mediaCreateUploadResult.originObjectUrl(), + mediaCreateUploadResult.masterPlaylistObjectUrl()); Long originMediaId = resolveOriginMediaId(series, contents); inheritOriginMediaTags(media, originMediaId); return backOfficeShortFormMapper.toShortFormUploadResponse( shortFormId, - posterObjectKey, - thumbnailObjectKey, - originObjectKey, - masterPlaylistObjectKey, - s3PresignService.createPutPresignedUrl(posterObjectKey, - uploadHelper.resolveImageContentType(sanitizedPosterFileName)), - s3PresignService.createPutPresignedUrl(thumbnailObjectKey, - uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)), - s3PresignService.createPutPresignedUrl(originObjectKey, - uploadHelper.resolveVideoContentType(sanitizedOriginFileName))); + mediaCreateUploadResult.posterObjectKey(), + mediaCreateUploadResult.thumbnailObjectKey(), + mediaCreateUploadResult.originObjectKey(), + mediaCreateUploadResult.masterPlaylistObjectKey(), + mediaCreateUploadResult.posterUploadUrl(), + mediaCreateUploadResult.thumbnailUploadUrl(), + mediaCreateUploadResult.originUploadUrl()); } @Transactional @@ -242,49 +229,26 @@ public ShortFormUpdateResponse updateShortFormUpload(Long mediaId, ShortFormUpda shortForm.updateMetadata(series, contents, request.duration(), request.videoSize()); Long shortFormId = shortForm.getId(); - String posterObjectKey = null; - String thumbnailObjectKey = null; - String originObjectKey = null; - String posterUploadUrl = null; - String thumbnailUploadUrl = null; - String originUploadUrl = null; - - String nextPosterUrl = media.getPosterUrl(); - String nextThumbnailUrl = media.getThumbnailUrl(); - String nextOriginUrl = shortForm.getOriginUrl(); - String nextMasterPlaylistUrl = shortForm.getMasterPlaylistUrl(); - String masterPlaylistObjectKey = "short-forms/" + shortFormId + "/transcoded/master.m3u8"; - - if (StringUtils.hasText(request.posterFileName())) { - String sanitizedPosterFileName = uploadHelper.sanitizeFileName(request.posterFileName()); - posterObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "poster", sanitizedPosterFileName); - nextPosterUrl = s3PresignService.toObjectUrl(posterObjectKey); - posterUploadUrl = s3PresignService.createPutPresignedUrl( - posterObjectKey, - uploadHelper.resolveImageContentType(sanitizedPosterFileName)); - } - - if (StringUtils.hasText(request.thumbnailFileName())) { - String sanitizedThumbnailFileName = uploadHelper.sanitizeFileName(request.thumbnailFileName()); - thumbnailObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "thumbnail", sanitizedThumbnailFileName); - nextThumbnailUrl = s3PresignService.toObjectUrl(thumbnailObjectKey); - thumbnailUploadUrl = s3PresignService.createPutPresignedUrl( - thumbnailObjectKey, - uploadHelper.resolveImageContentType(sanitizedThumbnailFileName)); - } - - if (StringUtils.hasText(request.originFileName())) { - String sanitizedOriginFileName = uploadHelper.sanitizeFileName(request.originFileName()); - originObjectKey = uploadHelper.buildObjectKey("short-forms", shortFormId, "origin", sanitizedOriginFileName); - nextOriginUrl = s3PresignService.toObjectUrl(originObjectKey); - nextMasterPlaylistUrl = s3PresignService.toObjectUrl(masterPlaylistObjectKey); - originUploadUrl = s3PresignService.createPutPresignedUrl( - originObjectKey, - uploadHelper.resolveVideoContentType(sanitizedOriginFileName)); - } + UploadHelper.MediaUpdateUploadResult mediaUpdateUploadResult = uploadHelper.prepareMediaUpdate( + "short-forms", + shortFormId, + request.posterFileName(), + request.thumbnailFileName(), + request.originFileName(), + media.getPosterUrl(), + media.getThumbnailUrl(), + shortForm.getOriginUrl(), + shortForm.getMasterPlaylistUrl() + ); - media.updateImageKeys(nextPosterUrl, nextThumbnailUrl); - shortForm.updateStorageKeys(nextOriginUrl, nextMasterPlaylistUrl); + media.updateImageKeys( + mediaUpdateUploadResult.nextPosterUrl(), + mediaUpdateUploadResult.nextThumbnailUrl() + ); + shortForm.updateStorageKeys( + mediaUpdateUploadResult.nextOriginUrl(), + mediaUpdateUploadResult.nextMasterPlaylistUrl() + ); Long originMediaId = resolveOriginMediaId(series, contents); mediaTagRepository.deleteAllByMedia_Id(media.getId()); @@ -292,13 +256,13 @@ public ShortFormUpdateResponse updateShortFormUpload(Long mediaId, ShortFormUpda return backOfficeShortFormMapper.toShortFormUpdateResponse( shortFormId, - posterObjectKey, - thumbnailObjectKey, - originObjectKey, - masterPlaylistObjectKey, - posterUploadUrl, - thumbnailUploadUrl, - originUploadUrl); + mediaUpdateUploadResult.posterObjectKey(), + mediaUpdateUploadResult.thumbnailObjectKey(), + mediaUpdateUploadResult.originObjectKey(), + mediaUpdateUploadResult.masterPlaylistObjectKey(), + mediaUpdateUploadResult.posterUploadUrl(), + mediaUpdateUploadResult.thumbnailUploadUrl(), + mediaUpdateUploadResult.originUploadUrl()); } private void validateExclusiveTarget(Long seriesId, Long contentsId) { diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java index a0c3854..1723801 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/upload/support/UploadHelper.java @@ -4,14 +4,17 @@ import com.ott.common.web.exception.ErrorCode; import com.ott.domain.member.domain.Member; import com.ott.domain.member.repository.MemberRepository; +import com.ott.infra.s3.service.S3PresignService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; @RequiredArgsConstructor @Component public class UploadHelper { private final MemberRepository memberRepository; + private final S3PresignService s3PresignService; public String buildObjectKey(String resourceRoot, Long resourceId, String assetType, String fileName) { return resourceRoot + "/" + resourceId + "/" + assetType + "/" + fileName; @@ -58,4 +61,245 @@ public Member resolveUploader(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED)); } + + public UploadFileResult prepareRequiredUpload( + String resourceRoot, + Long resourceId, + String assetType, + String fileName, + boolean isVideo + ) { + String sanitizedFileName = sanitizeFileName(fileName); + String objectKey = buildObjectKey(resourceRoot, resourceId, assetType, sanitizedFileName); + String contentType = isVideo + ? resolveVideoContentType(sanitizedFileName) + : resolveImageContentType(sanitizedFileName); + String objectUrl = s3PresignService.toObjectUrl(objectKey); + String uploadUrl = s3PresignService.createPutPresignedUrl(objectKey, contentType); + return new UploadFileResult(objectKey, objectUrl, uploadUrl); + } + + public UploadFileResult prepareOptionalUpload( + String resourceRoot, + Long resourceId, + String assetType, + String fileName, + boolean isVideo + ) { + if (!StringUtils.hasText(fileName)) { + return null; + } + return prepareRequiredUpload(resourceRoot, resourceId, assetType, fileName, isVideo); + } + + public String buildMasterPlaylistObjectKey(String resourceRoot, Long resourceId) { + return resourceRoot + "/" + resourceId + "/transcoded/master.m3u8"; + } + + public String toObjectUrl(String objectKey) { + return s3PresignService.toObjectUrl(objectKey); + } + + public ImageCreateUploadResult prepareImageCreate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName + ) { + UploadFileResult posterUpload = prepareRequiredUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareRequiredUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + + return new ImageCreateUploadResult( + posterUpload.objectKey(), + thumbnailUpload.objectKey(), + posterUpload.objectUrl(), + thumbnailUpload.objectUrl(), + posterUpload.uploadUrl(), + thumbnailUpload.uploadUrl() + ); + } + + public ImageUpdateUploadResult prepareImageUpdate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName, + String currentPosterUrl, + String currentThumbnailUrl + ) { + UploadFileResult posterUpload = prepareOptionalUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareOptionalUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + + String finalPosterUrl = currentPosterUrl; + String finalThumbnailUrl = currentThumbnailUrl; + String posterObjectKey = null; + String thumbnailObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + + if (posterUpload != null) { + finalPosterUrl = posterUpload.objectUrl(); + posterObjectKey = posterUpload.objectKey(); + posterUploadUrl = posterUpload.uploadUrl(); + } + if (thumbnailUpload != null) { + finalThumbnailUrl = thumbnailUpload.objectUrl(); + thumbnailObjectKey = thumbnailUpload.objectKey(); + thumbnailUploadUrl = thumbnailUpload.uploadUrl(); + } + + return new ImageUpdateUploadResult( + posterObjectKey, + thumbnailObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + finalPosterUrl, + finalThumbnailUrl + ); + } + + public MediaCreateUploadResult prepareMediaCreate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName, + String originFileName + ) { + UploadFileResult posterUpload = prepareRequiredUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareRequiredUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + UploadFileResult originUpload = prepareRequiredUpload(resourceRoot, resourceId, "origin", originFileName, true); + + String masterPlaylistObjectKey = buildMasterPlaylistObjectKey(resourceRoot, resourceId); + String masterPlaylistObjectUrl = toObjectUrl(masterPlaylistObjectKey); + + return new MediaCreateUploadResult( + posterUpload.objectKey(), + thumbnailUpload.objectKey(), + originUpload.objectKey(), + masterPlaylistObjectKey, + posterUpload.objectUrl(), + thumbnailUpload.objectUrl(), + originUpload.objectUrl(), + masterPlaylistObjectUrl, + posterUpload.uploadUrl(), + thumbnailUpload.uploadUrl(), + originUpload.uploadUrl() + ); + } + + public MediaUpdateUploadResult prepareMediaUpdate( + String resourceRoot, + Long resourceId, + String posterFileName, + String thumbnailFileName, + String originFileName, + String currentPosterUrl, + String currentThumbnailUrl, + String currentOriginUrl, + String currentMasterPlaylistUrl + ) { + UploadFileResult posterUpload = prepareOptionalUpload(resourceRoot, resourceId, "poster", posterFileName, false); + UploadFileResult thumbnailUpload = prepareOptionalUpload(resourceRoot, resourceId, "thumbnail", thumbnailFileName, false); + UploadFileResult originUpload = prepareOptionalUpload(resourceRoot, resourceId, "origin", originFileName, true); + + String posterObjectKey = null; + String thumbnailObjectKey = null; + String originObjectKey = null; + String posterUploadUrl = null; + String thumbnailUploadUrl = null; + String originUploadUrl = null; + String finalPosterUrl = currentPosterUrl; + String finalThumbnailUrl = currentThumbnailUrl; + String finalOriginUrl = currentOriginUrl; + String masterPlaylistObjectKey = buildMasterPlaylistObjectKey(resourceRoot, resourceId); + String finalMasterPlaylistUrl = currentMasterPlaylistUrl; + + if (posterUpload != null) { + posterObjectKey = posterUpload.objectKey(); + posterUploadUrl = posterUpload.uploadUrl(); + finalPosterUrl = posterUpload.objectUrl(); + } + if (thumbnailUpload != null) { + thumbnailObjectKey = thumbnailUpload.objectKey(); + thumbnailUploadUrl = thumbnailUpload.uploadUrl(); + finalThumbnailUrl = thumbnailUpload.objectUrl(); + } + if (originUpload != null) { + originObjectKey = originUpload.objectKey(); + originUploadUrl = originUpload.uploadUrl(); + finalOriginUrl = originUpload.objectUrl(); + finalMasterPlaylistUrl = toObjectUrl(masterPlaylistObjectKey); + } + + return new MediaUpdateUploadResult( + posterObjectKey, + thumbnailObjectKey, + originObjectKey, + masterPlaylistObjectKey, + posterUploadUrl, + thumbnailUploadUrl, + originUploadUrl, + finalPosterUrl, + finalThumbnailUrl, + finalOriginUrl, + finalMasterPlaylistUrl + ); + } + + public record UploadFileResult( + String objectKey, + String objectUrl, + String uploadUrl + ) { + } + + public record ImageCreateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String posterObjectUrl, + String thumbnailObjectUrl, + String posterUploadUrl, + String thumbnailUploadUrl + ) { + } + + public record ImageUpdateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String nextPosterUrl, + String nextThumbnailUrl + ) { + } + + public record MediaCreateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterObjectUrl, + String thumbnailObjectUrl, + String originObjectUrl, + String masterPlaylistObjectUrl, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl + ) { + } + + public record MediaUpdateUploadResult( + String posterObjectKey, + String thumbnailObjectKey, + String originObjectKey, + String masterPlaylistObjectKey, + String posterUploadUrl, + String thumbnailUploadUrl, + String originUploadUrl, + String nextPosterUrl, + String nextThumbnailUrl, + String nextOriginUrl, + String nextMasterPlaylistUrl + ) { + } } From 773fdc6dd4dcf1c912b0df9613370fc5701a6e4c Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Tue, 3 Mar 2026 18:09:42 +0900 Subject: [PATCH 223/257] =?UTF-8?q?[OT-156]=20[CHORE]:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C/=EC=88=98=EC=A0=95=20DTO=20@Schema=20type=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_admin/content/dto/request/ContentsUpdateRequest.java | 2 +- .../api_admin/content/dto/request/ContentsUploadRequest.java | 2 +- .../api_admin/content/dto/response/ContentsUpdateResponse.java | 2 +- .../api_admin/content/dto/response/ContentsUploadResponse.java | 2 +- .../ott/api_admin/series/dto/request/SeriesUpdateRequest.java | 2 +- .../ott/api_admin/series/dto/request/SeriesUploadRequest.java | 2 +- .../ott/api_admin/series/dto/response/SeriesUploadResponse.java | 2 +- .../api_admin/shortform/dto/request/ShortFormUpdateRequest.java | 2 +- .../api_admin/shortform/dto/request/ShortFormUploadRequest.java | 2 +- .../shortform/dto/response/ShortFormUpdateResponse.java | 2 +- .../shortform/dto/response/ShortFormUploadResponse.java | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java index d75a599..7ee6b5d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java @@ -10,7 +10,7 @@ import java.util.List; -@Schema(type = "Object", description = "콘텐츠 수정 요청") +@Schema(description = "콘텐츠 수정 요청") public record ContentsUpdateRequest( @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") @Positive diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java index 92d0763..2dee9c1 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUploadRequest.java @@ -10,7 +10,7 @@ import java.util.List; -@Schema(type = "Object", description = "콘텐츠 업로드 요청") +@Schema(description = "콘텐츠 업로드 요청") public record ContentsUploadRequest( @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") Long seriesId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java index 8b4bb63..f5e625e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUpdateResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(type = "Object", description = "콘텐츠 수정 응답") +@Schema(description = "콘텐츠 수정 응답") public record ContentsUpdateResponse( @Schema(type = "Long", description = "콘텐츠 ID", example = "10") Long contentsId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java index a5f0f25..27b04be 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/response/ContentsUploadResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(type = "Object", description = "콘텐츠 업로드 응답") +@Schema(description = "콘텐츠 업로드 응답") public record ContentsUploadResponse( @Schema(type = "Long", description = "생성된 콘텐츠 ID", example = "10") Long contentsId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java index fbdddd9..0bf714b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUpdateRequest.java @@ -11,7 +11,7 @@ @Schema(description = "시리즈 수정 요청") public record SeriesUpdateRequest( - @Schema(type = "String", description = "시리즈 제목", example = "수정된 시리즈 제목") + @Schema(description = "시리즈 제목", example = "수정된 시리즈 제목") @NotBlank String title, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java index 8e582da..a132970 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/request/SeriesUploadRequest.java @@ -9,7 +9,7 @@ import java.util.List; -@Schema(type = "Object", description = "시리즈 업로드 요청") +@Schema(description = "시리즈 업로드 요청") public record SeriesUploadRequest( @Schema(type = "String", description = "시리즈 제목", example = "응답하라 1988") @NotBlank diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java index d0b8bca..1daf12d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/dto/response/SeriesUploadResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(type = "Object", description = "시리즈 업로드 응답") +@Schema(description = "시리즈 업로드 응답") public record SeriesUploadResponse( @Schema(type = "Long", description = "생성된 시리즈 ID", example = "10") Long seriesId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java index 4facb02..18f5287 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java @@ -7,7 +7,7 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; -@Schema(type = "Object", description = "숏폼 수정 요청") +@Schema(description = "숏폼 수정 요청") public record ShortFormUpdateRequest( @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") @Positive diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index d42839a..b5043f2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -6,7 +6,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; -@Schema(type = "Object", description = "숏폼 업로드 요청") +@Schema(description = "숏폼 업로드 요청") public record ShortFormUploadRequest( @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") Long seriesId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java index b4ae423..002479d 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUpdateResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(type = "Object", description = "숏폼 수정 응답") +@Schema(description = "숏폼 수정 응답") public record ShortFormUpdateResponse( @Schema(type = "Long", description = "숏폼 ID", example = "10") Long shortFormId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java index 31f7c5f..903dd01 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/response/ShortFormUploadResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(type = "Object", description = "숏폼 업로드 응답") +@Schema(description = "숏폼 업로드 응답") public record ShortFormUploadResponse( @Schema(type = "Long", description = "생성된 숏폼 ID", example = "10") Long shortFormId, From 244498174cabdaecf07ba67508a67af639df9eb7 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 21:35:31 +0900 Subject: [PATCH 224/257] =?UTF-8?q?[FEAT]:=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/common/web/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java index 92c1583..a6a4a82 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java @@ -34,7 +34,7 @@ public enum ErrorCode { INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰입니다"), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "만료된 토큰입니다"), FORBIDDEN(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없습니다"), - KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "A004", "카카오 인증 서버에 접근할 수 없습니다"), + KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "A005", "카카오 인증 서버에 접근할 수 없습니다"), // ========== User (U) - 사용자 ========== USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다"), From 80f7318741016e4b8ef6d076bb6798b89b173ee6 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 21:37:36 +0900 Subject: [PATCH 225/257] =?UTF-8?q?[FEAT]:=20RestTemmplate=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-user/build.gradle | 3 +++ .../api_user/config/RestTemplateConfig.java | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/api-user/build.gradle b/apps/api-user/build.gradle index f37cd84..da1cf0e 100644 --- a/apps/api-user/build.gradle +++ b/apps/api-user/build.gradle @@ -20,6 +20,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // httpClient 5 + implementation 'org.apache.httpcomponents.client5:httpclient5' } diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java index 97769cb..438a11e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java @@ -1,14 +1,32 @@ package com.ott.api_user.config; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; +import java.util.concurrent.TimeUnit; + @Configuration public class RestTemplateConfig { + private static final int CONNECT_TIMEOUT_MS = 3_000; // 연결 타임아웃 3초 + private static final int READ_TIMEOUT_MS = 5_000; // 읽기 타임아웃 5초 + @Bean public RestTemplate restTemplate() { - return new RestTemplate(); + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .setResponseTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build(); + + HttpClient httpClient = HttpClientBuilder.create() + .setDefaultRequestConfig(requestConfig) + .build(); + + return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); } } \ No newline at end of file From be82e67bb2d5cf80b65d0d79a3726575b8be0b4b Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 21:38:28 +0900 Subject: [PATCH 226/257] =?UTF-8?q?[FIX]:=20=EC=BF=BC=EB=A6=AC=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=8C=80=EC=83=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/domain/playback/repository/PlaybackRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java index d86119a..244ddd3 100644 --- a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -17,6 +17,6 @@ public interface PlaybackRepository extends JpaRepository { // 회원 탈퇴 @Modifying(clearAutomatically = true) - @Query("UPDATE WatchHistory w SET w.status = 'DELETE' WHERE w.member.id = :memberId") + @Query("UPDATE Playback p SET p.status = 'DELETE' WHERE p.member.id = :memberId") void softDeleteAllByMemberId(@Param("memberId") Long memberId); } From c4fbd53e4bad2833ed7a5b85fcba02cd9451fdb7 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 21:39:30 +0900 Subject: [PATCH 227/257] =?UTF-8?q?[CHORE]:=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/auth/client/KakaoUnlinkClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java b/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java index 9fd7c9a..99eba7e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java @@ -44,9 +44,9 @@ public void unlink(String providerId) { try { ResponseEntity response = restTemplate.postForEntity(unlinkUrl, request, String.class); - log.info("카카오 연결 끊기 성공 - providerId: {}, status: {}", providerId, response.getStatusCode()); + log.info("카카오 연결 끊기 성공"); } catch (Exception e) { - log.error("카카오 연결 끊기 실패 - providerId: {}", providerId, e); + log.error("카카오 연결 끊기 실패"); throw new BusinessException(ErrorCode.KAKAO_UNLINK_FAILED); } } From efff66838ecffd2ba389c9cf7b5c48b2c2b1c4c6 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 21:40:14 +0900 Subject: [PATCH 228/257] =?UTF-8?q?[CHORE]:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20PermitAll=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/api_admin/config/SecurityConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 481a727..3090491 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -52,7 +52,11 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/back-office/reissue", "/swagger-ui/**", "/v3/api-docs/**", - "/swagger-resources/**" + "/swagger-resources/**", + "/back-office//swagger-ui/**", + "/back-office//v3/api-docs/**", + "/back-office//swagger-resources/**" + ).permitAll() .requestMatchers("/back-office/admin/**").hasRole("ADMIN") From c6b24e6291564e5916a59f4ac6faf2338e0da354 Mon Sep 17 00:00:00 2001 From: marulog Date: Tue, 3 Mar 2026 21:41:16 +0900 Subject: [PATCH 229/257] =?UTF-8?q?[FIX]:=20=EB=B0=B0=ED=8F=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/common/security/util/CookieUtil.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java index 8802ab0..fb8931b 100644 --- a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java +++ b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java @@ -10,24 +10,24 @@ public class CookieUtil { public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { ResponseCookie cookie = ResponseCookie.from(name, value) -// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - .secure(false) // HTTPS 요청만 허용 + .secure(true) // HTTPS 요청만 허용 .path("/") // 모든 경로로 전송 .maxAge(maxAge) - .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 + .sameSite("None") // 크로스 사이트에 대해서 쿠키 전송 허용 .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } public void deleteCookie(HttpServletResponse response, String name) { ResponseCookie cookie = ResponseCookie.from(name, "") -// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! .httpOnly(true) - .secure(false) + .secure(true) .path("/") .maxAge(0) - .sameSite("Lax") + .sameSite("None") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } From d33b7a4a1d30ba9414b31ac0b57183f173134ddb Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 01:30:08 +0900 Subject: [PATCH 230/257] =?UTF-8?q?[FIX]:=20=EC=A7=91=EA=B3=84=20=EB=A7=88?= =?UTF-8?q?=EA=B0=90=EC=9D=BC,=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EA=B1=B4?= =?UTF-8?q?=EB=84=88=EB=9B=B0=EA=B8=B0=20API,=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/api_user/auth/controller/AuthApi.java | 2 +- .../api_user/member/controller/MemberApi.java | 18 ++++++++++++- .../member/controller/MemberController.java | 19 ++++++++++++-- .../member/service/MemberService.java | 25 ++++++++++++++----- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java index c8ebdb7..5f7a49b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java @@ -24,7 +24,7 @@ public interface AuthApi { @Operation(summary = "로그아웃", description = "DB refreshToken을 삭제, accessToken/refreshToken 쿠키 제거") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "204", description = "로그아웃 성공"), @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) }) ResponseEntity logout(Authentication authentication, HttpServletResponse response); diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java index 9bc9150..216bd85 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; @@ -112,6 +113,20 @@ ResponseEntity> setPreferredTags( ); + @Operation(summary = "온보딩 건너뛰기", description = "온보딩을 건너뛸 경우 onboardingCompleted를 true로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "건너뛰기 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/me/onboarding/skip") + ResponseEntity skipOnboarding( + @AuthenticationPrincipal Long memberId); + + + // ------------------------------------------------------- // 시청이력 기반 태그 랭킹 조회 // ------------------------------------------------------- @@ -212,7 +227,7 @@ ResponseEntity>> getRecommendContentsBy responseCode = "404", description = "회원을 찾을 수 없음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/me/playlist") + @GetMapping("/me/history/playlist") ResponseEntity>> getWatchHistoryPlaylist( @AuthenticationPrincipal Long memberId, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @@ -232,6 +247,7 @@ ResponseEntity>> getWatchHisto }) @DeleteMapping("/me") ResponseEntity withdraw( + @Parameter HttpServletResponse response, @AuthenticationPrincipal Long memberId); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index cfe491b..2c099f6 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -4,8 +4,10 @@ import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.*; import com.ott.api_user.member.service.MemberService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; @@ -22,6 +24,7 @@ public class MemberController implements MemberApi { private final MemberService memberService; + private final CookieUtil cookie; @Override @GetMapping("/me") @@ -90,12 +93,24 @@ public ResponseEntity>> getWat } // 회원 탈퇴 - 현재 soft delete - // 현재 회원 탈퇴를 진행해도 JWT가 현재 stateless라서 만료 시간 까지 API 호출이 가능함 - // 추후 redis 블랙리스트 같은 기술을 도입해야됨 + // 회원 탈퇴 시 DB + 브라우저 토큰 삭제 @DeleteMapping("/me") public ResponseEntity withdraw( + HttpServletResponse response, @AuthenticationPrincipal Long memberId) { memberService.withdraw(memberId); + + cookie.deleteCookie(response, "accessToken"); + cookie.deleteCookie(response, "refreshToken"); + + return ResponseEntity.noContent().build(); + } + + // 온보딩 스킵 시 온보딩 컬럼 true 변경 + @Override + @PostMapping("/me/onboarding/skip") + public ResponseEntity skipOnboarding(@AuthenticationPrincipal Long memberId) { + memberService.skipOnboarding(memberId); return ResponseEntity.noContent().build(); } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 7d9733e..4eae0c0 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -11,6 +11,7 @@ import com.ott.common.web.response.PageInfo; import com.ott.common.web.response.PageResponse; import com.ott.domain.bookmark.repository.BookmarkRepository; +import com.ott.domain.click_event.repository.ClickRepository; import com.ott.domain.comment.repository.CommentRepository; import com.ott.domain.common.Status; import com.ott.domain.likes.repository.LikesRepository; @@ -26,7 +27,6 @@ import com.ott.domain.watch_history.repository.RecentWatchProjection; import com.ott.domain.watch_history.repository.TagRankingProjection; import com.ott.domain.watch_history.repository.WatchHistoryRepository; -import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -56,6 +56,7 @@ public class MemberService { private final LikesRepository likesRepository; private final PlaybackRepository playbackRepository; private final CommentRepository commentRepository; + private final ClickRepository clickRepository; /** * 마이 페이지 조회 : 닉네임, 선호태그 List 반환 @@ -121,7 +122,7 @@ public MyPageResponse updateMyInfo(Long memberId, UpdateMemberRequest request) { */ @Transactional public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { - Member findMember = memberRepository.findById(memberId) + Member findMember = memberRepository.findByIdAndStatus(memberId, Status.ACTIVE) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); //재 호출 시 중복 방지 코드 @@ -154,8 +155,9 @@ public TagRankingResponse getTagRanking(Long memberId) { .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); // 집계일과 마감일 선정 1일~말일까지 - LocalDateTime endDate = LocalDateTime.now(); - LocalDateTime startDate = endDate.minusMonths(1); + YearMonth currentYearMonth = YearMonth.now(); + LocalDateTime startDate = currentYearMonth.atDay(1).atStartOfDay(); + LocalDateTime endDate = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // 다음 달 1일 00:00:00 List tagRankingProjections = watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween(memberId, startDate, endDate); @@ -202,12 +204,12 @@ public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) // 이번 달 범위 YearMonth currentYearMonth = YearMonth.now(); LocalDateTime currentStart = currentYearMonth.atDay(1).atStartOfDay(); - LocalDateTime currentEnd = currentYearMonth.atEndOfMonth().atTime(23, 59, 59); + LocalDateTime currentEnd = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // 다음 달 1일 00:00:00 // 저번 달 범위 YearMonth prevYearMonth = currentYearMonth.minusMonths(1); LocalDateTime prevStart = prevYearMonth.atDay(1).atStartOfDay(); - LocalDateTime prevEnd = prevYearMonth.atEndOfMonth().atTime(23, 59, 59); + LocalDateTime prevEnd = currentYearMonth.atDay(1).atStartOfDay(); // 이번 달 1일 00:00:00 Long currentCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, currentStart, currentEnd); Long previousCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, prevStart, prevEnd); @@ -304,4 +306,15 @@ public void withdraw(Long memberId) { commentRepository.softDeleteAllByMemberId(memberId); } + + /** + * 온보딩 건너뛰기 - onboardingCompleted = true 처리 + */ + @Transactional + public void skipOnboarding(Long memberId) { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + findMember.completeOnboarding(); + } } From bf6747d2b5a89c5ccd21f959379638955b151a6d Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 01:45:20 +0900 Subject: [PATCH 231/257] =?UTF-8?q?[CHORE]:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ott/api_user/auth/controller/AuthApi.java | 2 +- .../ott/api_user/bookmark/controller/BookmarkController.java | 2 +- .../main/java/com/ott/api_user/likes/controller/LikesAPI.java | 2 +- .../java/com/ott/api_user/likes/controller/LikesController.java | 2 +- .../main/java/com/ott/api_user/member/controller/MemberApi.java | 2 +- .../com/ott/api_user/member/controller/MemberController.java | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java index 5f7a49b..79af5e2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java @@ -17,7 +17,7 @@ public interface AuthApi { @Operation(summary = "Access Token 재발급", description = "access token + refresh token 재발급.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse(responseCode = "204", description = "재발급 성공"), @ApiResponse(responseCode = "401", description = "refreshToken이 없거나 만료/유효하지 않음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) }) ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java index e38e71f..c230996 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java @@ -36,7 +36,7 @@ public ResponseEntity> editBookmark( @AuthenticationPrincipal Long memberId) { bookmarkService.editBookmark(memberId, request.getMediaId()); - return ResponseEntity.ok(SuccessResponse.of(null)); + return ResponseEntity.noContent().build(); } // 북마크한 콘텐츠 or 시리즈 리스트 조회 diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java index a53214e..be08fac 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java @@ -20,7 +20,7 @@ public interface LikesAPI { @Operation(summary = "좋아요 API", description = "좋아요 상태를 변경합니다. 등록/취소 모두 이 API를 사용합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "좋아요 성공"), + @ApiResponse(responseCode = "204", description = "좋아요 성공"), @ApiResponse(responseCode = "404", description = "미디어 또는 사용자를 찾을 수 없음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) diff --git a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java index 2450997..b8cc9c4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java @@ -24,6 +24,6 @@ public ResponseEntity> editLikes( @AuthenticationPrincipal Long memberId) { likesService.editLikes(memberId, request.getMediaId()); - return ResponseEntity.ok(SuccessResponse.of(null)); + return ResponseEntity.noContent().build(); } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java index 216bd85..b613f67 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -88,7 +88,7 @@ ResponseEntity> updateMyInfo( ) @ApiResponses({ @ApiResponse( - responseCode = "200", description = "선호 태그 저장 성공" + responseCode = "204", description = "선호 태그 저장 성공" ), @ApiResponse( responseCode = "400", description = "잘못된 요청 (빈 태그 목록, 중복 태그 ID 등)", diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index 2c099f6..a3d1839 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -50,7 +50,7 @@ public ResponseEntity> setPreferredTags( @Valid @RequestBody SetPreferredTagRequest request ) { memberService.setPreferredTags(memberId, request); - return ResponseEntity.ok(SuccessResponse.of(null)); + return ResponseEntity.noContent().build(); } // 유저 별 1달 간 상위 태그 조회 From 0a20fc005fe17d497b5144c7a80bc47430b9adfc Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 01:47:54 +0900 Subject: [PATCH 232/257] =?UTF-8?q?[FIX]:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=97=B0=EA=B4=80=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EA=B0=90=EC=82=B0=20=EB=B0=8F=20DELETE=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/member/service/MemberService.java | 13 ++++++++++--- .../bookmark/repository/BookmarkRepository.java | 17 +++++++++++++++++ .../click_event/repository/ClickRepository.java | 16 ++++++++++++++++ .../likes/repository/LikesRepository.java | 15 +++++++++++++++ .../member/repository/MemberRepository.java | 4 ++++ 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index 4eae0c0..be1d5ad 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -297,14 +297,21 @@ public void withdraw(Long memberId) { // 2. 회원 Soft Delete member.withdraw(); - // 3. 연관 데이터 Soft Delete - preferredTagRepository.softDeleteAllByMemberId(memberId); + // 탈퇴 회원의 ACTIVE한 북마크 수 차감 + bookmarkRepository.decreaseBookmarkCountByMemberId(memberId); bookmarkRepository.softDeleteAllByMemberId(memberId); + + + // 탈퇴 회원의 ACTIVE한 좋아요 수 차감 + likesRepository.decreaseLikesCountByMemberId(memberId); likesRepository.softDeleteAllByMemberId(memberId); + + // 3. 연관 데이터 Soft Delete + preferredTagRepository.softDeleteAllByMemberId(memberId); watchHistoryRepository.softDeleteAllByMemberId(memberId); playbackRepository.softDeleteAllByMemberId(memberId); commentRepository.softDeleteAllByMemberId(memberId); - + clickRepository.softDeleteAllByMemberId(memberId); } /** diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index 28ef765..7faa81a 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -34,4 +34,21 @@ Page findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( @Modifying(clearAutomatically = true) @Query("UPDATE Bookmark b SET b.status = 'DELETE' WHERE b.member.id = :memberId") void softDeleteAllByMemberId(@Param("memberId") Long memberId); + + + // 회원 탈퇴 전 ACTIVE인 유저가 북마크 row 상태 변경 + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE media m + JOIN ( + SELECT b.media_id, COUNT(*) AS cnt + FROM bookmark b + WHERE b.member_id = :memberId + AND b.status = 'ACTIVE' + GROUP BY b.media_id + ) t ON t.media_id = m.id + SET m.bookmark_count = GREATEST(0, m.bookmark_count - t.cnt) + """, nativeQuery = true) + void decreaseBookmarkCountByMemberId(@Param("memberId") Long memberId); + } diff --git a/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java b/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java new file mode 100644 index 0000000..21281de --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java @@ -0,0 +1,16 @@ +package com.ott.domain.click_event.repository; + +import com.ott.domain.click_event.domain.ClickEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + + +public interface ClickRepository extends JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("UPDATE Comment c SET c.status = 'DELETE' WHERE c.member.id = :memberId") + void softDeleteAllByMemberId(@Param("memberId") Long memberId); + +} diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index 6ead6d7..a455fa6 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -22,4 +22,19 @@ public interface LikesRepository extends JpaRepository { @Modifying(clearAutomatically = true) @Query("UPDATE Likes l SET l.status = 'DELETE' WHERE l.member.id = :memberId") void softDeleteAllByMemberId(@Param("memberId") Long memberId); + + // 회원 탈퇴 시 해당 유저가 좋아요한 미디어에 대하여 -count + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE media m + JOIN ( + SELECT l.media_id, COUNT(*) AS cnt + FROM likes l + WHERE l.member_id = :memberId + AND l.status = 'ACTIVE' + GROUP BY l.media_id + ) t ON t.media_id = m.id + SET m.likes_count = GREATEST(0, m.likes_count - t.cnt) + """, nativeQuery = true) + void decreaseLikesCountByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java index 478f208..6b08261 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.ott.domain.member.repository; +import com.ott.domain.common.Status; import com.ott.domain.member.domain.Member; import com.ott.domain.member.domain.Provider; import org.springframework.data.jpa.repository.JpaRepository; @@ -13,4 +14,7 @@ public interface MemberRepository extends JpaRepository, MemberRep // 관리자&에디터용 조회 Optional findByEmailAndProvider(String email, Provider provider); + + // Active한 유저 조회 + Optional findByIdAndStatus(Long memberId, Status status); } From a4efed458f1d9bfce606dfe54cbe2c96b6cf9504 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 02:12:53 +0900 Subject: [PATCH 233/257] =?UTF-8?q?[FEAT]:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC=20=EC=8B=9C=20media=5Fid,=20mediaType=20?= =?UTF-8?q?=ED=95=84=EC=88=98=20=EC=A0=84=EB=8B=AC=20DTO=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookmark/dto/response/BookmarkMediaResponse.java | 5 +++++ .../member/dto/response/RecentWatchResponse.java | 11 ++++++++--- .../repository/RecentWatchProjection.java | 10 +++++++--- .../repository/WatchHistoryRepositoryImpl.java | 3 ++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java index 20dce3b..23308b2 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java @@ -1,6 +1,7 @@ package com.ott.api_user.bookmark.dto.response; import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.common.MediaType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -13,6 +14,9 @@ public class BookmarkMediaResponse { @Schema(type ="Long", description = "미디어 ID", example = "1") private Long mediaId; + @Schema(type = "String", description = "미디어 타입 (CONTENTS, SERIES)", example = "SERIES") + private MediaType mediaType; + @Schema(type ="String", description = "미디어 제목", example = "어서와요 김마루의 숲") private String title; @@ -25,6 +29,7 @@ public class BookmarkMediaResponse { public static BookmarkMediaResponse from(Bookmark bookmark) { return BookmarkMediaResponse.builder() .mediaId(bookmark.getMedia().getId()) + .mediaType(bookmark.getMedia().getMediaType()) .title(bookmark.getMedia().getTitle()) .description(bookmark.getMedia().getDescription()) .posterUrl(bookmark.getMedia().getPosterUrl()) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java index 3f2a762..2aca58e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java @@ -1,5 +1,6 @@ package com.ott.api_user.member.dto.response; +import com.ott.domain.common.MediaType; import com.ott.domain.watch_history.repository.RecentWatchProjection; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -12,8 +13,11 @@ @Schema(description = "시청이력 플레이리스트 DTO") public class RecentWatchResponse { - @Schema(type = "Long", example = "3", description = "콘텐츠 ID (콘텐츠 상세 조회용)") - private Long contentsId; + @Schema(type = "Long", example = "3", description = "미디어 ID") + private Long mediaId; + + @Schema(type = "String", example = "CONTENTS", description = "미디어 타입 (CONTENTS, SERIES, SHORT_FORM)") + private MediaType mediaType; @Schema(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "포스터 URL") private String posterUrl; @@ -26,7 +30,8 @@ public class RecentWatchResponse { public static RecentWatchResponse from(RecentWatchProjection projection) { return RecentWatchResponse.builder() - .contentsId(projection.getContentsId()) + .mediaId(projection.getMediaId()) + .mediaType(projection.getMediaType()) .posterUrl(projection.getPosterUrl()) .positionSec(projection.getPositionSec()) .duration(projection.getDuration()) diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java index 582e7d6..ceee54d 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java @@ -1,19 +1,23 @@ package com.ott.domain.watch_history.repository; +import com.ott.domain.common.MediaType; import lombok.Getter; @Getter public class RecentWatchProjection { - private final Long contentsId; + private final Long mediaId; + private final MediaType mediaType; private final String posterUrl; private final Integer positionSec; private final Integer duration; - public RecentWatchProjection(Long contentsId, String posterUrl, Integer positionSec, Integer duration) { - this.contentsId = contentsId; + public RecentWatchProjection(Long mediaId, MediaType mediaType, String posterUrl, Integer positionSec, Integer duration) { + this.mediaId = mediaId; + this.mediaType = mediaType; this.posterUrl = posterUrl; this.positionSec = positionSec; this.duration = duration; } + } \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java index 55ccc54..57caacf 100644 --- a/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java @@ -107,7 +107,8 @@ public Page findWatchHistoryByMemberId(Long memberId, Pag List content = queryFactory .select(Projections.constructor(RecentWatchProjection.class, - contents.id, + contents.media.id, + contents.media.mediaType, contents.media.posterUrl, playback.positionSec.coalesce(0), contents.duration From d76cbd40fce244b1d047fba4df882c2e2235d875 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 02:56:39 +0900 Subject: [PATCH 234/257] =?UTF-8?q?[FIX]:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=EB=B6=81=EB=A7=88=ED=81=AC,=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=BF=BC=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BookmarkRepository.java | 19 +++++++++---------- .../likes/repository/LikesRepository.java | 5 ++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index 7faa81a..0ca6cce 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -39,16 +39,15 @@ Page findByMemberIdAndStatusAndMedia_MediaTypeOrderByCreatedDateDesc( // 회원 탈퇴 전 ACTIVE인 유저가 북마크 row 상태 변경 @Modifying(clearAutomatically = true) @Query(value = """ - UPDATE media m - JOIN ( - SELECT b.media_id, COUNT(*) AS cnt - FROM bookmark b - WHERE b.member_id = :memberId - AND b.status = 'ACTIVE' - GROUP BY b.media_id - ) t ON t.media_id = m.id - SET m.bookmark_count = GREATEST(0, m.bookmark_count - t.cnt) - """, nativeQuery = true) + UPDATE media m + JOIN ( + SELECT b.media_id + FROM bookmark b + WHERE b.member_id = :memberId + AND b.status = 'ACTIVE' + ) t ON t.media_id = m.id + SET m.bookmark_count = GREATEST(0, m.bookmark_count - 1) + """, nativeQuery = true) void decreaseBookmarkCountByMemberId(@Param("memberId") Long memberId); } diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index a455fa6..926e0e7 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -28,13 +28,12 @@ public interface LikesRepository extends JpaRepository { @Query(value = """ UPDATE media m JOIN ( - SELECT l.media_id, COUNT(*) AS cnt + SELECT l.media_id FROM likes l WHERE l.member_id = :memberId AND l.status = 'ACTIVE' - GROUP BY l.media_id ) t ON t.media_id = m.id - SET m.likes_count = GREATEST(0, m.likes_count - t.cnt) + SET m.likes_count = GREATEST(0, m.likes_count - 1) """, nativeQuery = true) void decreaseLikesCountByMemberId(@Param("memberId") Long memberId); } From b878ce8791ddc280cdc8a369d9f97d2a8b527d7e Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 03:09:05 +0900 Subject: [PATCH 235/257] =?UTF-8?q?[FEAT]:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?DTO=20=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/BookmarkMediaResponse.java | 22 ++++-- .../bookmark/service/BookmarkService.java | 18 ++--- .../repository/BookmarkMediaProjection.java | 34 ++++++++ .../repository/BookmarkRepository.java | 2 +- .../repository/BookmarkRepositoryCustom.java | 10 +++ .../repository/BookmarkRepositoryImpl.java | 77 +++++++++++++++++++ 6 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java create mode 100644 modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java create mode 100644 modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java index 23308b2..27524d8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java @@ -1,6 +1,6 @@ package com.ott.api_user.bookmark.dto.response; -import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.bookmark.repository.BookmarkMediaProjection; import com.ott.domain.common.MediaType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -26,13 +26,21 @@ public class BookmarkMediaResponse { @Schema(type ="String", description = "포스터 URL", example = "https://cdn.ott.com/posters/1.jpg") private String posterUrl; - public static BookmarkMediaResponse from(Bookmark bookmark) { + @Schema(type = "Integer", description = "이어보기 시점 (초), CONTENTS만 반환 SERIES는 null", example = "150") + private Integer positionSec; + + @Schema(type = "Integer", description = "전체 재생 시간 (초), CONTENTS만 반환 SERIES는 null", example = "3600") + private Integer duration; + + public static BookmarkMediaResponse from(BookmarkMediaProjection projection) { return BookmarkMediaResponse.builder() - .mediaId(bookmark.getMedia().getId()) - .mediaType(bookmark.getMedia().getMediaType()) - .title(bookmark.getMedia().getTitle()) - .description(bookmark.getMedia().getDescription()) - .posterUrl(bookmark.getMedia().getPosterUrl()) + .mediaId(projection.getMediaId()) + .mediaType(projection.getMediaType()) + .title(projection.getTitle()) + .description(projection.getDescription()) + .posterUrl(projection.getPosterUrl()) + .positionSec(projection.getPositionSec()) + .duration(projection.getDuration()) .build(); } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java index 47a9f08..45392cc 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java @@ -7,6 +7,7 @@ import com.ott.common.web.response.PageInfo; import com.ott.common.web.response.PageResponse; import com.ott.domain.bookmark.domain.Bookmark; +import com.ott.domain.bookmark.repository.BookmarkMediaProjection; import com.ott.domain.bookmark.repository.BookmarkRepository; import com.ott.domain.common.MediaType; import com.ott.domain.common.Status; @@ -86,18 +87,13 @@ public PageResponse getBookmarkMediaList(Long memberId, I Pageable pageable = PageRequest.of(page, size); - // ACTIVE && bookmark.media.mediaType 기준으로 CONTENTS, SERIES 필터링 - Page bookmarkPage = bookmarkRepository - .findByMemberIdAndStatusAndMedia_MediaTypeInOrderByCreatedDateDesc( - memberId, - Status.ACTIVE, - List.of(MediaType.CONTENTS, MediaType.SERIES), - pageable); + Page bookmarkPage = + bookmarkRepository.findBookmarkMediaList(memberId, pageable); - // Bookmark -> DTO로 변환 - List dataList = bookmarkPage.getContent().stream() - .map(BookmarkMediaResponse::from) - .toList(); + List dataList = bookmarkPage.getContent() + .stream() + .map(BookmarkMediaResponse::from) + .toList(); // pageInfo 생성 PageInfo pageInfo = PageInfo.toPageInfo( diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java new file mode 100644 index 0000000..c340ad9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java @@ -0,0 +1,34 @@ +package com.ott.domain.bookmark.repository; + +import com.ott.domain.common.MediaType; +import lombok.Getter; + +@Getter +public class BookmarkMediaProjection { + + private final Long mediaId; + private final MediaType mediaType; + private final String title; + private final String description; + private final String posterUrl; + private final Integer positionSec; // 콘텐츠만, SERIES는 null + private final Integer duration; // 콘텐츠만, SERIES는 null + + public BookmarkMediaProjection( + Long mediaId, + MediaType mediaType, + String title, + String description, + String posterUrl, + Integer positionSec, + Integer duration + ) { + this.mediaId = mediaId; + this.mediaType = mediaType; + this.title = title; + this.description = description; + this.posterUrl = posterUrl; + this.positionSec = positionSec; + this.duration = duration; + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java index 0ca6cce..03dd07e 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java @@ -15,7 +15,7 @@ import java.util.List; import java.util.Optional; -public interface BookmarkRepository extends JpaRepository { +public interface BookmarkRepository extends JpaRepository, BookmarkRepositoryCustom { boolean existsByMemberIdAndMediaIdAndStatus(Long memberId, Long mediaId, Status status); Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java new file mode 100644 index 0000000..e4c998c --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ott.domain.bookmark.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface BookmarkRepositoryCustom { + + // 북마크 콘텐츠/시리즈 목록 조회 (positionSec, duration 포함) + Page findBookmarkMediaList(Long memberId, Pageable pageable); +} diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java new file mode 100644 index 0000000..c24b5d9 --- /dev/null +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java @@ -0,0 +1,77 @@ +package com.ott.domain.bookmark.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +import static com.ott.domain.bookmark.domain.QBookmark.bookmark; +import static com.ott.domain.common.MediaType.CONTENTS; +import static com.ott.domain.common.MediaType.SERIES; +import static com.ott.domain.common.Status.ACTIVE; +import static com.ott.domain.contents.domain.QContents.contents; +import static com.ott.domain.media.domain.QMedia.media; +import static com.ott.domain.playback.domain.QPlayback.playback; + +@RequiredArgsConstructor +public class BookmarkRepositoryImpl implements BookmarkRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + /** + * 북마크 콘텐츠/시리즈 목록 조회 + * - CONTENTS: playback LEFT JOIN → positionSec(없으면 0), contents LEFT JOIN → duration + * - SERIES: positionSec = null, duration = null + */ + @Override + public Page findBookmarkMediaList(Long memberId, Pageable pageable) { + + List content = queryFactory + .select(Projections.constructor(BookmarkMediaProjection.class, + media.id, + media.mediaType, + media.title, + media.description, + media.posterUrl, + playback.positionSec.coalesce(0), // SERIES면 null (LEFT JOIN 미매칭) + contents.duration // SERIES면 null (LEFT JOIN 미매칭) + )) + .from(bookmark) + .join(bookmark.media, media) + // CONTENTS 타입일 때만 contents, playback 매칭됨 + .leftJoin(contents).on( + contents.media.id.eq(media.id) + ) + .leftJoin(playback).on( + playback.contents.id.eq(contents.id) + .and(playback.member.id.eq(memberId)) + .and(playback.status.eq(ACTIVE)) + ) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(ACTIVE), + media.mediaType.in(CONTENTS, SERIES) + ) + .orderBy(bookmark.createdDate.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(bookmark.count()) + .from(bookmark) + .join(bookmark.media, media) + .where( + bookmark.member.id.eq(memberId), + bookmark.status.eq(ACTIVE), + media.mediaType.in(CONTENTS, SERIES) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} \ No newline at end of file From 76c7c27f1a8e1847c16dbd07b9ead210d12c2c6d Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 09:39:30 +0900 Subject: [PATCH 236/257] =?UTF-8?q?[CHORE]:=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ott/api_admin/config/SecurityConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 3090491..70f4167 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -53,9 +53,9 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", - "/back-office//swagger-ui/**", - "/back-office//v3/api-docs/**", - "/back-office//swagger-resources/**" + "/back-office/swagger-ui/**", + "/back-office/v3/api-docs/**", + "/back-office/swagger-resources/**" ).permitAll() From 7f0c96fac293259f4e055b8ed5c2d562d7337b75 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 09:40:04 +0900 Subject: [PATCH 237/257] =?UTF-8?q?[FIX]:=20=EC=BF=BC=EB=A6=AC=EB=AC=B8?= =?UTF-8?q?=EC=97=90=20STATUS=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ott/api_user/member/service/MemberService.java | 2 +- .../java/com/ott/domain/tag/repository/TagRepository.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index be1d5ad..afa2216 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -198,7 +198,7 @@ public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - Tag findTag = tagRepository.findById(tagId) + Tag findTag = tagRepository.findByIdAndStatus(tagId, Status.ACTIVE) .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); // 이번 달 범위 diff --git a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java index 89dfbce..262cc4b 100644 --- a/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java @@ -1,6 +1,7 @@ package com.ott.domain.tag.repository; import java.util.List; +import java.util.Optional; import java.util.Set; import com.ott.domain.category.domain.Category; @@ -31,4 +32,7 @@ public interface TagRepository extends JpaRepository { // ACTIVE&&리스트안에 있는 태그 조회 List findAllByIdInAndStatus(List ids, Status status); + + // ACTIVE한 특정 태그 조회 + Optional findByIdAndStatus(Long tagId, Status status); } \ No newline at end of file From 8eb81b26f8ace1316654fd816a758f02713aa950 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 09:41:55 +0900 Subject: [PATCH 238/257] =?UTF-8?q?[FIX]:=20=EC=BF=BC=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/domain/bookmark/repository/BookmarkRepositoryImpl.java | 2 +- .../com/ott/domain/click_event/repository/ClickRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java index c24b5d9..132968c 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java @@ -38,7 +38,7 @@ public Page findBookmarkMediaList(Long memberId, Pageab media.title, media.description, media.posterUrl, - playback.positionSec.coalesce(0), // SERIES면 null (LEFT JOIN 미매칭) + media.mediaType.when(CONTENTS).then(playback.positionSec.coalesce(0)).otherwise((Integer) null), // SERIES는 null, CONTENTS만 playback 없으면 0 contents.duration // SERIES면 null (LEFT JOIN 미매칭) )) .from(bookmark) diff --git a/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java b/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java index 21281de..5c756b6 100644 --- a/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java @@ -10,7 +10,7 @@ public interface ClickRepository extends JpaRepository { @Modifying(clearAutomatically = true) - @Query("UPDATE Comment c SET c.status = 'DELETE' WHERE c.member.id = :memberId") + @Query("UPDATE ClickEvent c SET c.status = 'DELETE' WHERE c.member.id = :memberId") void softDeleteAllByMemberId(@Param("memberId") Long memberId); } From 98dbb76ea968d61a9e53b731d7534846dee68b73 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:42:39 +0900 Subject: [PATCH 239/257] =?UTF-8?q?[FIX]:=20IN=EC=A0=88=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=AF=B8=EB=B3=B4=EC=9E=A5=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=EC=9D=84=20=ED=86=B5=ED=95=B4=20Tag=20=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=84=9E=EC=9D=B4=EB=8A=94=EA=B1=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/service/PlaylistPreferenceService.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java index ef3e14c..6ab301d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java @@ -1,6 +1,7 @@ package com.ott.api_user.playlist.service; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -47,7 +48,6 @@ public List getTopTags(Long memberId){ // 1. 온보딩 선호 태그 가중치 반영 (+5점) // Map.merge 를 통해 누적 점수 계산 - // ex) preferredTagRepository.findTagIdsByMemberId(memberId, Status.ACTIVE) .forEach(id -> tagScores.merge(id, 5, Integer::sum)); @@ -72,7 +72,11 @@ public List getTopTags(Long memberId){ } // 최종적으로 추출된 3개의 ID로 실제 Tag 엔티티들을 DB에서 가져와 반환 - return tagRepository.findAllById(topTagIds); + // findAllById (In 절은 순서 보장 x 한번 더 TopTagIds 의 인덱스 순서에 맞게 정렬해주어야함) + List tags = tagRepository.findAllById(topTagIds); + tags.sort(Comparator.comparing(tag -> topTagIds.indexOf(tag.getId()))); + + return tags; } From 3d306c87bf74464d97db60263642b464b54f9eb2 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:52:17 +0900 Subject: [PATCH 240/257] =?UTF-8?q?[FIX]:=20JOIN=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20Like=20=EA=B0=80=EC=A4=91=EC=B9=98=20?= =?UTF-8?q?=EC=99=9C=EA=B3=A1=20=ED=98=84=EC=83=81=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...istService.java => PlaylisStrategytService.java} | 4 ++-- .../playlist/service/PlaylistPreferenceService.java | 10 +++++++++- .../service/strategy/TagPlaylistStrategy.java | 11 +++++++++-- .../domain/likes/repository/LikesRepository.java | 12 ++++++------ .../media_tag/repository/MediaTagRepository.java | 13 +++++++++++++ 5 files changed, 39 insertions(+), 11 deletions(-) rename apps/api-user/src/main/java/com/ott/api_user/playlist/service/{PlaylistService.java => PlaylisStrategytService.java} (98%) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java similarity index 98% rename from apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java rename to apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java index baec08b..f33a8f8 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java @@ -23,7 +23,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class PlaylistService { +public class PlaylisStrategytService { private final Map strategyMap; private final PlaylistPreferenceService preferenceService; @@ -65,7 +65,7 @@ public TopTagPlaylistResponse getTopTagPlaylistWithMetadata(PlaylistCondition co TopTagPlaylistResponse.CategoryInfo categoryInfo = null; TopTagPlaylistResponse.TagInfo tagInfo = null; - if (condition.getIndex() != null && condition.getIndex() < topTags.size()) { + if (condition.getIndex() != null && condition.getIndex() >= 0 && condition.getIndex() < topTags.size()) { Tag targetTag = topTags.get(condition.getIndex()); // TagInfo 객체 조립 diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java index 6ab301d..0e9726e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java @@ -14,6 +14,7 @@ import com.ott.domain.common.Status; import com.ott.domain.likes.repository.LikesRepository; +import com.ott.domain.media_tag.repository.MediaTagRepository; import com.ott.domain.playback.repository.PlaybackRepository; import com.ott.domain.preferred_tag.repository.PreferredTagRepository; import com.ott.domain.tag.domain.Tag; @@ -34,6 +35,7 @@ public class PlaylistPreferenceService { private final PlaybackRepository playbackRepository; private final LikesRepository likesRepository; private final TagRepository tagRepository; + private final MediaTagRepository mediaTagRepository; /* * [TAG 전략용] 시청 이력(+3) + 선호 태그(+5) 점수만 합산하여 Top 3 태그 추출 @@ -98,8 +100,14 @@ public Map getTotalTagScores(Long memberId) { .forEach(id -> totalScores.merge(id, 3, Integer::sum)); // 3. 강한 선호도: 최근 좋아요 누른 이력 (+2점) - likesRepository.findRecentTagIdsByMemberId(memberId, Status.ACTIVE, limit100) + // [1단계] 최근 좋아요 누른 '영상 ID' 최대 100개를 가져옴 + List likedMediaIds = likesRepository.findRecentLikedMediaIds(memberId, Status.ACTIVE, limit100); + + // [2단계] 가져온 영상이 하나라도 있다면, 그 영상들의 '태그 ID'를 한 번에 가져와 점수 부여 + if (!likedMediaIds.isEmpty()) { + mediaTagRepository.findTagIdsByMediaIds(likedMediaIds) .forEach(id -> totalScores.merge(id, 2, Integer::sum)); + } // 만들어진 유저의 최종 점수를 반환 return totalScores; diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java index fbd99ae..e241acc 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import com.ott.api_user.playlist.dto.request.PlaylistCondition; import com.ott.api_user.playlist.service.PlaylistPreferenceService; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; import com.ott.domain.media.domain.Media; import com.ott.domain.media.repository.MediaRepository; import com.ott.domain.tag.domain.Tag; @@ -38,15 +40,20 @@ public Page getPlaylist(PlaylistCondition condition, Pageable pageable) { // 유저의 취향 Top 3 태그를 계산해서 가져옴 List topTags = preferenceService.getTopTags(condition.getMemberId()); + int index = condition.getIndex(); // 프론트가 요청한 순위(index)의 태그 ID를 타겟으로 설정 - if (condition.getIndex() < topTags.size()) { + if (index >= 0 && condition.getIndex() < topTags.size()) { targetTagId = topTags.get(condition.getIndex()).getId(); } else { - return Page.empty(pageable); // 범위 초과 시 빈 리스트 반환 + throw new BusinessException(ErrorCode.INVALID_INPUT); } } + if(targetTagId == null){ + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + // 2. 모수 풀링: 홈 화면(page=0)은 섞기 위해 50개를 넉넉히 가져오고, 상세 페이지는 요구한 만큼만 가져옴 int fetchLimit = (pageable.getPageNumber() == 0 && isHomeScreen) ? 50 : pageable.getPageSize(); diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index 85d219e..374b591 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -21,12 +21,12 @@ public interface LikesRepository extends JpaRepository { Optional findByMemberIdAndMediaId(Long memberId, Long mediaId); // 최근 좋아요한 미디어의 태그 ID 조회 + // 1단계 : 최근 좋아요 누른 미디어 100개 먼저 조회 (JOIN 보다 LIMIT 먼저) @Query(""" - SELECT mt.tag.id FROM Likes l - JOIN MediaTag mt ON l.media.id = mt.media.id - WHERE l.member.id = :memberId AND l.status = :status - ORDER BY l.createdDate DESC - """) - List findRecentTagIdsByMemberId(@Param("memberId") Long memberId, @Param("status") Status status, + SELECT l.media.id FROM Likes l + WHERE l.member.id = :memberId AND l.status = :status + ORDER BY l.createdDate DESC + """) + List findRecentLikedMediaIds(@Param("memberId") Long memberId, @Param("status") Status status, Pageable pageable); } diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java index 9fd6e01..5e5a80d 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -1,7 +1,20 @@ package com.ott.domain.media_tag.repository; import com.ott.domain.media_tag.domain.MediaTag; + +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { + + // 최근 좋아요한 미디어의 태그 ID 조회 + // [2단계] 미디어에 대한 태그들을 전부 가져옴 + @Query(""" + SELECT mt.tag.id FROM MediaTag mt + WHERE mt.media IN :mediaIds + """) + List findTagIdsByMediaIds(@Param("mediaIds") List mediaIds); } From c5ac73fa81bf493c4e34b65c2a4b80c32f4a1200 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 11:05:31 +0900 Subject: [PATCH 241/257] =?UTF-8?q?[FIX]:=20queryDSL=20null=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bookmark/repository/BookmarkRepositoryImpl.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java index 132968c..1fd0e3a 100644 --- a/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java @@ -1,6 +1,7 @@ package com.ott.domain.bookmark.repository; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -38,7 +39,7 @@ public Page findBookmarkMediaList(Long memberId, Pageab media.title, media.description, media.posterUrl, - media.mediaType.when(CONTENTS).then(playback.positionSec.coalesce(0)).otherwise((Integer) null), // SERIES는 null, CONTENTS만 playback 없으면 0 + media.mediaType.when(CONTENTS).then(playback.positionSec.coalesce(0)).otherwise(Expressions.nullExpression(Integer.class)), // SERIES는 null, CONTENTS만 playback 없으면 0 contents.duration // SERIES면 null (LEFT JOIN 미매칭) )) .from(bookmark) @@ -74,4 +75,4 @@ public Page findBookmarkMediaList(Long memberId, Pageab return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } -} \ No newline at end of file +} From e0c8e511ad7cff8937b18e5d400edae33cc689d3 Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:26:42 +0900 Subject: [PATCH 242/257] =?UTF-8?q?[FIX]:=20JOIN=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20=EC=8B=9C=EC=B2=AD=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EA=B0=80=EC=A4=91=EC=B9=98=20=EC=99=9C?= =?UTF-8?q?=EA=B3=A1=20=ED=98=84=EC=83=81=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PlaylistPreferenceService.java | 9 +++++-- .../media/repository/MediaRepositoryImpl.java | 15 ++++++++---- .../repository/MediaTagRepository.java | 2 +- .../repository/PlaybackRepository.java | 24 ++++++++----------- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java index 0e9726e..2ab36ba 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java @@ -55,9 +55,14 @@ public List getTopTags(Long memberId){ // 2. 최근 시청 이력 가중치 반영 (+3점) - playbackRepository.findRecentTagIdsByMemberId(memberId, Status.ACTIVE, limit100) - .forEach(id -> tagScores.merge(id, 3, Integer::sum)); + // [1단계] 최근 시청한 영상에 대해 '영상 ID' 최대 100개를 가져옴 + List playedMediaIds = playbackRepository.findRecentPlayedMediaIds(memberId, Status.ACTIVE, limit100); + // [2단계] 가져온 영상이 하나라도 있다면, 그 영상들의 '태그 ID'를 한 번에 가져와 점수 부여 + if (!playedMediaIds.isEmpty()) { + mediaTagRepository.findTagIdsByMediaIds(playedMediaIds) + .forEach(id -> tagScores.merge(id, 3, Integer::sum)); // 혹은 totalScores.merge + } // 3. 점수가 가장 높은 순(내림차순)으로 정렬한 뒤, 상위 3개의 태그 ID만 추출 List topTagIds = tagScores.entrySet().stream() diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java index 470de41..6ccc9bf 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java @@ -180,8 +180,10 @@ public Page findHistoryPlaylists(Long memberId, Long excludeMediaId, Page JPAQuery countQuery = queryFactory .select(playback.count()) .from(playback) + .join(playback.contents.media, media) .where( playback.member.id.eq(memberId), + isActiveAndPublic(), excludeId(excludeMediaId)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); @@ -206,9 +208,11 @@ public Page findBookmarkedPlaylists(Long memberId, Long excludeMediaId, P JPAQuery countQuery = queryFactory .select(bookmark.count()) .from(bookmark) + .join(bookmark.media, media) .where( bookmark.member.id.eq(memberId), bookmark.status.eq(Status.ACTIVE), + isActiveAndPublic(), excludeId(excludeMediaId)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); @@ -233,8 +237,10 @@ public Page findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable JPAQuery countQuery = queryFactory .select(mediaTag.count()) .from(mediaTag) + .join(mediaTag.media, media) .where( mediaTag.tag.id.eq(tagId), + isActiveAndPublic(), excludeId(excludeMediaId)); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); @@ -263,8 +269,9 @@ public List findRecommendedMedias(Map tagScores, Long excl // 이때는 가장 최근 신작 노출 if (tagScores.isEmpty()) { return queryFactory.selectFrom(media) - .where(media.status.eq(Status.ACTIVE), - media.publicStatus.eq(PublicStatus.PUBLIC)) + .where( + isActiveAndPublic(), + excludeId(excludeMediaId)) .orderBy(media.id.desc()) .limit(limit) .offset(offset) @@ -322,8 +329,8 @@ private BooleanExpression uploaderIdEq(Long uploaderId) { private BooleanExpression isActiveAndPublic() { // Status.ACTIVE와 PublicStatus.PUBLIC 조건을 결합 - return media.status.eq(com.ott.domain.common.Status.ACTIVE) - .and(media.publicStatus.eq(com.ott.domain.common.PublicStatus.PUBLIC)); + return media.status.eq(Status.ACTIVE) + .and(media.publicStatus.eq(PublicStatus.PUBLIC)); } private BooleanExpression excludeId(Long excludeMediaId) { diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java index 5e5a80d..80afca1 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -10,7 +10,7 @@ public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { - // 최근 좋아요한 미디어의 태그 ID 조회 + // 미디어의 태그 ID 조회 (좋아욧 & 시청 이력에서 사용) // [2단계] 미디어에 대한 태그들을 전부 가져옴 @Query(""" SELECT mt.tag.id FROM MediaTag mt diff --git a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java index c615a68..1860e1b 100644 --- a/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java @@ -11,19 +11,15 @@ import com.ott.domain.playback.domain.Playback; public interface PlaybackRepository extends JpaRepository { - // 가장 최신으로 하나만 가져오기 - // Optional findFirstByMemberIdAndContentsIdAndStatus(Long memberId, - // Long contentsId, Status status); - - // 최신 시청 이력 100개 가져오기 - 선호 태그 조사용 + // 최근 시청한 미디어의 태그 ID 조회 + // 1단계 - 최근 시청 이력 100개 가져오기 (JOIN 보다 LIMIT 먼저) @Query(""" - SELECT mt.tag.id FROM Playback p - JOIN MediaTag mt ON p.contents.media.id = mt.media.id - WHERE p.member.id = :memberId AND p.status = :status - ORDER BY p.modifiedDate DESC - """) - List findRecentTagIdsByMemberId( - @Param("memberId") Long memberId, - @Param("status") Status status, - Pageable pageable); // pageable로 100개로 리미트 제한 + SELECT p.contents.media.id FROM Playback p + WHERE p.member.id = :memberId AND p.status = :status + ORDER BY p.modifiedDate DESC + """) + List findRecentPlayedMediaIds( + @Param("memberId") Long memberId, + @Param("status") Status status, + Pageable pageable); } From e667ed1537283a53847b4cd3db59dd4d3bc4c395 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 11:31:21 +0900 Subject: [PATCH 243/257] =?UTF-8?q?[REFACTOR]:=20=ED=83=9C=EA=B7=B8,=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_user/member/controller/MemberApi.java | 115 +------------- .../member/controller/MemberController.java | 43 ------ .../member/service/MemberService.java | 140 +----------------- .../playlist/controller/PlayListAPI.java | 82 ++++++++++ .../controller/PlaylistController.java | 43 ++++++ .../dto/response/RecentWatchResponse.java | 2 +- .../dto/response/TagPlaylistResponse.java} | 8 +- .../playlist/service/PlaylistService.java | 61 ++++++-- .../ott/api_user/tag/controller/TagAPI.java | 80 ++++++++++ .../tag/controller/TagController.java | 42 ++++++ .../response/TagMonthlyCompareResponse.java | 2 +- .../dto/response/TagRankingResponse.java | 2 +- .../ott/api_user/tag/service/TagService.java | 121 +++++++++++++++ 13 files changed, 432 insertions(+), 309 deletions(-) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java rename apps/api-user/src/main/java/com/ott/api_user/{member => playlist}/dto/response/RecentWatchResponse.java (96%) rename apps/api-user/src/main/java/com/ott/api_user/{member/dto/response/TagContentResponse.java => playlist/dto/response/TagPlaylistResponse.java} (81%) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java create mode 100644 apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java rename apps/api-user/src/main/java/com/ott/api_user/{member => tag}/dto/response/TagMonthlyCompareResponse.java (95%) rename apps/api-user/src/main/java/com/ott/api_user/{member => tag}/dto/response/TagRankingResponse.java (97%) create mode 100644 apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java index b613f67..781ffd7 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java @@ -4,7 +4,6 @@ import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.*; import com.ott.common.web.exception.ErrorResponse; -import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -17,13 +16,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.PositiveOrZero; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; @RequestMapping("/member") @Tag(name = "Member", description = "마이페이지 API") @@ -113,6 +109,9 @@ ResponseEntity> setPreferredTags( ); + // ------------------------------------------------------- + // 온보딩 건너 뛰기 + // ------------------------------------------------------- @Operation(summary = "온보딩 건너뛰기", description = "온보딩을 건너뛸 경우 onboardingCompleted를 true로 변경합니다.") @ApiResponses({ @ApiResponse(responseCode = "204", description = "건너뛰기 성공"), @@ -126,114 +125,6 @@ ResponseEntity skipOnboarding( @AuthenticationPrincipal Long memberId); - - // ------------------------------------------------------- - // 시청이력 기반 태그 랭킹 조회 - // ------------------------------------------------------- - @Operation(summary = "시청이력 기반 태그 랭킹 조회", description = "최근 1달간 시청이력을 기반으로 상위 4개 태그 + 기타 항목을 반환" - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", description = "조회 성공", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagRankingResponse.class) - ) - ), - @ApiResponse( - responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", description = "회원을 찾을 수 없음", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) - ) - ) - }) - @GetMapping("/me/tag/ranking") - ResponseEntity> getTagRanking( - @AuthenticationPrincipal Long memberId); - - - // ------------------------------------------------------- - // 태그 월별 시청 count 비교 - // ------------------------------------------------------- - @Operation(summary = "태그 월별 시청 count 비교", description = "특정 태그의 이번 달 vs 저번 달 시청 횟수를 반환" - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", description = "조회 성공", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagMonthlyCompareResponse.class) - ) - ), - @ApiResponse( - responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) - ) - ) - }) - @GetMapping("/me/tag/ranking/{tagId}") - ResponseEntity> getTagMonthlyCompare( - @AuthenticationPrincipal Long memberId, - @Positive @PathVariable Long tagId - ); - - - // ------------------------------------------------------- - // 태그별 추천 콘텐츠 목록 조회 - // ------------------------------------------------------- - @Operation(summary = "태그별 추천 콘텐츠 목록 조회", description = "해당 태그에 속하는 콘텐츠를 최대 20개 반환" - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", description = "조회 성공", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagContentResponse.class) - ) - ), - @ApiResponse( - responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) - ) - ) - }) - @GetMapping("/me/taglist/{tagId}") - ResponseEntity>> getRecommendContentsByTag( - @AuthenticationPrincipal Long memberId, - @Positive @PathVariable Long tagId - ); - - - // ------------------------------------------------------- - // 전체 시청이력 플레이리스트 페이징 조회 - // ------------------------------------------------------- - @Operation(summary = "시청이력 플레이리스트 조회", description = "전체 시청이력을 최신순으로 10개씩 페이징 조회합니다. 이어보기 시점 포함.") - @ApiResponses({ - @ApiResponse( - responseCode = "200", description = "조회 성공", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))), - @ApiResponse( - responseCode = "401", description = "인증 실패", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse( - responseCode = "404", description = "회원을 찾을 수 없음", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) - }) - @GetMapping("/me/history/playlist") - ResponseEntity>> getWatchHistoryPlaylist( - @AuthenticationPrincipal Long memberId, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") - @PositiveOrZero @RequestParam(defaultValue = "0") Integer page - ); - // ============================================================ // 회원 탈퇴 // ============================================================ diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java index a3d1839..b80cd7e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java @@ -5,19 +5,14 @@ import com.ott.api_user.member.dto.response.*; import com.ott.api_user.member.service.MemberService; import com.ott.common.security.util.CookieUtil; -import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/member") @RequiredArgsConstructor @@ -53,44 +48,6 @@ public ResponseEntity> setPreferredTags( return ResponseEntity.noContent().build(); } - // 유저 별 1달 간 상위 태그 조회 - @Override - @GetMapping("/me/tag/ranking") - public ResponseEntity> getTagRanking( - @AuthenticationPrincipal Long memberId - ) { - return ResponseEntity.ok(SuccessResponse.of(memberService.getTagRanking(memberId))); - } - - // 유저 별 2달 간 특정 태그 조회 - @Override - @GetMapping("/me/tag/ranking/{tagId}") - public ResponseEntity> getTagMonthlyCompare( - @AuthenticationPrincipal Long memberId, - @Positive @PathVariable Long tagId - ) { - return ResponseEntity.ok(SuccessResponse.of(memberService.getTagMonthlyCompare(memberId, tagId))); - } - - // 태그 별 추천 리스트 조회 - @Override - @GetMapping("/me/taglist/{tagId}") - public ResponseEntity>> getRecommendContentsByTag( - @AuthenticationPrincipal Long memberId, - @Positive @PathVariable Long tagId - ) { - return ResponseEntity.ok(SuccessResponse.of(memberService.getRecommendContentsByTag(memberId, tagId))); - } - - // 과거 시청 이력 조회, 10개씩 조회 - @Override - @GetMapping("/me/history/playlist") - public ResponseEntity>> getWatchHistoryPlaylist( - @AuthenticationPrincipal Long memberId, - @PositiveOrZero @RequestParam(defaultValue = "0") Integer page - ) { - return ResponseEntity.ok(SuccessResponse.of(memberService.getWatchHistoryPlaylist(memberId, page))); - } // 회원 탈퇴 - 현재 soft delete // 회원 탈퇴 시 DB + 브라우저 토큰 삭제 diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index afa2216..b114b52 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -4,8 +4,8 @@ import com.ott.api_user.member.dto.request.SetPreferredTagRequest; import com.ott.api_user.member.dto.request.UpdateMemberRequest; import com.ott.api_user.member.dto.response.*; -import com.ott.api_user.member.dto.response.TagMonthlyCompareResponse.MonthlyCount; -import com.ott.api_user.member.dto.response.TagRankingResponse.TagRankItem; +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.PageInfo; @@ -25,7 +25,6 @@ import com.ott.domain.tag.domain.Tag; import com.ott.domain.tag.repository.TagRepository; import com.ott.domain.watch_history.repository.RecentWatchProjection; -import com.ott.domain.watch_history.repository.TagRankingProjection; import com.ott.domain.watch_history.repository.WatchHistoryRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -33,10 +32,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.time.YearMonth; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; import java.util.List; @Service @@ -144,137 +139,6 @@ public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { findMember.completeOnboarding(); } - /** - * 마이페이지 - 시청이력 기반 상위 태그 랭킹 조회 1달 - * - 상위 4개: 개별 태그 항목 - * - 나머지: count 합산하여 기타 항목으로 반환 - */ - @Transactional(readOnly = true) - public TagRankingResponse getTagRanking(Long memberId) { - memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - // 집계일과 마감일 선정 1일~말일까지 - YearMonth currentYearMonth = YearMonth.now(); - LocalDateTime startDate = currentYearMonth.atDay(1).atStartOfDay(); - LocalDateTime endDate = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // 다음 달 1일 00:00:00 - - List tagRankingProjections = - watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween(memberId, startDate, endDate); - - List rankItems = new ArrayList<>(); - - // 시청이력이 없을 경우 빈 리스트가 전달됨 - if (tagRankingProjections.isEmpty()) { - return TagRankingResponse.builder().rankings(rankItems).build(); - } - - int topN = Math.min(4, tagRankingProjections.size()); - - // 상위 4개 추가 - for (int i = 0; i < topN; i++) { - TagRankingProjection projection = tagRankingProjections.get(i); - rankItems.add(TagRankItem.of(projection.getTagId(), projection.getTagName(), projection.getCount())); - } - - // 나머지 → 기타로 합산 - if (tagRankingProjections.size() > 4) { - long etcCount = tagRankingProjections.subList(4, tagRankingProjections.size()) - .stream() - .mapToLong(TagRankingProjection::getCount) - .sum(); - rankItems.add(TagRankItem.ofEtc(etcCount)); - } - - return TagRankingResponse.builder().rankings(rankItems).build(); - } - - - /** - * 마이페이지 - 특정 태그의 이번 달 vs 저번 달 시청 count 비교 - */ - @Transactional(readOnly = true) - public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) { - memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - Tag findTag = tagRepository.findByIdAndStatus(tagId, Status.ACTIVE) - .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); - - // 이번 달 범위 - YearMonth currentYearMonth = YearMonth.now(); - LocalDateTime currentStart = currentYearMonth.atDay(1).atStartOfDay(); - LocalDateTime currentEnd = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // 다음 달 1일 00:00:00 - - // 저번 달 범위 - YearMonth prevYearMonth = currentYearMonth.minusMonths(1); - LocalDateTime prevStart = prevYearMonth.atDay(1).atStartOfDay(); - LocalDateTime prevEnd = currentYearMonth.atDay(1).atStartOfDay(); // 이번 달 1일 00:00:00 - - Long currentCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, currentStart, currentEnd); - Long previousCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, prevStart, prevEnd); - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); - - // 저번 달 시청 기록이 없으면 null - MonthlyCount previousMonth = previousCount > 0 - ? MonthlyCount.builder() - .yearMonth(prevYearMonth.format(formatter)) - .count(previousCount) - .build() - : null; - - return TagMonthlyCompareResponse.builder() - .tagId(findTag.getId()) - .tagName(findTag.getName()) - .currentMonth(MonthlyCount.builder() - .yearMonth(currentYearMonth.format(formatter)) - .count(currentCount) - .build()) - .previousMonth(previousMonth) - .build(); - } - - // 태그별 추천 콘텐츠 목록 조회 (최대 20개) - @Transactional(readOnly = true) - public List getRecommendContentsByTag(Long memberId, Long tagId) { - memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - tagRepository.findById(tagId) - .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); - - return mediaRepository.findRecommendContentsByTagId(tagId, 20) - .stream() - .map(TagContentResponse::from) - .toList(); - } - - // 전체 시청이력 플레이리스트 페이징 조회 (최신순, 10개씩) - @Transactional(readOnly = true) - public PageResponse getWatchHistoryPlaylist(Long memberId, Integer page) { - memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - PageRequest pageable = PageRequest.of(page, 10); - - Page watchPage = - watchHistoryRepository.findWatchHistoryByMemberId(memberId, pageable); - - List dataList = watchPage.getContent() - .stream() - .map(RecentWatchResponse::from) - .toList(); - - PageInfo pageInfo = PageInfo.toPageInfo( - watchPage.getNumber(), - watchPage.getTotalPages(), - watchPage.getSize() - ); - - return PageResponse.toPageResponse(pageInfo, dataList); - } - /** * 회원 탈퇴 diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java new file mode 100644 index 0000000..3734ca5 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java @@ -0,0 +1,82 @@ +package com.ott.api_user.playlist.controller; + +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 java.util.List; + +@RequestMapping("/playlists") +@SecurityRequirement(name = "BearerAuth") // 인증인가 확인 +@Tag(name = "Playlist", description = "플레이리스트 API") +public interface PlayListAPI { + + // ------------------------------------------------------- + // 태그별 추천 콘텐츠 목록 조회 + // ------------------------------------------------------- + @Operation(summary = "태그별 추천 콘텐츠 리스트 조회", description = "해당 태그에 속하는 콘텐츠를 최대 20개 반환" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagPlaylistResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/{tagId}") + ResponseEntity>> getRecommendContentsByTag( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ); + + + // ------------------------------------------------------- + // 전체 시청이력 플레이리스트 페이징 조회 + // ------------------------------------------------------- + @Operation(summary = "과거 시청 이력 리스트 조회", description = "전체 시청이력을 최신순으로 10개씩 페이징 조회합니다. 이어보기 시점 포함.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = PageResponse.class))), + @ApiResponse( + responseCode = "401", description = "인증 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me/history") + ResponseEntity>> getWatchHistoryPlaylist( + @AuthenticationPrincipal Long memberId, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page + ); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java new file mode 100644 index 0000000..c466ec2 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -0,0 +1,43 @@ +package com.ott.api_user.playlist.controller; + +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.service.PlaylistService; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.common.web.response.PageResponse; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/playlists") +@RequiredArgsConstructor +public class PlaylistController implements PlayListAPI { + + private final PlaylistService playlistService; + + // 태그 별 추천 리스트 조회 + @Override + @GetMapping("/me/{tagId}") + public ResponseEntity>> getRecommendContentsByTag( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ) { + return ResponseEntity.ok(SuccessResponse.of(playlistService.getRecommendContentsByTag(memberId, tagId))); + } + + // 과거 시청 이력 조회, 10개씩 조회 + @Override + @GetMapping("/me/history") + public ResponseEntity>> getWatchHistoryPlaylist( + @AuthenticationPrincipal Long memberId, + @PositiveOrZero @RequestParam(defaultValue = "0") Integer page + ) { + return ResponseEntity.ok(SuccessResponse.of(playlistService.getWatchHistoryPlaylist(memberId, page))); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java similarity index 96% rename from apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java rename to apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java index 2aca58e..7d8162e 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_user.member.dto.response; +package com.ott.api_user.playlist.dto.response; import com.ott.domain.common.MediaType; import com.ott.domain.watch_history.repository.RecentWatchProjection; diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java similarity index 81% rename from apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java rename to apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java index 57ab1b4..173337b 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_user.member.dto.response; +package com.ott.api_user.playlist.dto.response; import com.ott.domain.common.MediaType; import com.ott.domain.media.repository.TagContentProjection; @@ -11,7 +11,7 @@ @Builder @AllArgsConstructor @Schema(description = "태그별 추천 콘텐츠 아이템") -public class TagContentResponse { +public class TagPlaylistResponse { @Schema(type = "Long", example = "5", description = "미디어 ID") private Long mediaId; @@ -22,8 +22,8 @@ public class TagContentResponse { @Schema(type = "String", example = "SERIES", description = "미디어 타입 (SERIES / CONTENTS)") private MediaType mediaType; - public static TagContentResponse from(TagContentProjection projection) { - return TagContentResponse.builder() + public static TagPlaylistResponse from(TagContentProjection projection) { + return TagPlaylistResponse.builder() .mediaId(projection.getMediaId()) .posterUrl(projection.getPosterUrl()) .mediaType(projection.getMediaType()) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java index 8452ebc..991e193 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java @@ -1,33 +1,76 @@ package com.ott.api_user.playlist.service; import java.util.List; -import java.util.stream.Collectors; +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.domain.media.repository.MediaRepository; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.RecentWatchProjection; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; 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 com.ott.api_user.common.ContentSource; -import com.ott.api_user.common.dto.ContentListElement; 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.PublicStatus; -import com.ott.domain.common.Status; -import com.ott.domain.contents.domain.Contents; import com.ott.domain.contents.repository.ContentsRepository; -import com.ott.domain.media.domain.Media; + import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class PlaylistService { private final ContentsRepository contentsRepository; + private final MemberRepository memberRepository; + private final TagRepository tagRepository; + private final MediaRepository mediaRepository; + private final WatchHistoryRepository watchHistoryRepository; + + + // 태그별 추천 콘텐츠 목록 조회 (최대 20개) + @Transactional(readOnly = true) + public List getRecommendContentsByTag(Long memberId, Long tagId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + tagRepository.findById(tagId) + .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); + + return mediaRepository.findRecommendContentsByTagId(tagId, 20) + .stream() + .map(TagPlaylistResponse::from) + .toList(); + } + + // 전체 시청이력 플레이리스트 페이징 조회 (최신순, 10개씩) + @Transactional(readOnly = true) + public PageResponse getWatchHistoryPlaylist(Long memberId, Integer page) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + PageRequest pageable = PageRequest.of(page, 10); + + Page watchPage = + watchHistoryRepository.findWatchHistoryByMemberId(memberId, pageable); + + List dataList = watchPage.getContent() + .stream() + .map(RecentWatchResponse::from) + .toList(); + + PageInfo pageInfo = PageInfo.toPageInfo( + watchPage.getNumber(), + watchPage.getTotalPages(), + watchPage.getSize() + ); + return PageResponse.toPageResponse(pageInfo, dataList); + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java new file mode 100644 index 0000000..11e72c0 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java @@ -0,0 +1,80 @@ +package com.ott.api_user.tag.controller; + +import com.ott.api_user.tag.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.tag.dto.response.TagRankingResponse; +import com.ott.common.web.exception.ErrorResponse; +import com.ott.common.web.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@RequestMapping("/tag") +@Tag(name = "Tag", description = "태그 API") +@SecurityRequirement(name = "BearerAuth") // 인증인가 확인 +public interface TagAPI { + + // ------------------------------------------------------- + // 시청이력 기반 태그 랭킹 조회 + // ------------------------------------------------------- + @Operation(summary = "시청이력 기반 태그 랭킹 조회", description = "최근 1달간 시청이력을 기반으로 상위 4개 태그 + 기타 항목을 반환" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagRankingResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "회원을 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/ranking") + ResponseEntity> getTagRanking( + @AuthenticationPrincipal Long memberId); + + + // ------------------------------------------------------- + // 태그 월별 시청 count 비교 + // ------------------------------------------------------- + @Operation(summary = "태그 월별 시청 count 비교", description = "특정 태그의 이번 달 vs 저번 달 시청 횟수를 반환" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagMonthlyCompareResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", description = "회원 또는 태그를 찾을 수 없음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + @GetMapping("/me/ranking/{tagId}") + ResponseEntity> getTagMonthlyCompare( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java new file mode 100644 index 0000000..c137dcf --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java @@ -0,0 +1,42 @@ +package com.ott.api_user.tag.controller; + +import com.ott.api_user.tag.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.tag.dto.response.TagRankingResponse; +import com.ott.api_user.tag.service.TagService; +import com.ott.common.web.response.SuccessResponse; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +@RestController +@RequestMapping("/tag") +@RequiredArgsConstructor +public class TagController implements TagAPI { + + private final TagService tagService; + + + // 유저 별 1달 간 상위 태그 조회 + @Override + @GetMapping("/me/ranking") + public ResponseEntity> getTagRanking( + @AuthenticationPrincipal Long memberId + ) { + return ResponseEntity.ok(SuccessResponse.of(tagService.getTagRanking(memberId))); + } + + // 유저 별 2달 간 특정 태그 조회 + @Override + @GetMapping("/me/ranking/{tagId}") + public ResponseEntity> getTagMonthlyCompare( + @AuthenticationPrincipal Long memberId, + @Positive @PathVariable Long tagId + ) { + return ResponseEntity.ok(SuccessResponse.of(tagService.getTagMonthlyCompare(memberId, tagId))); + } +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java similarity index 95% rename from apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java rename to apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java index 60aa945..522a24d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_user.member.dto.response; +package com.ott.api_user.tag.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java similarity index 97% rename from apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java rename to apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java index dfcf378..82b6595 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java @@ -1,4 +1,4 @@ -package com.ott.api_user.member.dto.response; +package com.ott.api_user.tag.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java b/apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java new file mode 100644 index 0000000..2110283 --- /dev/null +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java @@ -0,0 +1,121 @@ +package com.ott.api_user.tag.service; + +import com.ott.api_user.member.service.MemberService; +import com.ott.api_user.tag.dto.response.TagMonthlyCompareResponse; +import com.ott.api_user.tag.dto.response.TagRankingResponse; +import com.ott.common.web.exception.BusinessException; +import com.ott.common.web.exception.ErrorCode; +import com.ott.domain.common.Status; +import com.ott.domain.member.repository.MemberRepository; +import com.ott.domain.tag.domain.Tag; +import com.ott.domain.tag.repository.TagRepository; +import com.ott.domain.watch_history.repository.TagRankingProjection; +import com.ott.domain.watch_history.repository.WatchHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TagService { + + private final MemberRepository memberRepository; + private final WatchHistoryRepository watchHistoryRepository; + private final TagRepository tagRepository; + + /** + * 마이페이지 - 시청이력 기반 상위 태그 랭킹 조회 1달 + * - 상위 4개: 개별 태그 항목 + * - 나머지: count 합산하여 기타 항목으로 반환 + */ + @Transactional(readOnly = true) + public TagRankingResponse getTagRanking(Long memberId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 집계일과 마감일 선정 1일~말일까지 + YearMonth currentYearMonth = YearMonth.now(); + LocalDateTime startDate = currentYearMonth.atDay(1).atStartOfDay(); + LocalDateTime endDate = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // 다음 달 1일 00:00:00 + + List tagRankingProjections = + watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween(memberId, startDate, endDate); + + List rankItems = new ArrayList<>(); + + // 시청이력이 없을 경우 빈 리스트가 전달됨 + if (tagRankingProjections.isEmpty()) { + return TagRankingResponse.builder().rankings(rankItems).build(); + } + + int topN = Math.min(4, tagRankingProjections.size()); + + // 상위 4개 추가 + for (int i = 0; i < topN; i++) { + TagRankingProjection projection = tagRankingProjections.get(i); + rankItems.add(TagRankingResponse.TagRankItem.of(projection.getTagId(), projection.getTagName(), projection.getCount())); + } + + // 나머지 → 기타로 합산 + if (tagRankingProjections.size() > 4) { + long etcCount = tagRankingProjections.subList(4, tagRankingProjections.size()) + .stream() + .mapToLong(TagRankingProjection::getCount) + .sum(); + rankItems.add(TagRankingResponse.TagRankItem.ofEtc(etcCount)); + } + + return TagRankingResponse.builder().rankings(rankItems).build(); + } + + /** + * 마이페이지 - 특정 태그의 이번 달 vs 저번 달 시청 count 비교 + */ + @Transactional(readOnly = true) + public TagMonthlyCompareResponse getTagMonthlyCompare(Long memberId, Long tagId) { + memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Tag findTag = tagRepository.findByIdAndStatus(tagId, Status.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.TAG_NOT_FOUND)); + + // 이번 달 범위 + YearMonth currentYearMonth = YearMonth.now(); + LocalDateTime currentStart = currentYearMonth.atDay(1).atStartOfDay(); + LocalDateTime currentEnd = currentYearMonth.plusMonths(1).atDay(1).atStartOfDay(); // 다음 달 1일 00:00:00 + + // 저번 달 범위 + YearMonth prevYearMonth = currentYearMonth.minusMonths(1); + LocalDateTime prevStart = prevYearMonth.atDay(1).atStartOfDay(); + LocalDateTime prevEnd = currentYearMonth.atDay(1).atStartOfDay(); // 이번 달 1일 00:00:00 + + Long currentCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, currentStart, currentEnd); + Long previousCount = watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId, prevStart, prevEnd); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); + + // 저번 달 시청 기록이 없으면 null + TagMonthlyCompareResponse.MonthlyCount previousMonth = previousCount > 0 + ? TagMonthlyCompareResponse.MonthlyCount.builder() + .yearMonth(prevYearMonth.format(formatter)) + .count(previousCount) + .build() + : null; + + return TagMonthlyCompareResponse.builder() + .tagId(findTag.getId()) + .tagName(findTag.getName()) + .currentMonth(TagMonthlyCompareResponse.MonthlyCount.builder() + .yearMonth(currentYearMonth.format(formatter)) + .count(currentCount) + .build()) + .previousMonth(previousMonth) + .build(); + } +} From e056f35a9bd916c0666c08a9ae433436bfb4f210 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 4 Mar 2026 11:40:38 +0900 Subject: [PATCH 244/257] =?UTF-8?q?[CHORE]:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-monitoring.yml | 209 ++++++++ .../ott/api_admin/config/SecurityConfig.java | 7 + .../src/main/resources/application.yml | 2 +- .../ott/api_user/config/SecurityConfig.java | 4 +- apps/monitoring/docker-compose.prod.yml | 4 + apps/monitoring/docker-compose.yml | 33 ++ .../provisioning/dashboards/dashboards.yml | 13 + .../json/New dashboard-1772584885701.json | 447 ++++++++++++++++++ .../provisioning/datasources/prometheus.yml | 10 + .../monitoring/prometheus/prometheus.prod.yml | 41 ++ .../prometheus/prometheus.prod.yml.tpl | 41 ++ apps/monitoring/prometheus/prometheus.yml | 41 ++ .../src/main/resources/application.yml | 9 + docker-compose.yml | 35 ++ modules/common-web/build.gradle | 4 +- 15 files changed, 897 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy-monitoring.yml create mode 100644 apps/monitoring/docker-compose.prod.yml create mode 100644 apps/monitoring/docker-compose.yml create mode 100644 apps/monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json create mode 100644 apps/monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 apps/monitoring/prometheus/prometheus.prod.yml create mode 100644 apps/monitoring/prometheus/prometheus.prod.yml.tpl create mode 100644 apps/monitoring/prometheus/prometheus.yml diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml new file mode 100644 index 0000000..498a1c3 --- /dev/null +++ b/.github/workflows/deploy-monitoring.yml @@ -0,0 +1,209 @@ +name: Deploy Monitoring Stack + +on: + workflow_dispatch: + inputs: + monitoring_instance_tag: + description: "EC2 Name tag for monitoring server" + required: true + default: "oplust-monitoring-ec2" + type: string + user_api_target_ssm_param: + description: "SSM parameter name for user-api target (host:port)" + required: true + default: "/oplust/monitoring/targets/user-api" + type: string + admin_api_target_ssm_param: + description: "SSM parameter name for admin-api target (host:port)" + required: true + default: "/oplust/monitoring/targets/admin-api" + type: string + transcoder_target_ssm_param: + description: "SSM parameter name for transcoder target (host:port)" + required: true + default: "/oplust/monitoring/targets/transcoder" + type: string + grafana_password_ssm_param: + description: "SSM SecureString parameter name for Grafana admin password" + required: true + default: "/oplust/monitoring/grafana-admin-password" + type: string + grafana_admin_password: + description: "Optional override password (leave blank to use SSM)" + required: false + type: string + +env: + AWS_REGION: ap-northeast-2 + MONITORING_ROOT: /opt/oplust-monitoring + +jobs: + deploy-monitoring: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Resolve scrape targets from SSM + env: + USER_API_TARGET_SSM_PARAM: ${{ github.event.inputs.user_api_target_ssm_param }} + ADMIN_API_TARGET_SSM_PARAM: ${{ github.event.inputs.admin_api_target_ssm_param }} + TRANSCODER_TARGET_SSM_PARAM: ${{ github.event.inputs.transcoder_target_ssm_param }} + run: | + set -euo pipefail + + USER_API_TARGET=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$USER_API_TARGET_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + + ADMIN_API_TARGET=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$ADMIN_API_TARGET_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + + TRANSCODER_TARGET=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$TRANSCODER_TARGET_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + + if [ -z "$USER_API_TARGET" ] || [ "$USER_API_TARGET" = "None" ] || \ + [ -z "$ADMIN_API_TARGET" ] || [ "$ADMIN_API_TARGET" = "None" ] || \ + [ -z "$TRANSCODER_TARGET" ] || [ "$TRANSCODER_TARGET" = "None" ]; then + echo "One or more scrape targets are empty. Check SSM parameter values." >&2 + exit 1 + fi + + echo "USER_API_TARGET=$USER_API_TARGET" >> "$GITHUB_ENV" + echo "ADMIN_API_TARGET=$ADMIN_API_TARGET" >> "$GITHUB_ENV" + echo "TRANSCODER_TARGET=$TRANSCODER_TARGET" >> "$GITHUB_ENV" + + - name: Render prod Prometheus config + run: | + set -euo pipefail + + sed \ + -e "s|__USER_API_TARGET__|${USER_API_TARGET}|g" \ + -e "s|__ADMIN_API_TARGET__|${ADMIN_API_TARGET}|g" \ + -e "s|__TRANSCODER_TARGET__|${TRANSCODER_TARGET}|g" \ + apps/monitoring/prometheus/prometheus.prod.yml.tpl > apps/monitoring/prometheus/prometheus.prod.yml + + - name: Resolve Grafana admin password + env: + GRAFANA_ADMIN_PASSWORD_INPUT: ${{ github.event.inputs.grafana_admin_password }} + GRAFANA_PASSWORD_SSM_PARAM: ${{ github.event.inputs.grafana_password_ssm_param }} + run: | + set -euo pipefail + + if [ -n "$GRAFANA_ADMIN_PASSWORD_INPUT" ]; then + GRAFANA_PASSWORD="$GRAFANA_ADMIN_PASSWORD_INPUT" + else + GRAFANA_PASSWORD=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$GRAFANA_PASSWORD_SSM_PARAM" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text) + fi + + if [ -z "$GRAFANA_PASSWORD" ] || [ "$GRAFANA_PASSWORD" = "None" ]; then + echo "Grafana admin password is empty. Check input or SSM parameter." >&2 + exit 1 + fi + + echo "GRAFANA_PASSWORD=$GRAFANA_PASSWORD" >> "$GITHUB_ENV" + + - name: Deploy monitoring stack via SSM + env: + INSTANCE_TAG: ${{ github.event.inputs.monitoring_instance_tag }} + run: | + set -euo pipefail + + INSTANCE_ID=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${INSTANCE_TAG}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then + echo "No running monitoring instance found for tag: $INSTANCE_TAG" >&2 + exit 1 + fi + + COMPOSE_B64=$(base64 -w 0 apps/monitoring/docker-compose.yml) + COMPOSE_PROD_B64=$(base64 -w 0 apps/monitoring/docker-compose.prod.yml) + PROM_PROD_B64=$(base64 -w 0 apps/monitoring/prometheus/prometheus.prod.yml) + DATASOURCE_B64=$(base64 -w 0 apps/monitoring/grafana/provisioning/datasources/prometheus.yml) + DASH_PROVIDER_B64=$(base64 -w 0 apps/monitoring/grafana/provisioning/dashboards/dashboards.yml) + + PARAMS=$(jq -nc \ + --arg c1 "set -euo pipefail" \ + --arg c2 "sudo mkdir -p ${MONITORING_ROOT}/prometheus ${MONITORING_ROOT}/grafana/provisioning/datasources ${MONITORING_ROOT}/grafana/provisioning/dashboards/json ${MONITORING_ROOT}/grafana/provisioning/dashboards" \ + --arg c3 "echo '$COMPOSE_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/docker-compose.yml >/dev/null" \ + --arg c4 "echo '$COMPOSE_PROD_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/docker-compose.prod.yml >/dev/null" \ + --arg c5 "echo '$PROM_PROD_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/prometheus/prometheus.prod.yml >/dev/null" \ + --arg c6 "echo '$DATASOURCE_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/datasources/prometheus.yml >/dev/null" \ + --arg c7 "echo '$DASH_PROVIDER_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/dashboards/dashboards.yml >/dev/null" \ + --arg c8 "sudo sh -c 'cat > ${MONITORING_ROOT}/.env <<\"EOF\"\nGF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}\nEOF'" \ + --arg c9 "cd ${MONITORING_ROOT} && sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ + '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9]}') + + CMD_ID=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy monitoring stack" \ + --parameters "$PARAMS" \ + --query 'Command.CommandId' \ + --output text) + + echo "command_id=$CMD_ID instance_id=$INSTANCE_ID" + + for _ in $(seq 1 120); do + STATUS=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$STATUS" in + Success) + echo "Monitoring deployment success" + exit 0 + ;; + Failed|Cancelled|TimedOut) + echo "Monitoring deployment failed: $STATUS" >&2 + aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' \ + --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + sleep 5 + ;; + esac + done + + echo "Monitoring deployment timed out" >&2 + exit 1 \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index 481a727..2e079ad 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -48,8 +48,15 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health/**", "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", "/back-office/login", "/back-office/reissue", + "/back-office/swagger-ui.html", + "/back-office/swagger-ui/**", + "/back-office/v3/api-docs", + "/back-office/v3/api-docs/**", + "/back-office/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" diff --git a/apps/api-admin/src/main/resources/application.yml b/apps/api-admin/src/main/resources/application.yml index 09a850a..e3b79ad 100644 --- a/apps/api-admin/src/main/resources/application.yml +++ b/apps/api-admin/src/main/resources/application.yml @@ -30,7 +30,7 @@ management: endpoints: web: exposure: - include: health,info + include: health,info,prometheus endpoint: health: probes: diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java index 2373df7..7df9d82 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -59,6 +59,8 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health/**", "/actuator/info", + "/actuator/prometheus", + "/actuator/prometheus/**", "/oauth2/**", "/login/oauth2/**", "/auth/reissue", @@ -109,4 +111,4 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } -} \ No newline at end of file +} diff --git a/apps/monitoring/docker-compose.prod.yml b/apps/monitoring/docker-compose.prod.yml new file mode 100644 index 0000000..559f4b7 --- /dev/null +++ b/apps/monitoring/docker-compose.prod.yml @@ -0,0 +1,4 @@ +services: + prometheus: + volumes: + - ./prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro \ No newline at end of file diff --git a/apps/monitoring/docker-compose.yml b/apps/monitoring/docker-compose.yml new file mode 100644 index 0000000..a951002 --- /dev/null +++ b/apps/monitoring/docker-compose.yml @@ -0,0 +1,33 @@ +services: + prometheus: + image: prom/prometheus:v2.54.1 + container_name: oplust-prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + restart: unless-stopped + + grafana: + image: grafana/grafana:12.4.0 + container_name: oplust-grafana + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:-admin1234} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus + restart: unless-stopped + +volumes: + prometheus_data: + grafana_data: diff --git a/apps/monitoring/grafana/provisioning/dashboards/dashboards.yml b/apps/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..f29b13b --- /dev/null +++ b/apps/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: "oplust-local" + orgId: 1 + folder: "Oplust" + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 10 + options: + path: /etc/grafana/provisioning/dashboards/json + foldersFromFilesStructure: false \ No newline at end of file diff --git a/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json b/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json new file mode 100644 index 0000000..b8270a7 --- /dev/null +++ b/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json @@ -0,0 +1,447 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [ + + ], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "legend": { + "calcs": [ + + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"user-api|admin-api\"}[1m])) by (app)", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "API 요청 처리량 (RPS)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "{area=\"heap\", job=\"transcoder\"}" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [ + + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "sum by (app) (jvm_memory_used_bytes{job=~\"user-api|admin-api|transcoder\"})", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "JVM 메모리 사용량 (app별)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "builder", + "expr": "max by (app) (up{job=~\"user-api|admin-api|transcoder\"})", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "서비스 상태 점검 (UP)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [ + + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "avg by (app) (process_cpu_usage{job=~\"user-api|admin-api|transcoder\"} * 100)", + "interval": "", + "legendFormat": "{{app}}", + "range": true, + "refId": "A" + } + ], + "title": "CPU 사용률 (app별)", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [ + + ], + "templating": { + "list": [ + + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + + }, + "timezone": "browser", + "title": "모니터링 대시보드", + "uid": "adk6x5b", + "version": 101, + "weekStart": "" +} diff --git a/apps/monitoring/grafana/provisioning/datasources/prometheus.yml b/apps/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..0b304bc --- /dev/null +++ b/apps/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/apps/monitoring/prometheus/prometheus.prod.yml b/apps/monitoring/prometheus/prometheus.prod.yml new file mode 100644 index 0000000..2ef5abc --- /dev/null +++ b/apps/monitoring/prometheus/prometheus.prod.yml @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "user-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__USER_API_TARGET__"] + labels: + app: "user-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "user-api" + + - job_name: "admin-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__ADMIN_API_TARGET__"] + labels: + app: "admin-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "admin-api" + + - job_name: "transcoder" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__TRANSCODER_TARGET__"] + labels: + app: "transcoder" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "transcoder" diff --git a/apps/monitoring/prometheus/prometheus.prod.yml.tpl b/apps/monitoring/prometheus/prometheus.prod.yml.tpl new file mode 100644 index 0000000..e1830e5 --- /dev/null +++ b/apps/monitoring/prometheus/prometheus.prod.yml.tpl @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "user-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__USER_API_TARGET__"] + labels: + app: "user-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "user-api" + + - job_name: "admin-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__ADMIN_API_TARGET__"] + labels: + app: "admin-api" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "admin-api" + + - job_name: "transcoder" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["__TRANSCODER_TARGET__"] + labels: + app: "transcoder" + env: "prod" + relabel_configs: + - target_label: instance + replacement: "transcoder" \ No newline at end of file diff --git a/apps/monitoring/prometheus/prometheus.yml b/apps/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..838bc1b --- /dev/null +++ b/apps/monitoring/prometheus/prometheus.yml @@ -0,0 +1,41 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "user-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["host.docker.internal:8080"] + labels: + app: "user-api" + env: "local" + relabel_configs: + - target_label: instance + replacement: "user-api" + + - job_name: "admin-api" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["host.docker.internal:8081"] + labels: + app: "admin-api" + env: "local" + relabel_configs: + - target_label: instance + replacement: "admin-api" + + - job_name: "transcoder" + metrics_path: /actuator/prometheus + static_configs: + - targets: ["host.docker.internal:8082"] + labels: + app: "transcoder" + env: "local" + relabel_configs: + - target_label: instance + replacement: "transcoder" \ No newline at end of file diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 66a1f85..c34b772 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -48,3 +48,12 @@ storage: provider: ${STORAGE_PROVIDER} local: output-dir: ${STORAGE_OUTPUT_DIR} +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3cca0ea..89c42db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,5 +126,40 @@ services: rabbitmq: condition: service_healthy + # ============ Monitoring ============ + prometheus: + image: prom/prometheus:v2.54.1 + container_name: ott-prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + ports: + - "9090:9090" + volumes: + - ./apps/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + depends_on: + api-user: + condition: service_healthy + api-admin: + condition: service_started + transcoder: + condition: service_started + + grafana: + image: grafana/grafana:12.4.0 + container_name: ott-grafana + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:-admin1234} + volumes: + - grafana-data:/var/lib/grafana + - ./apps/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus + volumes: mysql-data: diff --git a/modules/common-web/build.gradle b/modules/common-web/build.gradle index 2a56d97..1a32d15 100644 --- a/modules/common-web/build.gradle +++ b/modules/common-web/build.gradle @@ -2,5 +2,7 @@ apply plugin: 'java-library' dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-actuator' + api 'io.micrometer:micrometer-registry-prometheus' api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' -} \ No newline at end of file +} From d25277af38ffb1d4e3c86b3ab41dfe6b0834e747 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 11:41:55 +0900 Subject: [PATCH 245/257] =?UTF-8?q?[REFACTOR]:=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20back-office=20path=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-admin/src/main/resources/application.yml | 4 ++++ .../main/java/com/ott/common/web/config/WebMvcConfig.java | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/api-admin/src/main/resources/application.yml b/apps/api-admin/src/main/resources/application.yml index 09a850a..b4174be 100644 --- a/apps/api-admin/src/main/resources/application.yml +++ b/apps/api-admin/src/main/resources/application.yml @@ -45,3 +45,7 @@ jwt: springdoc: api-docs: version: OPENAPI_3_0 + path: /back-office/v3/api-docs + + swagger-ui: + path: /back-office/swagger-ui/index.html \ No newline at end of file diff --git a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java index f742032..c0d70f7 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java +++ b/modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java @@ -24,6 +24,10 @@ public void addCorsMappings(CorsRegistry registry) { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/**").addResourceLocations("classpath:/static/"); + registry.addResourceHandler("/**") + .addResourceLocations( + "classpath:/static/", + "classpath:/META-INF/resources/", + "classpath:/META-INF/resources/webjars/"); } } From c829ac8709e08b6b691f07c691164dc35d859dfa Mon Sep 17 00:00:00 2001 From: yubin012 <101037608+yubin012@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:42:42 +0900 Subject: [PATCH 246/257] =?UTF-8?q?[REFACTOR]=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=B6=94=EC=B2=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D/=EC=9D=91=EB=8B=B5=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/controller/PlaylistApi.java | 36 +++++++++++-------- .../controller/PlaylistController.java | 4 +-- .../service/PlaylistPreferenceService.java | 14 +++++--- .../web/exception/GlobalExceptionHandler.java | 9 +++++ 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java index 2c53866..2a43b90 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @@ -20,7 +21,12 @@ 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 jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +@Validated @Tag(name = "Playlist", description = "플레이리스트 API") public interface PlaylistApi { @@ -37,8 +43,8 @@ public interface PlaylistApi { @GetMapping("/recommend") ResponseEntity>> getRecommendPlaylists( @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호(0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -46,9 +52,9 @@ ResponseEntity>> getRecommendPlay @GetMapping("/tags/top") ResponseEntity> getTopTagPlaylists( @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @Parameter(description = "유저 취향 순위 (0, 1, 2)", required = true) @RequestParam(value = "index") Integer index, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Max(value = 2, message = "인덱스는 2 이하여야 합니다.") @Parameter(description = "유저 취향 순위 (0, 1, 2)", required = true) @RequestParam(value = "index") Integer index, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -57,8 +63,8 @@ ResponseEntity> getTopTagPlaylists( ResponseEntity>> getTagPlaylists( @Parameter(description = "태그 ID", required = true) @PathVariable(value = "tagId") Long tagId, @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -66,8 +72,8 @@ ResponseEntity>> getTagPlaylists( @GetMapping("/trending") ResponseEntity>> getTrendingPlaylists( @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -75,8 +81,8 @@ ResponseEntity>> getTrendingPlayl @GetMapping("/history") ResponseEntity>> getHistoryPlaylists( @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -84,8 +90,8 @@ ResponseEntity>> getHistoryPlayli @GetMapping("/bookmarks") ResponseEntity>> getBookmarkPlaylists( @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); @@ -93,8 +99,8 @@ ResponseEntity>> getBookmarkPlayl @GetMapping("/search") ResponseEntity>> getSearchPlaylists( @Parameter(description = "현재 영상 ID", required = true) @RequestParam(value = "excludeMediaId") Long excludeMediaId, - @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); } \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java index 45c8583..c4a9378 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -13,7 +13,7 @@ import com.ott.api_user.playlist.dto.request.PlaylistCondition; import com.ott.api_user.playlist.dto.response.PlaylistResponse; import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; -import com.ott.api_user.playlist.service.PlaylistService; +import com.ott.api_user.playlist.service.PlaylisStrategytService; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -24,7 +24,7 @@ @RequestMapping("/playlists") public class PlaylistController implements PlaylistApi { - private final PlaylistService playlistService; + private final PlaylisStrategytService playlistService; // 1. 종합 추천 @Override diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java index 2ab36ba..6911e31 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java @@ -60,8 +60,8 @@ public List getTopTags(Long memberId){ // [2단계] 가져온 영상이 하나라도 있다면, 그 영상들의 '태그 ID'를 한 번에 가져와 점수 부여 if (!playedMediaIds.isEmpty()) { - mediaTagRepository.findTagIdsByMediaIds(playedMediaIds) - .forEach(id -> tagScores.merge(id, 3, Integer::sum)); // 혹은 totalScores.merge + mediaTagRepository.findTagIdsByMediaIds(playedMediaIds) + .forEach(id -> tagScores.merge(id, 3, Integer::sum)); // 혹은 totalScores.merge } // 3. 점수가 가장 높은 순(내림차순)으로 정렬한 뒤, 상위 3개의 태그 ID만 추출 @@ -101,8 +101,12 @@ public Map getTotalTagScores(Long memberId) { .forEach(id -> totalScores.merge(id, 5, Integer::sum)); // 2. 최근 관심사: 최근 시청 이력 (+3점) - playbackRepository.findRecentTagIdsByMemberId(memberId, Status.ACTIVE, limit100) + List playedMediaIds = playbackRepository.findRecentPlayedMediaIds(memberId, Status.ACTIVE, limit100); + + if (!playedMediaIds.isEmpty()) { + mediaTagRepository.findTagIdsByMediaIds(playedMediaIds) .forEach(id -> totalScores.merge(id, 3, Integer::sum)); + } // 3. 강한 선호도: 최근 좋아요 누른 이력 (+2점) // [1단계] 최근 좋아요 누른 '영상 ID' 최대 100개를 가져옴 @@ -110,8 +114,8 @@ public Map getTotalTagScores(Long memberId) { // [2단계] 가져온 영상이 하나라도 있다면, 그 영상들의 '태그 ID'를 한 번에 가져와 점수 부여 if (!likedMediaIds.isEmpty()) { - mediaTagRepository.findTagIdsByMediaIds(likedMediaIds) - .forEach(id -> totalScores.merge(id, 2, Integer::sum)); + mediaTagRepository.findTagIdsByMediaIds(likedMediaIds) + .forEach(id -> totalScores.merge(id, 2, Integer::sum)); } // 만들어진 유저의 최종 점수를 반환 diff --git a/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java b/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java index 607e8b5..0de822c 100644 --- a/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java +++ b/modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.ott.common.web.exception; import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -89,4 +90,12 @@ protected ResponseEntity handleException(Exception ex) { ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_ERROR, ex.getMessage()); return ResponseEntity.status(ErrorCode.INTERNAL_ERROR.getStatus()).body(response); } + + @ExceptionHandler(ConstraintViolationException.class) + protected ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { + log.warn("ConstraintViolationException: {}", ex.getMessage()); + // C001 에러 코드를 사용하여 400 Bad Request 응답 생성 + ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT, ex.getMessage()); + return ResponseEntity.badRequest().body(response); + } } From 29ee431c7a3f2982d47b0c35005b04fb9b88ec4c Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 4 Mar 2026 13:05:21 +0900 Subject: [PATCH 247/257] =?UTF-8?q?[CHORE]:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=ED=8F=AC=ED=8A=B8=20=EC=B6=A9=EB=8F=8C=20=EB=B0=8F?= =?UTF-8?q?=20user-api=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-user/src/main/resources/application.yml | 2 +- apps/monitoring/docker-compose.yml | 2 +- docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api-user/src/main/resources/application.yml b/apps/api-user/src/main/resources/application.yml index a274367..aa10d05 100644 --- a/apps/api-user/src/main/resources/application.yml +++ b/apps/api-user/src/main/resources/application.yml @@ -53,7 +53,7 @@ management: endpoints: web: exposure: - include: health,info + include: health,info,prometheus endpoint: health: probes: diff --git a/apps/monitoring/docker-compose.yml b/apps/monitoring/docker-compose.yml index a951002..7aaac3b 100644 --- a/apps/monitoring/docker-compose.yml +++ b/apps/monitoring/docker-compose.yml @@ -17,7 +17,7 @@ services: image: grafana/grafana:12.4.0 container_name: oplust-grafana ports: - - "3000:3000" + - "3001:3000" environment: GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:-admin1234} diff --git a/docker-compose.yml b/docker-compose.yml index 89c42db..ffa3f73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -151,7 +151,7 @@ services: image: grafana/grafana:12.4.0 container_name: ott-grafana ports: - - "3000:3000" + - "3001:3000" environment: GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:-admin1234} From 9bee9eaddf2b3686d20c5bc6c6a0f02268019b1a Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 4 Mar 2026 13:23:47 +0900 Subject: [PATCH 248/257] =?UTF-8?q?[CHORE]:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=B0=ED=8F=AC=20=EB=B0=8F=20=EB=A1=9C=EC=BB=AC?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-monitoring.yml | 10 +++++---- apps/monitoring/docker-compose.yml | 4 ++-- .../json/New dashboard-1772584885701.json | 22 +++++++++---------- apps/monitoring/prometheus/prometheus.yml | 8 +++---- docker-compose.yml | 6 +++-- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml index 498a1c3..4d87c28 100644 --- a/.github/workflows/deploy-monitoring.yml +++ b/.github/workflows/deploy-monitoring.yml @@ -149,6 +149,7 @@ jobs: PROM_PROD_B64=$(base64 -w 0 apps/monitoring/prometheus/prometheus.prod.yml) DATASOURCE_B64=$(base64 -w 0 apps/monitoring/grafana/provisioning/datasources/prometheus.yml) DASH_PROVIDER_B64=$(base64 -w 0 apps/monitoring/grafana/provisioning/dashboards/dashboards.yml) + DASHBOARDS_JSON_TGZ_B64=$(tar -C apps/monitoring/grafana/provisioning/dashboards -czf - json | base64 -w 0) PARAMS=$(jq -nc \ --arg c1 "set -euo pipefail" \ @@ -158,9 +159,10 @@ jobs: --arg c5 "echo '$PROM_PROD_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/prometheus/prometheus.prod.yml >/dev/null" \ --arg c6 "echo '$DATASOURCE_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/datasources/prometheus.yml >/dev/null" \ --arg c7 "echo '$DASH_PROVIDER_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/dashboards/dashboards.yml >/dev/null" \ - --arg c8 "sudo sh -c 'cat > ${MONITORING_ROOT}/.env <<\"EOF\"\nGF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}\nEOF'" \ - --arg c9 "cd ${MONITORING_ROOT} && sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ - '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9]}') + --arg c8 "echo '$DASHBOARDS_JSON_TGZ_B64' | base64 -d | sudo tar -xzf - -C ${MONITORING_ROOT}/grafana/provisioning/dashboards" \ + --arg c9 "sudo sh -c 'cat > ${MONITORING_ROOT}/.env <<\"EOF\"\nGF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}\nEOF'" \ + --arg c10 "cd ${MONITORING_ROOT} && sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ + '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10]}') CMD_ID=$(aws ssm send-command \ --region "$AWS_REGION" \ @@ -206,4 +208,4 @@ jobs: done echo "Monitoring deployment timed out" >&2 - exit 1 \ No newline at end of file + exit 1 diff --git a/apps/monitoring/docker-compose.yml b/apps/monitoring/docker-compose.yml index 7aaac3b..9761e77 100644 --- a/apps/monitoring/docker-compose.yml +++ b/apps/monitoring/docker-compose.yml @@ -19,8 +19,8 @@ services: ports: - "3001:3000" environment: - GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:-admin1234} + GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} volumes: - grafana_data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning:ro diff --git a/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json b/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json index b8270a7..940f7ab 100644 --- a/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json +++ b/apps/monitoring/grafana/provisioning/dashboards/json/New dashboard-1772584885701.json @@ -262,17 +262,17 @@ ], "thresholds": { "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "green", + "value": 1 + } + ] + } }, "overrides": [ diff --git a/apps/monitoring/prometheus/prometheus.yml b/apps/monitoring/prometheus/prometheus.yml index 838bc1b..e4386f0 100644 --- a/apps/monitoring/prometheus/prometheus.yml +++ b/apps/monitoring/prometheus/prometheus.yml @@ -10,7 +10,7 @@ scrape_configs: - job_name: "user-api" metrics_path: /actuator/prometheus static_configs: - - targets: ["host.docker.internal:8080"] + - targets: ["api-user:8080"] labels: app: "user-api" env: "local" @@ -21,7 +21,7 @@ scrape_configs: - job_name: "admin-api" metrics_path: /actuator/prometheus static_configs: - - targets: ["host.docker.internal:8081"] + - targets: ["api-admin:8081"] labels: app: "admin-api" env: "local" @@ -32,10 +32,10 @@ scrape_configs: - job_name: "transcoder" metrics_path: /actuator/prometheus static_configs: - - targets: ["host.docker.internal:8082"] + - targets: ["transcoder:8082"] labels: app: "transcoder" env: "local" relabel_configs: - target_label: instance - replacement: "transcoder" \ No newline at end of file + replacement: "transcoder" diff --git a/docker-compose.yml b/docker-compose.yml index ffa3f73..c050ccc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,8 +153,8 @@ services: ports: - "3001:3000" environment: - GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:-admin1234} + GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} volumes: - grafana-data:/var/lib/grafana - ./apps/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro @@ -163,3 +163,5 @@ services: volumes: mysql-data: + prometheus-data: + grafana-data: From e19c0b8e292fedf6f206bb11c548f01df42d14b9 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 4 Mar 2026 13:43:57 +0900 Subject: [PATCH 249/257] =?UTF-8?q?[CHORE]:=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20Prometheus=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20=EC=8B=9C=EC=9E=91=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c050ccc..378dcd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -139,13 +139,6 @@ services: volumes: - ./apps/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus - depends_on: - api-user: - condition: service_healthy - api-admin: - condition: service_started - transcoder: - condition: service_started grafana: image: grafana/grafana:12.4.0 From 3678a4f260b9ec42c582c37210f2409d15c1e920 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 14:06:04 +0900 Subject: [PATCH 250/257] =?UTF-8?q?[REFACTOR]:=20=EC=95=88=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentsApi.java | 3 -- .../controller/ContentsController.java | 7 ---- .../content/dto/ContentsDetailResponse.java | 1 - .../content/service/ContentsService.java | 12 ------ .../playlist/controller/PlaylistApi.java | 34 ++++++++++++++++- .../controller/PlaylistController.java | 5 +-- .../dto/request/PlaylistCondition.java | 7 +--- .../repository/ContentsRepository.java | 38 ------------------- .../likes/repository/LikesRepository.java | 3 +- .../media/repository/MediaRepository.java | 8 ---- 10 files changed, 38 insertions(+), 80 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java index 8e7e2f4..b7f41a3 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java @@ -4,11 +4,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; -import com.ott.api_user.common.ContentSource; import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.common.web.exception.ErrorResponse; -import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java index 92cfa72..29cb2e5 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java @@ -2,19 +2,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.annotation.Validated; 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; -import com.ott.api_user.common.ContentSource; import com.ott.api_user.content.dto.ContentsDetailResponse; import com.ott.api_user.content.service.ContentsService; -import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java index fa66be2..2923356 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java @@ -2,7 +2,6 @@ import java.util.List; -import com.ott.domain.common.MediaType; import com.ott.domain.contents.domain.Contents; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java index abe50cd..5815874 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java @@ -1,28 +1,17 @@ package com.ott.api_user.content.service; import java.util.List; -import java.util.stream.Collectors; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import com.ott.api_user.common.ContentSource; import com.ott.api_user.content.dto.ContentsDetailResponse; 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.bookmark.repository.BookmarkRepository; import com.ott.domain.category.repository.CategoryRepository; -import com.ott.domain.common.MediaType; import com.ott.domain.common.PublicStatus; import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; import com.ott.domain.contents.repository.ContentsRepository; import com.ott.domain.likes.repository.LikesRepository; -import com.ott.domain.media.domain.Media; -import com.ott.domain.media_tag.repository.MediaTagRepository; -import com.ott.domain.playback.repository.PlaybackRepository; import com.ott.domain.tag.repository.TagRepository; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -42,7 +31,6 @@ public class ContentsService { // 재생 상세 public ContentsDetailResponse getContentDetail(Long mediaId, Long memberId) { - Contents contents = contentsRepository.findByMediaIdAndStatusAndMedia_PublicStatus(mediaId, Status.ACTIVE, PublicStatus.PUBLIC) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java index 2a43b90..2282f8a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java @@ -1,6 +1,8 @@ package com.ott.api_user.playlist.controller; import com.ott.api_user.playlist.dto.response.PlaylistResponse; +import com.ott.api_user.playlist.dto.response.RecentWatchResponse; +import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; @@ -25,6 +27,8 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; + @Validated @Tag(name = "Playlist", description = "플레이리스트 API") @@ -38,6 +42,7 @@ public interface PlaylistApi { @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) }) + @Operation(summary = "OO 님이 좋아하실만한 콘텐츠", description = "유저 취향을 합산하여 추천합니다. (홈 화면 셔플 지원)") @GetMapping("/recommend") @@ -103,4 +108,31 @@ ResponseEntity>> getSearchPlaylis @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, @Parameter(hidden = true) @AuthenticationPrincipal Long memberId ); -} \ No newline at end of file + + @Operation(summary = "태그별 추천 콘텐츠 조회", description = "특정 태그 ID를 기반으로 유저 맞춤 추천 콘텐츠를 제공합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = { + @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = TagPlaylistResponse.class))) }), + @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) + }) + @GetMapping("/me/{tagId}") + ResponseEntity>> getRecommendContentsByTag( + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId, + @Positive @Parameter(description = "태그 ID", required = true) @PathVariable Long tagId + ); + + + @Operation(summary = "과거 시청 이력 조회", description = "유저의 시청 이력을 10개씩 페이지네이션하여 제공합니다.") + @ApiResponses(value = { + @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)) }) + }) + @GetMapping("/me/history") + ResponseEntity>> getWatchHistoryPlaylist( + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId, + @PositiveOrZero @Parameter(description = "페이지 번호(0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page + ); +} diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java index 0af53b9..301cd91 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -21,7 +21,6 @@ import com.ott.common.web.response.SuccessResponse; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; - import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -30,7 +29,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/playlists") -public class PlaylistController implements PlayListAPI { +public class PlaylistController implements PlaylistApi { private final PlaylistService playlistService; private final PlaylisStrategytService playlisStrategytService; @@ -76,7 +75,7 @@ public ResponseEntity>> getRecomm // 2. Top 3 태그별 리스트 @Override public ResponseEntity> getTopTagPlaylists( - @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, @RequestParam(value = "index") Integer index, @RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "size", defaultValue = "10") Integer size, diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java index 46b5343..d650259 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java @@ -1,18 +1,15 @@ package com.ott.api_user.playlist.dto.request; -import org.hibernate.annotations.SourceType; + import com.ott.api_user.common.ContentSource; import com.ott.domain.common.MediaType; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import lombok.ToString; + // 플레이리스트 공통 요청 DTO // 진입점, 플레이리스트 or 재생목록 (둘은 재사용, 현재 컨텐츠 id 에 따라) 에 따라 들어오는 파라미터를 공통으로 사용 diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java index 46be0ff..83b0f96 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java @@ -5,31 +5,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; - -// import java.util.List; -// -// import org.springframework.data.domain.Pageable; -// import org.springframework.data.jpa.repository.JpaRepository; -// import org.springframework.data.jpa.repository.Query; -// import org.springframework.data.repository.query.Param; -// -// import com.ott.domain.common.Status; -// import com.ott.domain.contents.domain.Contents; -// -// public interface ContentsRepository extends JpaRepository { -// -// // 제목에 검색어 포함, 상태 ACTIVE, 시리즈 없는 콘텐츠만 검색 (최신순 정렬) -// @Query("SELECT c FROM Contents c " + -// "WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " + -// "AND c.status = :status " + -// "AND c.series IS NULL " + -// "ORDER BY c.createdDate DESC") -// List searchLatest(@Param("keyword") String searchWord, -// @Param("status") Status status, -// Pageable pageable); -// -// } - import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -38,7 +13,6 @@ import com.ott.domain.common.Status; import com.ott.domain.contents.domain.Contents; -import java.util.Optional; public interface ContentsRepository extends JpaRepository, ContentsRepositoryCustom { @@ -54,18 +28,6 @@ Page findBySeriesIdAndStatusAndMedia_PublicStatusOrderByIdAsc(Long ser @EntityGraph(attributePaths = {"media"}) Optional findByIdAndStatus(Long id, Status status); - // @Query(""" - // SELECT c FROM Contents c - // JOIN FETCH c.media m - // WHERE c.id = :contentsId - // AND c.status = :status - // AND m.publicStatus = :publicStatus - // """) - // Optional findByIdAndStatusAndMedia_PublicStatus( - // @Param("contentsId") Long contentsId, - // @Param("status") Status status, - // @Param("publicStatus") PublicStatus publicStatus); - @Query(""" SELECT c FROM Contents c JOIN FETCH c.media m diff --git a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java index 3d08f67..e70235b 100644 --- a/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java @@ -9,8 +9,7 @@ import com.ott.domain.common.Status; import com.ott.domain.likes.domain.Likes; import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; + import java.util.Optional; diff --git a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java index e8bfd3d..97ffc04 100644 --- a/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java @@ -2,14 +2,6 @@ import com.ott.domain.media.domain.Media; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - public interface MediaRepository extends JpaRepository, MediaRepositoryCustom { - - } From 8aa628a813cbb694c606d1152da24cdf6c9be40c Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 4 Mar 2026 14:06:40 +0900 Subject: [PATCH 251/257] =?UTF-8?q?[FIX]:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=B0=ED=8F=AC=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=EC=9D=98=20env=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20com?= =?UTF-8?q?pose=20=EC=8B=A4=ED=96=89=20=ED=98=B8=ED=99=98=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-monitoring.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml index 4d87c28..7ad39d1 100644 --- a/.github/workflows/deploy-monitoring.yml +++ b/.github/workflows/deploy-monitoring.yml @@ -160,9 +160,10 @@ jobs: --arg c6 "echo '$DATASOURCE_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/datasources/prometheus.yml >/dev/null" \ --arg c7 "echo '$DASH_PROVIDER_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/dashboards/dashboards.yml >/dev/null" \ --arg c8 "echo '$DASHBOARDS_JSON_TGZ_B64' | base64 -d | sudo tar -xzf - -C ${MONITORING_ROOT}/grafana/provisioning/dashboards" \ - --arg c9 "sudo sh -c 'cat > ${MONITORING_ROOT}/.env <<\"EOF\"\nGF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}\nEOF'" \ - --arg c10 "cd ${MONITORING_ROOT} && sudo docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ - '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10]}') + --arg c9 "printf '%s\n' 'GF_SECURITY_ADMIN_USER=admin' 'GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}' | sudo tee ${MONITORING_ROOT}/.env >/dev/null" \ + --arg c10 "if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; elif sudo docker-compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker-compose'; else echo 'Docker Compose not found' >&2; exit 1; fi" \ + --arg c11 "cd ${MONITORING_ROOT} && \$COMPOSE_CMD -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ + '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10,$c11]}') CMD_ID=$(aws ssm send-command \ --region "$AWS_REGION" \ From bacff9d118850981e9fe6d0fe8d34ce8c4e3ab31 Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 14:18:18 +0900 Subject: [PATCH 252/257] =?UTF-8?q?[REFACTOR]:=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EB=AC=B8=EC=84=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/controller/PlaylistApi.java | 138 ------------------ 1 file changed, 138 deletions(-) delete mode 100644 apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java deleted file mode 100644 index 2282f8a..0000000 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.ott.api_user.playlist.controller; - -import com.ott.api_user.playlist.dto.response.PlaylistResponse; -import com.ott.api_user.playlist.dto.response.RecentWatchResponse; -import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; -import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; -import com.ott.common.web.response.PageResponse; -import com.ott.common.web.response.SuccessResponse; -import com.ott.common.web.exception.ErrorResponse; - -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; - -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 jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.PositiveOrZero; - -import java.util.List; - - -@Validated -@Tag(name = "Playlist", description = "플레이리스트 API") -public interface PlaylistApi { - - @ApiResponses(value = { - @ApiResponse(responseCode = "0", description = "조회 성공 - 플레이리스트 구성", content ={ - @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = PlaylistResponse.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)) }) - }) - - - @Operation(summary = "OO 님이 좋아하실만한 콘텐츠", description = "유저 취향을 합산하여 추천합니다. (홈 화면 셔플 지원)") - @GetMapping("/recommend") - ResponseEntity>> getRecommendPlaylists( - @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @PositiveOrZero @Parameter(description = "페이지 번호(0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "선호 태그 순위별 리스트", description = "유저의 Top 3 태그 순위를 기반으로 제공합니다.") - @GetMapping("/tags/top") - ResponseEntity> getTopTagPlaylists( - @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @PositiveOrZero @Max(value = 2, message = "인덱스는 2 이하여야 합니다.") @Parameter(description = "유저 취향 순위 (0, 1, 2)", required = true) @RequestParam(value = "index") Integer index, - @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "상세 페이지 - 특정 해시태그 리스트", description = "해당 태그의 영상만 제공합니다.") - @GetMapping("/tags/{tagId}") - ResponseEntity>> getTagPlaylists( - @Parameter(description = "태그 ID", required = true) @PathVariable(value = "tagId") Long tagId, - @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "인기 차트 (Trending)", description = "북마크가 많은 인기 순서대로 제공합니다.") - @GetMapping("/trending") - ResponseEntity>> getTrendingPlaylists( - @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "시청 이력 (History)", description = "유저가 최근 시청한 영상 목록을 제공합니다.") - @GetMapping("/history") - ResponseEntity>> getHistoryPlaylists( - @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "북마크 목록 (Bookmark)", description = "유저가 북마크한 영상 목록을 제공합니다.") - @GetMapping("/bookmarks") - ResponseEntity>> getBookmarkPlaylists( - @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, - @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "검색 상세 페이지 재생목록", description = "검색 결과에서 진입 시 종합 추천 리스트로 대체하여 제공합니다.") - @GetMapping("/search") - ResponseEntity>> getSearchPlaylists( - @Parameter(description = "현재 영상 ID", required = true) @RequestParam(value = "excludeMediaId") Long excludeMediaId, - @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, - @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId - ); - - @Operation(summary = "태그별 추천 콘텐츠 조회", description = "특정 태그 ID를 기반으로 유저 맞춤 추천 콘텐츠를 제공합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", content = { - @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = TagPlaylistResponse.class))) }), - @ApiResponse(responseCode = "400", description = "요청 파라미터 오류", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) }) - }) - @GetMapping("/me/{tagId}") - ResponseEntity>> getRecommendContentsByTag( - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId, - @Positive @Parameter(description = "태그 ID", required = true) @PathVariable Long tagId - ); - - - @Operation(summary = "과거 시청 이력 조회", description = "유저의 시청 이력을 10개씩 페이지네이션하여 제공합니다.") - @ApiResponses(value = { - @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)) }) - }) - @GetMapping("/me/history") - ResponseEntity>> getWatchHistoryPlaylist( - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId, - @PositiveOrZero @Parameter(description = "페이지 번호(0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page - ); -} From 37282c0532e9309f1f97cb5d0df8aaa8d0208d1f Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Wed, 4 Mar 2026 14:33:56 +0900 Subject: [PATCH 253/257] =?UTF-8?q?[OT-181]=20[FIX]:=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EB=B0=B0=ED=8F=AC=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=95=84=EC=9A=94=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=88=EC=9D=84=20=EC=8B=9C=20=EC=8B=A4=ED=8C=A8->=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=B0=9B=EA=B8=B0=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-monitoring.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml index 7ad39d1..8201ef7 100644 --- a/.github/workflows/deploy-monitoring.yml +++ b/.github/workflows/deploy-monitoring.yml @@ -161,7 +161,7 @@ jobs: --arg c7 "echo '$DASH_PROVIDER_B64' | base64 -d | sudo tee ${MONITORING_ROOT}/grafana/provisioning/dashboards/dashboards.yml >/dev/null" \ --arg c8 "echo '$DASHBOARDS_JSON_TGZ_B64' | base64 -d | sudo tar -xzf - -C ${MONITORING_ROOT}/grafana/provisioning/dashboards" \ --arg c9 "printf '%s\n' 'GF_SECURITY_ADMIN_USER=admin' 'GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}' | sudo tee ${MONITORING_ROOT}/.env >/dev/null" \ - --arg c10 "if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; elif sudo docker-compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker-compose'; else echo 'Docker Compose not found' >&2; exit 1; fi" \ + --arg c10 "if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; elif sudo docker-compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker-compose'; else if command -v dnf >/dev/null 2>&1; then sudo dnf install -y docker-compose-plugin || true; fi; if command -v yum >/dev/null 2>&1; then sudo yum install -y docker-compose-plugin || true; fi; if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; elif sudo docker-compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker-compose'; else sudo mkdir -p /usr/local/lib/docker/cli-plugins; sudo curl -fsSL https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose; sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose; if sudo docker compose version >/dev/null 2>&1; then COMPOSE_CMD='sudo docker compose'; else echo 'Docker Compose not found' >&2; exit 1; fi; fi; fi" \ --arg c11 "cd ${MONITORING_ROOT} && \$COMPOSE_CMD -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d" \ '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10,$c11]}') @@ -209,4 +209,4 @@ jobs: done echo "Monitoring deployment timed out" >&2 - exit 1 + exit 1 \ No newline at end of file From 2ac371969ff7e108a6eaaa71004374cd28f8f44e Mon Sep 17 00:00:00 2001 From: marulog Date: Wed, 4 Mar 2026 14:34:31 +0900 Subject: [PATCH 254/257] =?UTF-8?q?[REFACTOR]:=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EB=AC=B8=EC=84=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/controller/PlayListAPI.java | 78 ++++++++++++++++++- .../controller/PlaylistController.java | 2 +- .../ott/api_user/tag/controller/TagAPI.java | 5 ++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java index 3734ca5..9f61b4a 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java @@ -1,18 +1,22 @@ package com.ott.api_user.playlist.controller; +import com.ott.api_user.playlist.dto.response.PlaylistResponse; import com.ott.api_user.playlist.dto.response.RecentWatchResponse; import com.ott.api_user.playlist.dto.response.TagPlaylistResponse; +import com.ott.api_user.playlist.dto.response.TopTagPlaylistResponse; import com.ott.common.web.exception.ErrorResponse; import com.ott.common.web.response.PageResponse; import com.ott.common.web.response.SuccessResponse; 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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import org.springframework.http.ResponseEntity; @@ -32,7 +36,7 @@ public interface PlayListAPI { // ------------------------------------------------------- // 태그별 추천 콘텐츠 목록 조회 // ------------------------------------------------------- - @Operation(summary = "태그별 추천 콘텐츠 리스트 조회", description = "해당 태그에 속하는 콘텐츠를 최대 20개 반환" + @Operation(summary = "[마이페이지] 태그별 추천 콘텐츠 리스트 조회", description = "해당 태그에 속하는 콘텐츠를 최대 20개 반환" ) @ApiResponses({ @ApiResponse( @@ -40,6 +44,11 @@ public interface PlayListAPI { content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagPlaylistResponse.class) ) ), + @ApiResponse( + responseCode = "400", description = "요청 파라미터 오류", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), @ApiResponse( responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) @@ -61,7 +70,7 @@ ResponseEntity>> getRecommendContentsB // ------------------------------------------------------- // 전체 시청이력 플레이리스트 페이징 조회 // ------------------------------------------------------- - @Operation(summary = "과거 시청 이력 리스트 조회", description = "전체 시청이력을 최신순으로 10개씩 페이징 조회합니다. 이어보기 시점 포함.") + @Operation(summary = "[마이페이지] 과거 시청 이력 리스트 조회", description = "전체 시청이력을 최신순으로 10개씩 페이징 조회합니다. 이어보기 시점 포함.") @ApiResponses({ @ApiResponse( responseCode = "200", description = "조회 성공", @@ -79,4 +88,69 @@ ResponseEntity>> getWatchHisto @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @PositiveOrZero @RequestParam(defaultValue = "0") Integer page ); + + @Operation(summary = "OO 님이 좋아하실만한 콘텐츠", description = "유저 취향을 합산하여 추천합니다. (홈 화면 셔플 지원)") + @GetMapping("/recommend") + ResponseEntity>> getRecommendPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "페이지 번호(0부터 시작)", example = "0") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "선호 태그 순위별 리스트", description = "유저의 Top 3 태그 순위를 기반으로 제공합니다.") + @GetMapping("/tags/top") + ResponseEntity> getTopTagPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Max(value = 2, message = "인덱스는 2 이하여야 합니다.") @Parameter(description = "유저 취향 순위 (0, 1, 2)", required = true) @RequestParam(value = "index") Integer index, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "상세 페이지 - 특정 해시태그 리스트", description = "해당 태그의 영상만 제공합니다.") + @GetMapping("/tags/{tagId}") + ResponseEntity>> getTagPlaylists( + @Parameter(description = "태그 ID", required = true) @PathVariable(value = "tagId") Long tagId, + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "인기 차트 (Trending)", description = "북마크가 많은 인기 순서대로 제공합니다.") + @GetMapping("/trending") + ResponseEntity>> getTrendingPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "시청 이력 (History)", description = "유저가 최근 시청한 영상 목록을 제공합니다.") + @GetMapping("/history") + ResponseEntity>> getHistoryPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "북마크 목록 (Bookmark)", description = "유저가 북마크한 영상 목록을 제공합니다.") + @GetMapping("/bookmarks") + ResponseEntity>> getBookmarkPlaylists( + @Parameter(description = "현재 영상 ID") @RequestParam(value = "excludeMediaId", required = false) Long excludeMediaId, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); + + @Operation(summary = "검색 상세 페이지 재생목록", description = "검색 결과에서 진입 시 종합 추천 리스트로 대체하여 제공합니다.") + @GetMapping("/search") + ResponseEntity>> getSearchPlaylists( + @Parameter(description = "현재 영상 ID", required = true) @RequestParam(value = "excludeMediaId") Long excludeMediaId, + @PositiveOrZero @Parameter(description = "페이지 번호") @RequestParam(value = "page", defaultValue = "0") Integer page, + @Positive @Parameter(description = "페이지 크기") @RequestParam(value = "size", defaultValue = "10") Integer size, + @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + ); } diff --git a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java index 301cd91..f9dbd9d 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java @@ -29,7 +29,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/playlists") -public class PlaylistController implements PlaylistApi { +public class PlaylistController implements PlayListAPI { private final PlaylistService playlistService; private final PlaylisStrategytService playlisStrategytService; diff --git a/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java index 11e72c0..b34dbf4 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java @@ -61,6 +61,11 @@ ResponseEntity> getTagRanking( content = @Content(mediaType = "application/json", schema = @Schema(implementation = TagMonthlyCompareResponse.class) ) ), + @ApiResponse( + responseCode = "400", description = "요청 파라미터 오류", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) + ) + ), @ApiResponse( responseCode = "401", description = "인증 실패 (토큰 없음 또는 만료)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class) From aebc59e9ce1438be1536fb80894bb1b84394c4bd Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 5 Mar 2026 13:02:56 +0900 Subject: [PATCH 255/257] =?UTF-8?q?[FEAT]:=20=EC=97=85=EB=A1=9C=EB=93=9C/?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackOfficeContentsApi.java | 4 +- .../BackOfficeContentsController.java | 6 +-- .../dto/request/ContentsUpdateRequest.java | 5 ++ .../service/BackOfficeContentsService.java | 5 +- .../controller/BackOfficeSeriesApi.java | 4 +- .../BackOfficeSeriesController.java | 6 +-- .../service/BackOfficeSeriesService.java | 5 +- .../controller/BackOfficeShortFormApi.java | 10 ++-- .../BackOfficeShortFormController.java | 6 +-- .../dto/request/ShortFormUpdateRequest.java | 13 +++--- .../dto/request/ShortFormUploadRequest.java | 11 +++-- .../service/BackOfficeShortFormService.java | 46 +++++++++---------- .../repository/ContentsRepositoryCustom.java | 2 + .../repository/ContentsRepositoryImpl.java | 11 +++++ .../repository/MediaTagRepository.java | 4 +- .../repository/SeriesRepositoryCustom.java | 2 + .../repository/SeriesRepositoryImpl.java | 11 +++++ .../repository/ShortFormRepositoryCustom.java | 1 + .../repository/ShortFormRepositoryImpl.java | 19 ++++++++ 19 files changed, 114 insertions(+), 57 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java index 1923527..cd7aaf0 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsApi.java @@ -109,8 +109,8 @@ ResponseEntity> createContentsUpload( ) }) ResponseEntity> updateContentsUpload( - @Parameter(description = "수정 대상 콘텐츠의 미디어 ID", required = true, example = "1") - @PathVariable("mediaId") Long mediaId, + @Parameter(description = "수정 대상 콘텐츠의 ID", required = true, example = "1") + @PathVariable("contentsId") Long contentsId, @Parameter(description = "ContentsUpdateRequest를 참고해주세요.", required = true) @Valid @RequestBody ContentsUpdateRequest request diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java index aaf6a0b..69cb05f 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/controller/BackOfficeContentsController.java @@ -63,11 +63,11 @@ public ResponseEntity> createContentsUpl } @Override - @PatchMapping("/{mediaId}/upload") + @PatchMapping("/{contentsId}/upload") public ResponseEntity> updateContentsUpload( - @PathVariable("mediaId") Long mediaId, + @PathVariable("contentsId") Long contentsId, @Valid @RequestBody ContentsUpdateRequest request ) { - return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.updateContentsUpload(mediaId, request))); + return ResponseEntity.ok(SuccessResponse.of(backOfficeContentsService.updateContentsUpload(contentsId, request))); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java index 7ee6b5d..12f693f 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java @@ -43,19 +43,24 @@ public record ContentsUpdateRequest( @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") @PositiveOrZero + @NotNull Integer duration, @Schema(type = "Integer", description = "영상 크기(KB)", example = "512000") @PositiveOrZero + @NotNull Integer videoSize, @Schema(type = "String", description = "포스터 원본 파일명(교체 시에만 입력)", example = "poster-new.jpg") + @NotBlank String posterFileName, @Schema(type = "String", description = "썸네일 원본 파일명(교체 시에만 입력)", example = "thumb-new.jpg") + @NotBlank String thumbnailFileName, @Schema(type = "String", description = "원본 영상 파일명(교체 시에만 입력)", example = "origin-new.mp4") + @NotBlank String originFileName ) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java index d67f624..1ccb9bb 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java @@ -159,8 +159,8 @@ public ContentsUploadResponse createContentsUpload(ContentsUploadRequest request } @Transactional - public ContentsUpdateResponse updateContentsUpload(Long mediaId, ContentsUpdateRequest request) { - Contents contents = contentsRepository.findWithMediaAndUploaderByMediaId(mediaId) + public ContentsUpdateResponse updateContentsUpload(Long contentsId, ContentsUpdateRequest request) { + Contents contents = contentsRepository.findWithMediaById(contentsId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); Media media = contents.getMedia(); @@ -169,7 +169,6 @@ public ContentsUpdateResponse updateContentsUpload(Long mediaId, ContentsUpdateR media.updateMetadata(request.title(), request.description(), request.publicStatus()); contents.updateMetadata(series, request.actors(), request.duration(), request.videoSize()); - Long contentsId = contents.getId(); UploadHelper.MediaUpdateUploadResult mediaUpdateUploadResult = uploadHelper.prepareMediaUpdate( "contents", contentsId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java index 29e7fbf..b6edb9b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesApi.java @@ -134,8 +134,8 @@ ResponseEntity> createSeriesUpload( ) }) ResponseEntity> updateSeriesUpload( - @Parameter(description = "수정 대상 미디어 ID", required = true, example = "1") - @PathVariable("mediaId") Long mediaId, + @Parameter(description = "수정 대상 시리즈 ID", required = true, example = "1") + @PathVariable("seriesId") Long seriesId, @Parameter(description = "SeriesUpdateRequest를 참고해주세요.", required = true) @Valid @RequestBody SeriesUpdateRequest request diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java index 705b26b..9944d4c 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/controller/BackOfficeSeriesController.java @@ -72,11 +72,11 @@ public ResponseEntity> createSeriesUpload( } @Override - @PatchMapping("/{mediaId}/upload") + @PatchMapping("/{seriesId}/upload") public ResponseEntity> updateSeriesUpload( - @PathVariable("mediaId") Long mediaId, + @PathVariable("seriesId") Long seriesId, @Valid @RequestBody SeriesUpdateRequest request ) { - return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.updateSeriesUpload(mediaId, request))); + return ResponseEntity.ok(SuccessResponse.of(backOfficeSeriesService.updateSeriesUpload(seriesId, request))); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java index a2e2e03..4981e19 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/series/service/BackOfficeSeriesService.java @@ -152,15 +152,14 @@ public SeriesUploadResponse createSeriesUpload(SeriesUploadRequest request, Long } @Transactional - public SeriesUpdateResponse updateSeriesUpload(Long mediaId, SeriesUpdateRequest request) { - Series series = seriesRepository.findWithMediaAndUploaderByMediaId(mediaId) + public SeriesUpdateResponse updateSeriesUpload(Long seriesId, SeriesUpdateRequest request) { + Series series = seriesRepository.findWithMediaById(seriesId) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); Media media = series.getMedia(); media.updateMetadata(request.title(), request.description(), request.publicStatus()); series.updateActors(request.actors()); - Long seriesId = series.getId(); UploadHelper.ImageUpdateUploadResult imageUpdateUploadResult = uploadHelper.prepareImageUpdate( "series", seriesId, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java index 8991291..5450484 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormApi.java @@ -107,8 +107,10 @@ ResponseEntity> getShortFormDetail( }) ResponseEntity> createShortFormUpload( @Parameter(description = "ShortFormUploadRequest 참고해주세요.", required = true) - @RequestBody ShortFormUploadRequest request, - @Parameter(hidden = true) @AuthenticationPrincipal Long memberId + @Valid @RequestBody ShortFormUploadRequest request, + + @Parameter(hidden = true) + @AuthenticationPrincipal Long memberId ); @Operation(summary = "숏폼 수정", description = "숏폼 메타데이터를 수정하고 필요 시 파일 교체용 Presigned URL을 발급합니다.") @@ -127,8 +129,8 @@ ResponseEntity> createShortFormUpload( ) }) ResponseEntity> updateShortFormUpload( - @Parameter(description = "수정 대상 숏폼 미디어 ID", required = true, example = "1") - @PathVariable("mediaId") Long mediaId, + @Parameter(description = "수정 대상 숏폼 ID", required = true, example = "1") + @PathVariable("shortformId") Long shortformId, @Parameter(description = "ShortFormUpdateRequest 참고해주세요.", required = true) @Valid @RequestBody ShortFormUpdateRequest request, diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java index dbb0ede..1ec5ece 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/controller/BackOfficeShortFormController.java @@ -79,12 +79,12 @@ public ResponseEntity> createShortFormU } @Override - @PatchMapping("/{mediaId}/upload") + @PatchMapping("/{shortformId}/upload") public ResponseEntity> updateShortFormUpload( - @PathVariable("mediaId") Long mediaId, + @PathVariable("shortformId") Long shortformId, @Valid @RequestBody ShortFormUpdateRequest request, Authentication authentication ) { - return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.updateShortFormUpload(mediaId, request, authentication))); + return ResponseEntity.ok(SuccessResponse.of(backOfficeShortFormService.updateShortFormUpload(shortformId, request, authentication))); } } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java index 18f5287..15fd948 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUpdateRequest.java @@ -1,5 +1,6 @@ package com.ott.api_admin.shortform.dto.request; +import com.ott.domain.common.MediaType; import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -9,13 +10,13 @@ @Schema(description = "숏폼 수정 요청") public record ShortFormUpdateRequest( - @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") - @Positive - Long seriesId, + @Schema(type = "Long", description = "원본 콘텐츠 ID", example = "1") + @NotNull + Long originId, - @Schema(type = "Long", description = "연결할 콘텐츠 ID(선택)", example = "2") - @Positive - Long contentsId, + @Schema(type = "String", description = "원본 콘텐츠 타입", example = "SERIES") + @NotNull + MediaType mediaType, @Schema(type = "String", description = "숏폼 제목", example = "하이라이트 수정") @NotBlank diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java index b5043f2..9321d0e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/dto/request/ShortFormUploadRequest.java @@ -1,5 +1,6 @@ package com.ott.api_admin.shortform.dto.request; +import com.ott.domain.common.MediaType; import com.ott.domain.common.PublicStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -8,11 +9,13 @@ @Schema(description = "숏폼 업로드 요청") public record ShortFormUploadRequest( - @Schema(type = "Long", description = "연결할 시리즈 ID(선택)", example = "1") - Long seriesId, + @Schema(type = "Long", description = "원본 콘텐츠 ID", example = "1") + @NotNull + Long originId, - @Schema(type = "Long", description = "연결할 콘텐츠 ID(선택)", example = "2") - Long contentsId, + @Schema(type = "String", description = "원본 콘텐츠 타입", example = "SERIES") + @NotNull + MediaType mediaType, @Schema(type = "String", description = "숏폼 제목", example = "하이라이트") @NotBlank diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 3e83780..56c5778 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -152,11 +152,17 @@ public ShortFormDetailResponse getShortFormDetail(Long mediaId, Authentication a @Transactional public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest request, Long memberId) { - validateExclusiveTarget(request.seriesId(), request.contentsId()); - Member uploader = uploadHelper.resolveUploader(memberId); - Series series = resolveSeries(request.seriesId()); - Contents contents = resolveContents(request.contentsId()); + Series series = null; + Contents contents = null; + + if ( request.mediaType().equals(MediaType.SERIES) ) { + series = seriesRepository.findById(request.originId()) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + else if ( request.mediaType().equals(MediaType.CONTENTS) ){ + contents = resolveContents(request.originId()); + } Media media = mediaRepository.save( Media.builder() @@ -209,8 +215,8 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ } @Transactional - public ShortFormUpdateResponse updateShortFormUpload(Long mediaId, ShortFormUpdateRequest request, Authentication authentication) { - ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByMediaId(mediaId) + public ShortFormUpdateResponse updateShortFormUpload(Long shortformId, ShortFormUpdateRequest request, Authentication authentication) { + ShortForm shortForm = shortFormRepository.findWithMediaAndUploaderByShortFormId(shortformId) .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); Media media = shortForm.getMedia(); @@ -221,9 +227,17 @@ public ShortFormUpdateResponse updateShortFormUpload(Long mediaId, ShortFormUpda throw new BusinessException(ErrorCode.FORBIDDEN); } - validateExclusiveTarget(request.seriesId(), request.contentsId()); - Series series = resolveSeries(request.seriesId()); - Contents contents = resolveContents(request.contentsId()); + Series series = null; + Contents contents = null; + + if ( request.mediaType().equals(MediaType.SERIES) ) { + series = seriesRepository.findById(request.originId()) + .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); + } + else if ( request.mediaType().equals(MediaType.CONTENTS) ){ + contents = resolveContents(request.originId()); + } + media.updateMetadata(request.title(), request.description(), request.publicStatus()); shortForm.updateMetadata(series, contents, request.duration(), request.videoSize()); @@ -265,20 +279,6 @@ public ShortFormUpdateResponse updateShortFormUpload(Long mediaId, ShortFormUpda mediaUpdateUploadResult.originUploadUrl()); } - private void validateExclusiveTarget(Long seriesId, Long contentsId) { - if ((seriesId == null && contentsId == null) || (seriesId != null && contentsId != null)) { - throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); - } - } - - private Series resolveSeries(Long seriesId) { - if (seriesId == null) { - return null; - } - return seriesRepository.findById(seriesId) - .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - } - private Contents resolveContents(Long contentsId) { if (contentsId == null) { return null; diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java index ea196c6..581ce57 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryCustom.java @@ -7,6 +7,8 @@ public interface ContentsRepositoryCustom { + Optional findWithMediaById(Long contentsId); + Optional findWithMediaAndUploaderByMediaId(Long mediaId); List findAllByMediaIdIn(List mediaIdList); diff --git a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java index 29391ba..adaa607 100644 --- a/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepositoryImpl.java @@ -18,6 +18,17 @@ public class ContentsRepositoryImpl implements ContentsRepositoryCustom { private final JPAQueryFactory queryFactory; + @Override + public Optional findWithMediaById(Long contentsId) { + Contents result = queryFactory + .selectFrom(contents) + .join(contents.media, media).fetchJoin() + .where(contents.id.eq(contentsId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + @Override public List findAllByMediaIdIn(List mediaIdList) { return queryFactory diff --git a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java index 80afca1..2ee70be 100644 --- a/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java +++ b/modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java @@ -10,11 +10,13 @@ public interface MediaTagRepository extends JpaRepository, MediaTagRepositoryCustom { - // 미디어의 태그 ID 조회 (좋아욧 & 시청 이력에서 사용) + // 미디어의 태그 ID 조회 (좋아요 & 시청 이력에서 사용) // [2단계] 미디어에 대한 태그들을 전부 가져옴 @Query(""" SELECT mt.tag.id FROM MediaTag mt WHERE mt.media IN :mediaIds """) List findTagIdsByMediaIds(@Param("mediaIds") List mediaIds); + + void deleteAllByMedia_Id(Long mediaId); } diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java index 496cdf1..1e0bba0 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryCustom.java @@ -11,6 +11,8 @@ public interface SeriesRepositoryCustom { Page findSeriesListWithMediaBySearchWord(Pageable pageable, String searchWord); + Optional findWithMediaById(Long seriesId); + Optional findWithMediaAndUploaderByMediaId(Long mediaId); List findAllByMediaIdIn(List mediaIdList); diff --git a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java index 8ca2f06..df8736c 100644 --- a/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepositoryImpl.java @@ -22,6 +22,17 @@ public class SeriesRepositoryImpl implements SeriesRepositoryCustom { private final JPAQueryFactory queryFactory; + @Override + public Optional findWithMediaById(Long seriesId) { + Series result = queryFactory + .selectFrom(series) + .join(series.media, media).fetchJoin() + .where(series.id.eq(seriesId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + @Override public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { Series result = queryFactory diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java index 2739ecb..02c3efe 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryCustom.java @@ -8,6 +8,7 @@ public interface ShortFormRepositoryCustom { Optional findWithMediaAndUploaderByMediaId(Long mediaId); + Optional findWithMediaAndUploaderByShortFormId(Long shortFormId); List findAllByMediaIdIn(List mediaIdList); } diff --git a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java index 3143e83..d6f9a6d 100644 --- a/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java +++ b/modules/domain/src/main/java/com/ott/domain/short_form/repository/ShortFormRepositoryImpl.java @@ -38,6 +38,25 @@ public Optional findWithMediaAndUploaderByMediaId(Long mediaId) { return Optional.ofNullable(result); } + @Override + public Optional findWithMediaAndUploaderByShortFormId(Long shortFormId) { + QMedia contentsMedia = new QMedia("contentsMedia"); + QMedia seriesMedia = new QMedia("seriesMedia"); + + ShortForm result = queryFactory + .selectFrom(shortForm) + .join(shortForm.media, media).fetchJoin() + .join(media.uploader, member).fetchJoin() + .leftJoin(shortForm.contents, contents).fetchJoin() + .leftJoin(contents.media, contentsMedia).fetchJoin() + .leftJoin(shortForm.series, series).fetchJoin() + .leftJoin(series.media, seriesMedia).fetchJoin() + .where(shortForm.id.eq(shortFormId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + @Override public List findAllByMediaIdIn(List mediaIdList) { return queryFactory From a98d9900aea4a2fbc8c030db2f3c1e088934605f Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Thu, 5 Mar 2026 13:23:39 +0900 Subject: [PATCH 256/257] =?UTF-8?q?[FIX]:=20=EC=88=98=EC=A0=95=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EA=B0=92=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=88=8F=ED=8F=BC?= =?UTF-8?q?=20mediaType=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/dto/request/ContentsUpdateRequest.java | 3 --- .../shortform/service/BackOfficeShortFormService.java | 10 ++++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java index 12f693f..6d4950e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java @@ -52,15 +52,12 @@ public record ContentsUpdateRequest( Integer videoSize, @Schema(type = "String", description = "포스터 원본 파일명(교체 시에만 입력)", example = "poster-new.jpg") - @NotBlank String posterFileName, @Schema(type = "String", description = "썸네일 원본 파일명(교체 시에만 입력)", example = "thumb-new.jpg") - @NotBlank String thumbnailFileName, @Schema(type = "String", description = "원본 영상 파일명(교체 시에만 입력)", example = "origin-new.mp4") - @NotBlank String originFileName ) { } diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java index 56c5778..748fa6b 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/shortform/service/BackOfficeShortFormService.java @@ -159,9 +159,10 @@ public ShortFormUploadResponse createShortFormUpload(ShortFormUploadRequest requ if ( request.mediaType().equals(MediaType.SERIES) ) { series = seriesRepository.findById(request.originId()) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - } - else if ( request.mediaType().equals(MediaType.CONTENTS) ){ + } else if ( request.mediaType().equals(MediaType.CONTENTS) ){ contents = resolveContents(request.originId()); + } else { + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); } Media media = mediaRepository.save( @@ -233,9 +234,10 @@ public ShortFormUpdateResponse updateShortFormUpload(Long shortformId, ShortForm if ( request.mediaType().equals(MediaType.SERIES) ) { series = seriesRepository.findById(request.originId()) .orElseThrow(() -> new BusinessException(ErrorCode.SERIES_NOT_FOUND)); - } - else if ( request.mediaType().equals(MediaType.CONTENTS) ){ + } else if ( request.mediaType().equals(MediaType.CONTENTS) ){ contents = resolveContents(request.originId()); + } else { + throw new BusinessException(ErrorCode.INVALID_SHORTFORM_TARGET); } From 8b24a56e385a4164f3dd36609a732b736c4c6ef4 Mon Sep 17 00:00:00 2001 From: arlen02-01 Date: Fri, 6 Mar 2026 11:45:50 +0900 Subject: [PATCH 257/257] =?UTF-8?q?[CHORE]:=20RabbitMQ=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-rabbitmq.yml | 131 ++++++++++++++++++ .../dto/request/ContentsUpdateRequest.java | 4 - 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy-rabbitmq.yml diff --git a/.github/workflows/deploy-rabbitmq.yml b/.github/workflows/deploy-rabbitmq.yml new file mode 100644 index 0000000..f7bf7c4 --- /dev/null +++ b/.github/workflows/deploy-rabbitmq.yml @@ -0,0 +1,131 @@ +name: Deploy RabbitMQ + +on: + workflow_dispatch: + inputs: + rabbitmq_instance_tag: + description: "EC2 Name tag for RabbitMQ server" + required: true + default: "oplust-rabbitmq-ec2" + type: string + rabbitmq_image_tag: + description: "RabbitMQ image tag" + required: true + default: "3.13-management" + type: string + rabbitmq_user_ssm_param: + description: "SSM SecureString parameter for RabbitMQ default user" + required: true + default: "/oplust/rabbitmq/default-user" + type: string + rabbitmq_password_ssm_param: + description: "SSM SecureString parameter for RabbitMQ default password" + required: true + default: "/oplust/rabbitmq/default-password" + type: string + rabbitmq_vhost_ssm_param: + description: "SSM parameter for RabbitMQ default vhost" + required: true + default: "/oplust/rabbitmq/default-vhost" + type: string + +env: + AWS_REGION: ap-northeast-2 + RABBITMQ_ROOT: /opt/oplust-rabbitmq + RABBITMQ_CONTAINER_NAME: oplust-rabbitmq + RABBITMQ_DATA_VOLUME: oplust-rabbitmq-data + +jobs: + deploy-rabbitmq: + runs-on: ubuntu-latest + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy RabbitMQ via SSM + env: + INSTANCE_TAG: ${{ github.event.inputs.rabbitmq_instance_tag }} + RABBITMQ_IMAGE_TAG: ${{ github.event.inputs.rabbitmq_image_tag }} + RABBITMQ_USER_SSM_PARAM: ${{ github.event.inputs.rabbitmq_user_ssm_param }} + RABBITMQ_PASSWORD_SSM_PARAM: ${{ github.event.inputs.rabbitmq_password_ssm_param }} + RABBITMQ_VHOST_SSM_PARAM: ${{ github.event.inputs.rabbitmq_vhost_ssm_param }} + run: | + set -euo pipefail + + INSTANCE_ID=$(aws ec2 describe-instances \ + --region "$AWS_REGION" \ + --filters "Name=tag:Name,Values=${INSTANCE_TAG}" "Name=instance-state-name,Values=running" \ + --query "Reservations[0].Instances[0].InstanceId" \ + --output text) + + if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then + echo "No running RabbitMQ instance found for tag: $INSTANCE_TAG" >&2 + exit 1 + fi + + PARAMS=$(jq -nc \ + --arg c1 "set -euo pipefail" \ + --arg c2 "sudo mkdir -p ${RABBITMQ_ROOT}" \ + --arg c3 "RABBITMQ_DEFAULT_USER=\$(aws ssm get-parameter --region $AWS_REGION --name '${RABBITMQ_USER_SSM_PARAM}' --with-decryption --query 'Parameter.Value' --output text)" \ + --arg c4 "RABBITMQ_DEFAULT_PASS=\$(aws ssm get-parameter --region $AWS_REGION --name '${RABBITMQ_PASSWORD_SSM_PARAM}' --with-decryption --query 'Parameter.Value' --output text)" \ + --arg c5 "RABBITMQ_DEFAULT_VHOST=\$(aws ssm get-parameter --region $AWS_REGION --name '${RABBITMQ_VHOST_SSM_PARAM}' --with-decryption --query 'Parameter.Value' --output text)" \ + --arg c6 "if [ -z \\\"\$RABBITMQ_DEFAULT_USER\\\" ] || [ \\\"\$RABBITMQ_DEFAULT_USER\\\" = \\\"None\\\" ] || [ -z \\\"\$RABBITMQ_DEFAULT_PASS\\\" ] || [ \\\"\$RABBITMQ_DEFAULT_PASS\\\" = \\\"None\\\" ] || [ -z \\\"\$RABBITMQ_DEFAULT_VHOST\\\" ] || [ \\\"\$RABBITMQ_DEFAULT_VHOST\\\" = \\\"None\\\" ]; then echo 'RabbitMQ env values are empty from SSM' >&2; exit 1; fi" \ + --arg c7 "printf '%s\n' \\\"RABBITMQ_DEFAULT_USER=\$RABBITMQ_DEFAULT_USER\\\" \\\"RABBITMQ_DEFAULT_PASS=\$RABBITMQ_DEFAULT_PASS\\\" \\\"RABBITMQ_DEFAULT_VHOST=\$RABBITMQ_DEFAULT_VHOST\\\" | sudo tee ${RABBITMQ_ROOT}/.env >/dev/null" \ + --arg c8 "sudo chmod 600 ${RABBITMQ_ROOT}/.env" \ + --arg c9 "sudo docker pull rabbitmq:${RABBITMQ_IMAGE_TAG}" \ + --arg c10 "sudo docker rm -f ${RABBITMQ_CONTAINER_NAME} || true" \ + --arg c11 "sudo docker volume create ${RABBITMQ_DATA_VOLUME} >/dev/null" \ + --arg c12 "sudo docker run -d --name ${RABBITMQ_CONTAINER_NAME} --restart unless-stopped -p 5672:5672 -p 15672:15672 --env-file ${RABBITMQ_ROOT}/.env -v ${RABBITMQ_DATA_VOLUME}:/var/lib/rabbitmq rabbitmq:${RABBITMQ_IMAGE_TAG}" \ + --arg c13 "for i in \$(seq 1 30); do if sudo docker exec ${RABBITMQ_CONTAINER_NAME} rabbitmq-diagnostics -q ping >/dev/null 2>&1; then echo 'RabbitMQ is healthy'; exit 0; fi; sleep 2; done; echo 'RabbitMQ health check failed' >&2; exit 1" \ + '{commands:[$c1,$c2,$c3,$c4,$c5,$c6,$c7,$c8,$c9,$c10,$c11,$c12,$c13]}') + + CMD_ID=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy RabbitMQ" \ + --parameters "$PARAMS" \ + --query 'Command.CommandId' \ + --output text) + + echo "command_id=$CMD_ID instance_id=$INSTANCE_ID" + + for _ in $(seq 1 120); do + STATUS=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text 2>/dev/null || true) + + case "$STATUS" in + Success) + echo "RabbitMQ deployment success" + exit 0 + ;; + Failed|Cancelled|TimedOut) + echo "RabbitMQ deployment failed: $STATUS" >&2 + aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query '{StdOut:StandardOutputContent,StdErr:StandardErrorContent}' \ + --output json || true + exit 1 + ;; + Pending|InProgress|Delayed|"") + sleep 5 + ;; + *) + sleep 5 + ;; + esac + done + + echo "RabbitMQ deployment timed out" >&2 + exit 1 diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java index 6d4950e..4b7ab07 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/content/dto/request/ContentsUpdateRequest.java @@ -42,13 +42,9 @@ public record ContentsUpdateRequest( List<@NotNull @Positive Long> tagIdList, @Schema(type = "Integer", description = "영상 길이(초)", example = "3600") - @PositiveOrZero - @NotNull Integer duration, @Schema(type = "Integer", description = "영상 크기(KB)", example = "512000") - @PositiveOrZero - @NotNull Integer videoSize, @Schema(type = "String", description = "포스터 원본 파일명(교체 시에만 입력)", example = "poster-new.jpg")