diff --git a/build.gradle b/build.gradle index 1484686..5e1aa94 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,15 @@ dependencies { // mail implementation 'org.springframework.boot:spring-boot-starter-mail' + // OCI SDK BOM + implementation platform('com.oracle.oci.sdk:oci-java-sdk-bom:3.31.0') + + // OCI ObjectStorage + implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage' + implementation 'com.oracle.oci.sdk:oci-java-sdk-common' + + //HTTP 클라이언트 + implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java b/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java index 468a62d..4f30b6d 100644 --- a/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java +++ b/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java @@ -34,8 +34,8 @@ import com.campus.campus.domain.school.domain.repository.MajorRepository; import com.campus.campus.domain.school.domain.repository.SchoolRepository; import com.campus.campus.global.config.SecurityConfig; -import com.campus.campus.global.util.jwt.application.service.RedisTokenService; import com.campus.campus.global.util.jwt.JwtProvider; +import com.campus.campus.global.util.jwt.application.service.RedisTokenService; import lombok.RequiredArgsConstructor; @@ -133,13 +133,6 @@ public void findPassword(StudentCouncilFindPasswordRequest studentCouncilFindPas studentCouncilRepository.save(studentCouncil); } - private record CouncilScope( - College college, - Major major - ) { - - } - private CouncilScope validateCouncilScope( StudentCouncilSignUpRequest studentCouncilSignUpRequest, School school @@ -196,4 +189,11 @@ private void checkVerifiedEmail(String email, VerificationType verificationType) throw new EmailNotVerifiedException(); } } + + private record CouncilScope( + College college, + Major major + ) { + + } } diff --git a/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java b/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java index 22063bc..f0d4da4 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java +++ b/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java @@ -63,4 +63,15 @@ public class StudentCouncil extends BaseEntity { public void changePassword(String newPassword) { this.password = newPassword; } + + public String getFullCouncilName() { + StringBuilder fullName = new StringBuilder(); + if (school != null) + fullName.append(school.getSchoolName()); + if (college != null) + fullName.append(" ").append(college.getCollegeName()); + if (major != null) + fullName.append(" ").append(major.getMajorName()); + return fullName.append(" 학생회").toString().trim(); + } } diff --git a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java index a66d652..29067ad 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java +++ b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java @@ -3,14 +3,24 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.campus.campus.domain.council.domain.entity.StudentCouncil; public interface StudentCouncilRepository extends JpaRepository { + Optional findByLoginId(String loginId); Optional findByEmail(String email); + @Query("SELECT sc FROM StudentCouncil sc " + + "LEFT JOIN FETCH sc.school " + + "LEFT JOIN FETCH sc.college " + + "LEFT JOIN FETCH sc.major " + + "WHERE sc.id = :councilId") + Optional findByIdWithDetails(@Param("councilId") Long councilId); + boolean existsByLoginId(String loginId); boolean existsByEmail(String email); diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java new file mode 100644 index 0000000..ec6f806 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java @@ -0,0 +1,42 @@ +package com.campus.campus.domain.studentcouncilpost.application.dto.request; + +import java.time.LocalDateTime; +import java.util.List; + +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostCategory; +import com.campus.campus.domain.studentcouncilpost.domain.entity.ThumbnailIcon; +import com.fasterxml.jackson.annotation.JsonFormat; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PostRequestDto( + + @NotNull + PostCategory category, + + @NotBlank + String title, + + @NotBlank + String content, + + String place, + + @Schema(example = "2025-04-10T18:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime startDateTime, + + @Schema(example = "2025-04-30T23:59") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime endDateTime, + + // 썸네일 (둘 중 하나는 필수) + String thumbnailImageUrl, + ThumbnailIcon thumbnailIcon, + + // 본문 이미지들 + List imageUrls +) { +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/NormalizedDateTime.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/NormalizedDateTime.java new file mode 100644 index 0000000..79f69e5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/NormalizedDateTime.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.dto.response; + +import java.time.LocalDateTime; + +public record NormalizedDateTime( + LocalDateTime startDateTime, + LocalDateTime endDateTime +) { +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java new file mode 100644 index 0000000..a0940d9 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java @@ -0,0 +1,18 @@ +package com.campus.campus.domain.studentcouncilpost.application.dto.response; + +import java.time.LocalDateTime; + +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostCategory; +import com.campus.campus.domain.studentcouncilpost.domain.entity.ThumbnailIcon; + +public record PostListItemResponseDto( + Long id, + PostCategory category, + String title, + String place, + LocalDateTime endDateTime, + String thumbnailImageUrl, + ThumbnailIcon thumbnailIcon, + Boolean isWriter +) { +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostResponseDto.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostResponseDto.java new file mode 100644 index 0000000..aec1282 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostResponseDto.java @@ -0,0 +1,35 @@ +package com.campus.campus.domain.studentcouncilpost.application.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostCategory; +import com.campus.campus.domain.studentcouncilpost.domain.entity.ThumbnailIcon; +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PostResponseDto( + + Long id, + Long writerId, + String writerName, + Boolean isWriter, + + PostCategory category, + String title, + String content, + String place, + LocalDate startDate, + LocalDate endDate, + LocalDateTime startDateTime, + + String thumbnailImageUrl, + ThumbnailIcon thumbnailIcon, + + List images +) { +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java new file mode 100644 index 0000000..e7c4f69 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java @@ -0,0 +1,27 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode implements ErrorCodeInterface { + + POST_NOT_FOUND(2401, HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."), + NOT_POST_WRITER(2402, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."), + THUMBNAIL_REQUIRED(2403, HttpStatus.BAD_REQUEST, "썸네일(이미지 또는 아이콘)은 반드시 필요합니다."), + POST_IMAGE_LIMIT_EXCEEDED(2404, HttpStatus.BAD_REQUEST, "게시글 이미지는 최대 10개까지 등록할 수 있습니다."), + + // EVENT 관련 + EVENT_START_DATETIME_REQUIRED(2405, HttpStatus.BAD_REQUEST, "행사는 시작 일시가 필요합니다."), + EVENT_END_DATETIME_NOT_ALLOWED(2406, HttpStatus.BAD_REQUEST, "행사는 종료 일시를 가질 수 없습니다."), + + // PARTNERSHIP 관련 + PARTNERSHIP_DATE_REQUIRED(2407, HttpStatus.BAD_REQUEST, "제휴는 시작일과 종료일이 필요합니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java new file mode 100644 index 0000000..2d937ef --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class EventEndDateTimeNotAllowedException extends ApplicationException { + public EventEndDateTimeNotAllowedException() { + super(ErrorCode.EVENT_END_DATETIME_NOT_ALLOWED); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java new file mode 100644 index 0000000..7850e50 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class EventStartDateTimeRequiredException extends ApplicationException { + public EventStartDateTimeRequiredException() { + super(ErrorCode.EVENT_START_DATETIME_REQUIRED); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java new file mode 100644 index 0000000..599da36 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotPostWriterException extends ApplicationException { + public NotPostWriterException() { + super(ErrorCode.NOT_POST_WRITER); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java new file mode 100644 index 0000000..0bb5476 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class PartnershipDateRequiredException extends ApplicationException { + public PartnershipDateRequiredException() { + super(ErrorCode.PARTNERSHIP_DATE_REQUIRED); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java new file mode 100644 index 0000000..0e67c79 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class PostImageLimitExceededException extends ApplicationException { + public PostImageLimitExceededException() { + super(ErrorCode.POST_IMAGE_LIMIT_EXCEEDED); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java new file mode 100644 index 0000000..7722589 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class PostNotFoundException extends ApplicationException { + public PostNotFoundException() { + super(ErrorCode.POST_NOT_FOUND); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java new file mode 100644 index 0000000..e0e1fb2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ThumbnailRequiredException extends ApplicationException { + public ThumbnailRequiredException() { + super(ErrorCode.THUMBNAIL_REQUIRED); + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java new file mode 100644 index 0000000..b6f7e81 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java @@ -0,0 +1,92 @@ +package com.campus.campus.domain.studentcouncilpost.application.mapper; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.PostListItemResponseDto; +import com.campus.campus.domain.studentcouncilpost.application.dto.request.PostRequestDto; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.PostResponseDto; +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostImage; +import com.campus.campus.domain.studentcouncilpost.domain.entity.StudentCouncilPost; + +public class StudentCouncilPostMapper { + + public static PostListItemResponseDto toListItem( + StudentCouncilPost post, + Long currentUserId + ) { + return new PostListItemResponseDto( + post.getId(), + post.getCategory(), + post.getTitle(), + post.getPlace(), + post.getEndDateTime(), + post.getThumbnailImageUrl(), + post.getThumbnailIcon(), + post.getWriter().getId().equals(currentUserId) + ); + + } + + public static PostResponseDto toDetail( + StudentCouncilPost post, + List images, + Long currentUserId + ) { + + var writer = post.getWriter(); + var builder = PostResponseDto.builder() + .id(post.getId()) + .writerId(writer.getId()) + .writerName(writer.getFullCouncilName()) + .isWriter(post.isWrittenBy(currentUserId)) + .category(post.getCategory()) + .title(post.getTitle()) + .content(post.getContent()) + .place(post.getPlace()) + .thumbnailImageUrl(post.getThumbnailImageUrl()) + .thumbnailIcon(post.getThumbnailIcon()) + .images(images != null ? images : Collections.emptyList()); + + if (post.isEvent()) { + builder.startDateTime(post.getStartDateTime()); + } else { + builder.startDate(post.getDisplayStartDate()); + builder.endDate(post.getDisplayEndDate()); + } + + return builder.build(); + } + + public static StudentCouncilPost toEntity( + StudentCouncil writer, + PostRequestDto dto, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ) { + return StudentCouncilPost.builder() + .writer(writer) + .category(dto.category()) + .title(dto.title()) + .content(dto.content()) + .place(dto.place()) + .startDateTime(startDateTime) + .endDateTime(endDateTime) + .thumbnailImageUrl(dto.thumbnailImageUrl()) + .thumbnailIcon(dto.thumbnailIcon()) + .build(); + } + + public static PostImage toEntity( + StudentCouncilPost post, + String imageUrl + ) { + return PostImage.builder() + .post(post) + .imageUrl(imageUrl) + .build(); + } + +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java new file mode 100644 index 0000000..d8c753b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java @@ -0,0 +1,265 @@ +package com.campus.campus.domain.studentcouncilpost.application.service; + +import java.util.ArrayList; +import java.util.List; + +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 com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException; +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.NormalizedDateTime; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.PostListItemResponseDto; +import com.campus.campus.domain.studentcouncilpost.application.dto.request.PostRequestDto; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.PostResponseDto; +import com.campus.campus.domain.studentcouncilpost.application.exception.NotPostWriterException; +import com.campus.campus.domain.studentcouncilpost.application.exception.PostImageLimitExceededException; +import com.campus.campus.domain.studentcouncilpost.application.exception.PostNotFoundException; +import com.campus.campus.domain.studentcouncilpost.application.exception.ThumbnailRequiredException; +import com.campus.campus.domain.studentcouncilpost.application.mapper.StudentCouncilPostMapper; +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostCategory; +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostImage; +import com.campus.campus.domain.studentcouncilpost.domain.entity.StudentCouncilPost; +import com.campus.campus.domain.studentcouncilpost.domain.repository.PostImageRepository; +import com.campus.campus.domain.studentcouncilpost.domain.repository.StudentCouncilPostRepository; +import com.campus.campus.global.oci.application.service.PresignedUrlService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentCouncilPostService { + + private final StudentCouncilPostRepository postRepository; + private final StudentCouncilRepository studentCouncilRepository; + private final PostImageRepository postImageRepository; + private final PresignedUrlService presignedUrlService; + + @Transactional + public PostResponseDto create(Long councilId, PostRequestDto dto) { + + if (dto.imageUrls() != null && dto.imageUrls().size() > 10) { + throw new PostImageLimitExceededException(); + } + + StudentCouncil writer = studentCouncilRepository.findByIdWithDetails(councilId) + .orElseThrow(StudentCouncilNotFoundException::new); + + if (dto.thumbnailImageUrl() == null && dto.thumbnailIcon() == null) { + throw new ThumbnailRequiredException(); + } + + NormalizedDateTime normalized = dto.category().validateAndNormalize(dto); + + StudentCouncilPost post = StudentCouncilPostMapper.toEntity( + writer, + dto, + normalized.startDateTime(), + normalized.endDateTime() + ); + + postRepository.save(post); + + if (dto.imageUrls() != null) { + for (String imageUrl : dto.imageUrls()) { + postImageRepository.save( + StudentCouncilPostMapper.toEntity(post, imageUrl) + ); + } + } + + List imageUrls = postImageRepository + .findAllByPostOrderByIdAsc(post) + .stream() + .map(PostImage::getImageUrl) + .toList(); + + return StudentCouncilPostMapper.toDetail( + post, + imageUrls, + councilId + ); + } + + @Transactional(readOnly = true) + public PostResponseDto findById(Long postId, Long currentUserId) { + StudentCouncilPost post = postRepository.findByIdWithFullInfo(postId) + .orElseThrow(PostNotFoundException::new); + + List imageUrls = postImageRepository + .findAllByPostOrderByIdAsc(post) + .stream() + .map(PostImage::getImageUrl) + .toList(); + + return StudentCouncilPostMapper.toDetail( + post, + imageUrls, + currentUserId + ); + } + + @Transactional(readOnly = true) + public Page findAll( + PostCategory category, + int page, + int size, + Long currentUserId + ) { + Pageable pageable = PageRequest.of( + Math.max(page - 1, 0), + size, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + + Page posts = (category == null) + ? postRepository.findAll(pageable) + : postRepository.findAllByCategory(category, pageable); + + return posts.map(post -> + StudentCouncilPostMapper.toListItem(post, currentUserId) + ); + } + + @Transactional + public void delete(Long councilId, Long postId) { + + StudentCouncilPost post = postRepository.findByIdWithWriter(postId) + .orElseThrow(PostNotFoundException::new); + + if (!post.getWriter().getId().equals(councilId)) { + throw new NotPostWriterException(); + } + + List postImages = postImageRepository.findAllByPost(post); + + List deleteTargets = new ArrayList<>(); + + if (post.getThumbnailImageUrl() != null) { + deleteTargets.add(post.getThumbnailImageUrl()); + } + + postImages.stream() + .map(PostImage::getImageUrl) + .forEach(deleteTargets::add); + + postImageRepository.deleteAll(postImages); + postRepository.delete(post); + + for (String imageUrl : deleteTargets) { + try { + presignedUrlService.deleteImage(imageUrl); + } catch (Exception e) { + log.warn("OCI 파일 삭제 실패: {}", imageUrl); + } + } + } + + @Transactional + public PostResponseDto update( + Long councilId, + Long postId, + PostRequestDto dto + ) { + + if (dto.imageUrls() != null && dto.imageUrls().size() > 10) { + throw new PostImageLimitExceededException(); + } + + StudentCouncilPost post = postRepository.findByIdWithFullInfo(postId) + .orElseThrow(PostNotFoundException::new); + + if (!post.getWriter().getId().equals(councilId)) { + throw new NotPostWriterException(); + } + + // 썸네일 검증 + if (dto.thumbnailImageUrl() == null && dto.thumbnailIcon() == null) { + throw new ThumbnailRequiredException(); + } + + NormalizedDateTime normalized = dto.category().validateAndNormalize(dto); + + String oldThumbnailUrl = post.getThumbnailImageUrl(); + List oldImages = postImageRepository.findAllByPost(post); + + post.update( + dto.title(), + dto.content(), + dto.place(), + normalized.startDateTime(), + normalized.endDateTime(), + dto.thumbnailImageUrl(), + dto.thumbnailIcon(), + dto.category() + ); + + postImageRepository.deleteByPost(post); + + if (dto.imageUrls() != null) { + for (String imageUrl : dto.imageUrls()) { + postImageRepository.save( + StudentCouncilPostMapper.toEntity(post, imageUrl) + ); + } + } + + cleanupUnusedImages(oldThumbnailUrl, oldImages, dto); + + List imageUrls = postImageRepository + .findAllByPostOrderByIdAsc(post) + .stream() + .map(PostImage::getImageUrl) + .toList(); + + return StudentCouncilPostMapper.toDetail( + post, + imageUrls, + councilId + ); + } + + //이미지 삭제 + private void cleanupUnusedImages( + String oldThumbnailUrl, + List oldImages, + PostRequestDto dto + ) { + List newUrls = dto.imageUrls() == null + ? List.of() + : dto.imageUrls(); + + List deleteTargets = new ArrayList<>(); + + // 썸네일 변경 시 이전 썸네일 + if (oldThumbnailUrl != null && + !oldThumbnailUrl.equals(dto.thumbnailImageUrl())) { + deleteTargets.add(oldThumbnailUrl); + } + + // 본문 이미지 중 제거된 이미지 + oldImages.stream() + .map(PostImage::getImageUrl) + .filter(url -> !newUrls.contains(url)) + .forEach(deleteTargets::add); + //삭제 + for (String imageUrl : deleteTargets) { + if (imageUrl == null || imageUrl.isBlank()) { + continue; + } + + try { + presignedUrlService.deleteImage(imageUrl); + } catch (Exception e) { + log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl); + } + } + } +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java new file mode 100644 index 0000000..26d30a7 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java @@ -0,0 +1,40 @@ +package com.campus.campus.domain.studentcouncilpost.domain.entity; + +import java.time.LocalTime; + +import com.campus.campus.domain.studentcouncilpost.application.dto.response.NormalizedDateTime; +import com.campus.campus.domain.studentcouncilpost.application.dto.request.PostRequestDto; +import com.campus.campus.domain.studentcouncilpost.application.exception.EventEndDateTimeNotAllowedException; +import com.campus.campus.domain.studentcouncilpost.application.exception.EventStartDateTimeRequiredException; +import com.campus.campus.domain.studentcouncilpost.application.exception.PartnershipDateRequiredException; + +public enum PostCategory { + + EVENT { + @Override + public NormalizedDateTime validateAndNormalize(PostRequestDto dto) { + if (dto.startDateTime() == null) { + throw new EventStartDateTimeRequiredException(); + } + if (dto.endDateTime() != null) { + throw new EventEndDateTimeNotAllowedException(); + } + return new NormalizedDateTime(dto.startDateTime(), null); + } + }, + + PARTNERSHIP { + @Override + public NormalizedDateTime validateAndNormalize(PostRequestDto dto) { + if (dto.startDateTime() == null || dto.endDateTime() == null) { + throw new PartnershipDateRequiredException(); + } + return new NormalizedDateTime( + dto.startDateTime().with(LocalTime.MIN), + dto.endDateTime().with(LocalTime.MAX) + ); + } + }; + + public abstract NormalizedDateTime validateAndNormalize(PostRequestDto dto); +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java new file mode 100644 index 0000000..85f7c86 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java @@ -0,0 +1,36 @@ +package com.campus.campus.domain.studentcouncilpost.domain.entity; + +import static jakarta.persistence.FetchType.*; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String imageUrl; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "post_id") + private StudentCouncilPost post; + +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java new file mode 100644 index 0000000..f274511 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java @@ -0,0 +1,97 @@ +package com.campus.campus.domain.studentcouncilpost.domain.entity; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.global.entity.BaseEntity; + +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StudentCouncilPost extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "writer_id") + private StudentCouncil writer; + + @Enumerated(EnumType.STRING) + private PostCategory category; + + private String title; + + @Column(columnDefinition = "TEXT") + private String content; + + private String place; + + private LocalDateTime startDateTime; + private LocalDateTime endDateTime; + + private String thumbnailImageUrl; + + @Enumerated(EnumType.STRING) + private ThumbnailIcon thumbnailIcon; + + public void update(String title, + String content, + String place, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + String thumbnailImageUrl, + ThumbnailIcon thumbnailIcon, + PostCategory category) { + + this.title = title; + this.content = content; + this.place = place; + this.startDateTime = startDateTime; + this.endDateTime = endDateTime; + this.category = category; + + if (thumbnailImageUrl != null) + this.thumbnailImageUrl = thumbnailImageUrl; + + if (thumbnailIcon != null) + this.thumbnailIcon = thumbnailIcon; + } + + public boolean isEvent() { + return this.category == PostCategory.EVENT; + } + + public LocalDate getDisplayStartDate() { + return startDateTime != null ? startDateTime.toLocalDate() : null; + } + + public LocalDate getDisplayEndDate() { + return endDateTime != null ? endDateTime.toLocalDate() : null; + } + + public boolean isWrittenBy(Long userId) { + return writer != null && writer.getId().equals(userId); + } + +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java new file mode 100644 index 0000000..94bdfa8 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.studentcouncilpost.domain.entity; + +public enum ThumbnailIcon { + CAFE, + EVENT, + NOTICE, + FOOD, + SPORTS +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java new file mode 100644 index 0000000..ee5188b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java @@ -0,0 +1,17 @@ +package com.campus.campus.domain.studentcouncilpost.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostImage; +import com.campus.campus.domain.studentcouncilpost.domain.entity.StudentCouncilPost; + +public interface PostImageRepository extends JpaRepository { + + List findAllByPost(StudentCouncilPost post); + + void deleteByPost(StudentCouncilPost post); + + List findAllByPostOrderByIdAsc(StudentCouncilPost post); +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java new file mode 100644 index 0000000..d2a7477 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java @@ -0,0 +1,34 @@ +package com.campus.campus.domain.studentcouncilpost.domain.repository; + +import java.util.Optional; + +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.campus.campus.domain.studentcouncilpost.domain.entity.PostCategory; +import com.campus.campus.domain.studentcouncilpost.domain.entity.StudentCouncilPost; + +public interface StudentCouncilPostRepository extends JpaRepository { + + Page findAllByCategory(PostCategory category, Pageable pageable); + + @Query("SELECT p FROM StudentCouncilPost p " + + "JOIN FETCH p.writer w " + + "JOIN FETCH w.school s " + + "LEFT JOIN FETCH w.college c " + + "LEFT JOIN FETCH w.major m " + + "WHERE p.id = :postId") + Optional findByIdWithFullInfo(@Param("postId") Long postId); + + @Query("SELECT p FROM StudentCouncilPost p " + + "JOIN FETCH p.writer w " + + "LEFT JOIN FETCH w.school " + + "LEFT JOIN FETCH w.college " + + "LEFT JOIN FETCH w.major " + + "WHERE p.id = :postId") + Optional findByIdWithWriter(@Param("postId") Long postId); + +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java new file mode 100644 index 0000000..21d6bfc --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.studentcouncilpost.presentation; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.response.ResponseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PostResponseCode implements ResponseCodeInterface { + + POST_CREATE_SUCCESS(201, HttpStatus.CREATED, "게시글 생성에 성공했습니다."), + POST_READ_SUCCESS(200, HttpStatus.OK, "게시글 조회에 성공했습니다."), + POST_LIST_READ_SUCCESS(200, HttpStatus.OK, "게시글 목록 조회에 성공했습니다."), + POST_UPDATE_SUCCESS(200, HttpStatus.OK, "게시글 수정에 성공했습니다."), + POST_DELETE_SUCCESS(204, HttpStatus.NO_CONTENT, "게시글 삭제에 성공했습니다."), + POST_IMAGE_FINALIZE_SUCCESS(200, HttpStatus.OK, "이미지 확정(이동)에 성공했습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java b/src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java new file mode 100644 index 0000000..5e4930d --- /dev/null +++ b/src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java @@ -0,0 +1,178 @@ +package com.campus.campus.domain.studentcouncilpost.presentation; + +import org.springframework.data.domain.Page; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +import com.campus.campus.domain.studentcouncilpost.application.dto.request.PostRequestDto; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.PostListItemResponseDto; +import com.campus.campus.domain.studentcouncilpost.application.dto.response.PostResponseDto; +import com.campus.campus.domain.studentcouncilpost.application.service.StudentCouncilPostService; +import com.campus.campus.domain.studentcouncilpost.domain.entity.PostCategory; +import com.campus.campus.global.annotation.CurrentUserId; +import com.campus.campus.global.common.response.CommonResponse; + +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.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@PreAuthorize("hasRole('COUNCIL')") +@RequestMapping("/student-council/posts") +@Tag(name = "Student Council Post", description = "학생회(COUNCIL) 권한 전용 제휴/행사 게시글 관리 API") +@RequiredArgsConstructor +public class StudentCouncilPostController { + + private final StudentCouncilPostService postService; + + @PostMapping + @Operation( + summary = "학생회 제휴/행사 게시글 생성", + description = + "새로운 학생회 게시글을 작성합니다.\n\n" + + + "### 📌 핵심 내용\n" + + "1. **카테고리 선택**\n" + + " - `PARTNERSHIP`(제휴) 또는 `EVENT`(행사) 중 하나를 반드시 선택해야 합니다.\n\n" + + + "2. **카테고리별 날짜/시간 규칙**\n" + + " - `EVENT` (행사)\n" + + " - `startDateTime`은 **필수**입니다. (날짜 + 시간 포함)\n" + + " - `endDateTime`은 **허용되지 않습니다**.\n" + + " - `PARTNERSHIP` (제휴)\n" + + " - `startDateTime`, `endDateTime`은 **모두 필수**입니다.\n" + + " - 서버에서 시간은 자동 정규화됩니다.\n\n" + + + "3. **이미지 처리 방식**\n" + + " - OCI Presigned URL로 업로드 후 최종 URL 전달\n\n" + + + "4. **권한 제한**\n" + + " - 학생회 계정만 작성 가능", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PostRequestDto.class), + examples = { + @ExampleObject( + name = "EVENT 게시글", + summary = "행사 게시글", + value = """ + { + "category": "EVENT", + "title": "2025 봄 축제", + "content": "중앙 동아리 연합 봄 축제", + "place": "대운동장", + "startDateTime": "2025-04-10T18:00", + "thumbnailIcon": "EVENT", + "imageUrls": [] + } + """ + ), + @ExampleObject( + name = "PARTNERSHIP 게시글", + summary = "제휴 게시글", + value = """ + { + "category": "PARTNERSHIP", + "title": "카페 할인", + "content": "10% 할인", + "place": "OO카페", + "startDateTime": "2025-04-01T00:00", + "endDateTime": "2025-04-30T23:59", + "thumbnailIcon": "CAFE", + "imageUrls": [] + } + """ + ) + } + ) + ) + ) + public CommonResponse createPost( + @CurrentUserId Long councilId, + @RequestBody @Valid PostRequestDto requestDto + ) { + PostResponseDto responseDto = postService.create(councilId, requestDto); + return CommonResponse.success( + PostResponseCode.POST_CREATE_SUCCESS, + responseDto); + } + + @GetMapping("/{postId}") + @Operation(summary = "학생회 게시글 단건 조회") + public CommonResponse getPost( + @PathVariable Long postId, + @CurrentUserId Long councilId + ) { + PostResponseDto responseDto = + postService.findById(postId, councilId); + + return CommonResponse.success( + PostResponseCode.POST_READ_SUCCESS, + responseDto + ); + } + + @GetMapping + @Operation( + summary = "학생회 게시글 목록 조회 (필터링 포함)", + description = "전체 게시글 혹은 제휴(PARTNERSHIP), 행사(EVENT) 카테고리별로 필터링하여 목록을 조회합니다." + ) + public CommonResponse> getPostList( + @Parameter( + description = "필터링할 카테고리 (미선택 시 전체 조회)", + example = "PARTNERSHIP", + schema = @Schema(implementation = PostCategory.class) + ) + @RequestParam(required = false) PostCategory category, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "3") int size, + @CurrentUserId Long councilId + ) { + Page responseDto = + postService.findAll( + category, page, size, councilId); + + return CommonResponse.success( + PostResponseCode.POST_LIST_READ_SUCCESS, + responseDto + ); + } + + @PatchMapping("/{postId}") + @Operation(summary = "학생회 게시글 수정") + public CommonResponse updatePost( + @CurrentUserId Long councilId, + @PathVariable Long postId, + @RequestBody @Valid PostRequestDto requestDto + ) { + PostResponseDto responseDto = postService.update(councilId, postId, requestDto); + return CommonResponse.success( + PostResponseCode.POST_UPDATE_SUCCESS, + responseDto); + } + + @DeleteMapping("/{postId}") + @Operation(summary = "학생회 게시글 삭제") + public CommonResponse deletePost( + @CurrentUserId Long councilId, + @PathVariable Long postId + ) { + postService.delete(councilId, postId); + return CommonResponse.success(PostResponseCode.POST_DELETE_SUCCESS); + } +} diff --git a/src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java b/src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java index 6b5112a..0f2123e 100644 --- a/src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java +++ b/src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java @@ -1,5 +1,6 @@ package com.campus.campus.global.annotation; +import com.campus.campus.global.util.jwt.StudentCouncilPrincipal; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -56,6 +57,12 @@ public Object resolveArgument( return id; } + if (principal instanceof StudentCouncilPrincipal councilPrincipal) { + Long id = councilPrincipal.getCouncilId(); + if (id == null && required) throw new UnAuthorizedException(); + return id; + } + // 3) principal 타입이 예상과 다름 (예: String "anonymousUser" 등) if (required) { throw new UnAuthorizedException(); diff --git a/src/main/java/com/campus/campus/global/config/OciConfig.java b/src/main/java/com/campus/campus/global/config/OciConfig.java new file mode 100644 index 0000000..8dd5306 --- /dev/null +++ b/src/main/java/com/campus/campus/global/config/OciConfig.java @@ -0,0 +1,265 @@ +package com.campus.campus.global.config; + +import java.io.File; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.oracle.bmc.Region; +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider; +import com.oracle.bmc.auth.SimplePrivateKeySupplier; +import com.oracle.bmc.objectstorage.ObjectStorage; +import com.oracle.bmc.objectstorage.ObjectStorageClient; + +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class OciConfig { + + private static final String PEM_HEADER = "-----BEGIN PRIVATE KEY-----"; + private static final String PEM_FOOTER = "-----END PRIVATE KEY-----"; + private static final String KEY_DIR_NAME = ".oci-keys"; + private static final String KEY_FILE_NAME = "private-key.pem"; + private static final String OBJECT_STORAGE_URL_TEMPLATE = + "https://objectstorage.%s.oraclecloud.com/n/%s/b/%s/o/%s"; + + @Getter + @Value("${oci.region}") + private String region; + + @Getter + @Value("${oci.namespace}") + private String namespace; + + @Getter + @Value("${oci.bucket-name}") + private String bucketName; + + @Value("${oci.tenancy-ocid}") + private String tenancyOcid; + + @Value("${oci.user-ocid}") + private String userOcid; + + @Value("${oci.fingerprint}") + private String fingerprint; + + @Value("${oci.private-key}") + private String privateKeyRaw; + + @Value("${oci.passphrase:}") + private String passPhrase; + + private Path keyFilePath; + + @Bean + public ObjectStorage objectStorage() { + log.info(">>> [OCI] Initializing ObjectStorage client..."); + + String normalizedPem = normalizePem(privateKeyRaw); + + try { + + keyFilePath = createSecureKeyFile(normalizedPem); + + // Provider 생성 + SimplePrivateKeySupplier keySupplier = + new SimplePrivateKeySupplier(keyFilePath.toString()); + + SimpleAuthenticationDetailsProvider provider = + SimpleAuthenticationDetailsProvider.builder() + .tenantId(tenancyOcid) + .userId(userOcid) + .fingerprint(fingerprint) + .privateKeySupplier(keySupplier) + .passPhrase(passPhrase) + .region(Region.fromRegionId(region)) + .build(); + + ObjectStorage client = ObjectStorageClient.builder() + .region(Region.fromRegionId(region)) + .build(provider); + + log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region); + return client; + + } catch (Exception e) { + cleanupKeyFile(); + log.error(">>> [OCI ERROR] Failed to initialize ObjectStorage", e); + throw new IllegalStateException("Failed to initialize ObjectStorage", e); + } + } + + private String normalizePem(String pem) { + if (pem == null || pem.isBlank()) { + throw new IllegalArgumentException("Private key cannot be null or empty"); + } + + String normalized = pem.replace("\\n", "\n").trim(); + + String firstLine = normalized.lines().findFirst().orElse(""); + String lastLine = normalized.lines().reduce((a, b) -> b).orElse(""); + + if (!firstLine.equals(PEM_HEADER)) { + throw new IllegalArgumentException("Invalid PEM header: " + firstLine); + } + if (!lastLine.equals(PEM_FOOTER)) { + throw new IllegalArgumentException("Invalid PEM footer: " + lastLine); + } + + long lineCount = normalized.lines().count(); + log.info(">>> [PEM] Normalization successful, line count: {}", lineCount); + + return normalized; + } + + /** + * 앱 내부 디렉토리에 보안 키 파일 생성 + * 경로: {user.dir}/.oci-keys/private-key.pem + */ + private Path createSecureKeyFile(String pem) throws IOException { + // 1. 앱 실행 디렉토리 하위에 .oci-keys 디렉토리 생성 + Path workingDir = Paths.get(System.getProperty("user.dir")); + Path keyDir = workingDir.resolve(KEY_DIR_NAME); + + Files.createDirectories(keyDir); + log.info(">>> [PEM] Key directory: {}", keyDir.toAbsolutePath()); + + setDirectoryPermissions(keyDir); + + Path keyFile = keyDir.resolve(KEY_FILE_NAME); + + Files.deleteIfExists(keyFile); + + Files.writeString(keyFile, pem, StandardCharsets.UTF_8); + + setFilePermissions(keyFile); + + log.info(">>> [PEM] Secure key file created: {}", keyFile.toAbsolutePath()); + + return keyFile; + } + + /** + * 디렉토리 권한 설정 (700 - owner만 접근) + */ + private void setDirectoryPermissions(Path dir) throws IOException { + File d = dir.toFile(); + + try { + + Set perms = Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ); + Files.setPosixFilePermissions(dir, perms); + log.debug(">>> [PEM] Directory POSIX permissions set (700)"); + + } catch (UnsupportedOperationException e) { + // Windows + d.setReadable(false, false); + d.setReadable(true, true); + d.setWritable(false, false); + d.setWritable(true, true); + d.setExecutable(false, false); + d.setExecutable(true, true); + log.debug(">>> [PEM] Directory Windows permissions set"); + } + } + + /** + * 파일 권한 설정 (600 - owner만 읽기/쓰기) + */ + private void setFilePermissions(Path file) throws IOException { + File f = file.toFile(); + + try { + Set perms = Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE + ); + Files.setPosixFilePermissions(file, perms); + log.debug(">>> [PEM] File POSIX permissions set (600)"); + + } catch (UnsupportedOperationException e) { + // Windows + f.setReadable(false, false); + f.setReadable(true, true); + f.setWritable(false, false); + f.setWritable(true, true); + f.setExecutable(false, false); + log.debug(">>> [PEM] File Windows permissions set"); + } + } + + /** + * Object Storage 전체 URL 생성 (URL 인코딩 포함) + */ + public String fullObjectUrl(String objectName) { + if (objectName == null || objectName.isBlank()) { + throw new IllegalArgumentException("Object name cannot be null or empty"); + } + + String encodedObjectName = URLEncoder.encode(objectName, StandardCharsets.UTF_8); + + return String.format( + OBJECT_STORAGE_URL_TEMPLATE, + region, + namespace, + bucketName, + encodedObjectName + ); + } + + /** + * 애플리케이션 종료 시 키 파일 정리 + */ + @PreDestroy + public void cleanup() { + log.info(">>> [OCI] Cleaning up resources..."); + cleanupKeyFile(); + } + + /** + * 키 파일 삭제 + */ + private void cleanupKeyFile() { + if (keyFilePath != null) { + try { + boolean deleted = Files.deleteIfExists(keyFilePath); + if (deleted) { + log.info(">>> [PEM] Key file deleted: {}", keyFilePath); + + // 빈 디렉토리도 삭제 시도 + Path keyDir = keyFilePath.getParent(); + if (keyDir != null && Files.isDirectory(keyDir)) { + try { + Files.delete(keyDir); + log.info(">>> [PEM] Key directory deleted: {}", keyDir); + } catch (IOException e) { + // 디렉토리가 비어있지 않거나 삭제 실패 시 무시 + log.debug(">>> [PEM] Key directory not deleted (may contain other files)"); + } + } + } else { + log.warn(">>> [PEM] Key file not found: {}", keyFilePath); + } + } catch (IOException e) { + log.error(">>> [PEM ERROR] Failed to delete key file: {}", keyFilePath, e); + } + } + } +} diff --git a/src/main/java/com/campus/campus/global/config/SecurityConfig.java b/src/main/java/com/campus/campus/global/config/SecurityConfig.java index 578c88d..13e8eaa 100644 --- a/src/main/java/com/campus/campus/global/config/SecurityConfig.java +++ b/src/main/java/com/campus/campus/global/config/SecurityConfig.java @@ -22,7 +22,7 @@ import lombok.RequiredArgsConstructor; @Configuration -@EnableMethodSecurity +@EnableMethodSecurity(prePostEnabled = true) @RequiredArgsConstructor public class SecurityConfig { diff --git a/src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java b/src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java new file mode 100644 index 0000000..8bd7d26 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java @@ -0,0 +1,28 @@ +package com.campus.campus.global.oci.application.dto.request; + +import com.campus.campus.global.oci.exception.InvalidImageContentTypeException; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record PresignedUrlRequestDto( + + @Schema( + description = "이미지 MIME 타입", + example = "image/png" + ) + @NotBlank + String contentType +) { + + @JsonIgnore + @Schema(hidden = true) + public String resolveExtension() { + return switch (contentType) { + case "image/png" -> ".png"; + case "image/jpeg", "image/jpg" -> ".jpg"; + default -> throw new InvalidImageContentTypeException(contentType); + }; + } +} diff --git a/src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java b/src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java new file mode 100644 index 0000000..d39f33a --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java @@ -0,0 +1,12 @@ +package com.campus.campus.global.oci.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignedUrlResponseDto( + @Schema(description = "Presigned PUT 업로드 URL") + String uploadUrl, + + @Schema(description = "업로드 완료 후 접근 가능한 이미지 URL") + String imageUrl +) { +} diff --git a/src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java b/src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java new file mode 100644 index 0000000..0149d30 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java @@ -0,0 +1,112 @@ +package com.campus.campus.global.oci.application.service; + +import java.net.URI; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.campus.campus.global.config.OciConfig; +import com.campus.campus.global.oci.application.dto.request.PresignedUrlRequestDto; +import com.campus.campus.global.oci.application.dto.response.PresignedUrlResponseDto; +import com.campus.campus.global.oci.exception.OciObjectDeleteFailException; +import com.campus.campus.global.oci.exception.OciPresignedUrlCreateFailException; +import com.campus.campus.global.oci.mapper.PresignedUrlMapper; +import com.oracle.bmc.objectstorage.ObjectStorage; +import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest; +import com.oracle.bmc.objectstorage.requests.DeleteObjectRequest; +import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PresignedUrlService { + + private static final long PRESIGNED_TTL_MS = 10 * 60 * 1000; // 10분 + private final ObjectStorage objectStorage; + private final OciConfig ociConfig; + + public PresignedUrlResponseDto createPresignedUrl( + String directory, + PresignedUrlRequestDto request + ) { + String objectName = + directory + "/" + UUID.randomUUID() + request.resolveExtension(); + + String uploadUrl = createPresignedPutUrl(objectName); + String imageUrl = ociConfig.fullObjectUrl(objectName); + + return new PresignedUrlResponseDto(uploadUrl, imageUrl); + } + + private String createPresignedPutUrl(String objectName) { + try { + CreatePreauthenticatedRequestRequest request = + PresignedUrlMapper.toPutObjectRequest( + ociConfig.getBucketName(), + ociConfig.getNamespace(), + objectName, + System.currentTimeMillis() + PRESIGNED_TTL_MS + ); + + CreatePreauthenticatedRequestResponse response = + objectStorage.createPreauthenticatedRequest(request); + + return String.format( + "https://objectstorage.%s.oraclecloud.com%s", + ociConfig.getRegion(), + response.getPreauthenticatedRequest().getAccessUri() + ); + + } catch (Exception e) { + log.error(">>> PRESIGNED URL CREATE ERROR", e); + throw new OciPresignedUrlCreateFailException(); + } + } + + public void deleteImage(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) + return; + + String objectName = extractObjectNameFromUrl(imageUrl); + deleteObject(objectName); + } + + private String extractObjectNameFromUrl(String url) { + try { + URI uri = URI.create(url); + String path = uri.getPath(); + int idx = path.indexOf("/o/"); + if (idx == -1) { + throw new IllegalArgumentException("Invalid OCI object URL"); + } + return path.substring(idx + 3); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid OCI object URL: " + url); + } + } + + /** + * OCI Object 삭제 + */ + private void deleteObject(String objectName) { + try { + DeleteObjectRequest request = + PresignedUrlMapper.toDeleteObjectRequest( + ociConfig.getBucketName(), + ociConfig.getNamespace(), + objectName + ); + + objectStorage.deleteObject(request); + + log.info("OCI object deleted: {}", objectName); + + } catch (Exception e) { + log.error(">>> OCI DELETE ERROR", e); + throw new OciObjectDeleteFailException(); + } + } +} diff --git a/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java b/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java new file mode 100644 index 0000000..babe000 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java @@ -0,0 +1,22 @@ +package com.campus.campus.global.oci.exception; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.exception.ErrorCodeInterface; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements ErrorCodeInterface { + + OCI_PRESIGNED_URL_CREATE_FAILED(6100, HttpStatus.INTERNAL_SERVER_ERROR, "Presigned URL 생성에 실패했습니다."), + OCI_OBJECT_COPY_FAILED(6101, HttpStatus.INTERNAL_SERVER_ERROR, "이미지 복사 중 오류가 발생했습니다."), + OCI_OBJECT_DELETE_FAILED(6102, HttpStatus.INTERNAL_SERVER_ERROR, "임시 이미지 삭제 중 오류가 발생했습니다."), + OCI_IMAGE_MOVE_FAILED(6103, HttpStatus.INTERNAL_SERVER_ERROR, "이미지를 최종 경로로 이동하는 데 실패했습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java b/src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java new file mode 100644 index 0000000..99dd182 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java @@ -0,0 +1,7 @@ +package com.campus.campus.global.oci.exception; + +public class InvalidImageContentTypeException extends RuntimeException { + public InvalidImageContentTypeException(String contentType) { + super("지원하지 않는 이미지 타입입니다: " + contentType); + } +} diff --git a/src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java b/src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java new file mode 100644 index 0000000..432774d --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java @@ -0,0 +1,9 @@ +package com.campus.campus.global.oci.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class OciObjectDeleteFailException extends ApplicationException { + public OciObjectDeleteFailException() { + super(ErrorCode.OCI_OBJECT_DELETE_FAILED); + } +} diff --git a/src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java b/src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java new file mode 100644 index 0000000..c69e758 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java @@ -0,0 +1,9 @@ +package com.campus.campus.global.oci.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class OciPresignedUrlCreateFailException extends ApplicationException { + public OciPresignedUrlCreateFailException() { + super(ErrorCode.OCI_PRESIGNED_URL_CREATE_FAILED); + } +} diff --git a/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java b/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java new file mode 100644 index 0000000..56a70f4 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java @@ -0,0 +1,44 @@ +package com.campus.campus.global.oci.mapper; + +import java.util.Date; +import java.util.UUID; + +import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails; +import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest; +import com.oracle.bmc.objectstorage.requests.DeleteObjectRequest; + +public class PresignedUrlMapper { + + public static CreatePreauthenticatedRequestRequest toPutObjectRequest( + String bucketName, + String namespaceName, + String objectName, + long expiresAtMillis + ) { + CreatePreauthenticatedRequestDetails details = + CreatePreauthenticatedRequestDetails.builder() + .name("upload-" + UUID.randomUUID()) + .objectName(objectName) + .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + .timeExpires(new Date(expiresAtMillis)) + .build(); + + return CreatePreauthenticatedRequestRequest.builder() + .bucketName(bucketName) + .namespaceName(namespaceName) + .createPreauthenticatedRequestDetails(details) + .build(); + } + + public static DeleteObjectRequest toDeleteObjectRequest( + String bucketName, + String namespaceName, + String objectName + ) { + return DeleteObjectRequest.builder() + .bucketName(bucketName) + .namespaceName(namespaceName) + .objectName(objectName) + .build(); + } +} diff --git a/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java b/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java new file mode 100644 index 0000000..b0428b1 --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java @@ -0,0 +1,51 @@ +package com.campus.campus.global.oci.presentation; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.campus.campus.global.common.response.CommonResponse; +import com.campus.campus.global.oci.application.dto.request.PresignedUrlRequestDto; +import com.campus.campus.global.oci.application.dto.response.PresignedUrlResponseDto; +import com.campus.campus.global.oci.application.service.PresignedUrlService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/storage") +@Tag(name = "Object Storage", description = "OCI 스토리지 공통 API") +public class PresignedUrlController { + + private final PresignedUrlService presignedUrlService; + + @PostMapping("/presigned") + @Operation(summary = "공통 이미지 업로드용 Presigned URL 생성") + public CommonResponse createPresignedUrl( + @RequestBody @Valid PresignedUrlRequestDto request + ) { + return CommonResponse.success( + PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS, + presignedUrlService.createPresignedUrl("uploads", request) + ); + } + + @PostMapping("/posts/images/presigned") + @Operation( + summary = "게시글 이미지 업로드용 Presigned URL 생성", + description = "게시글 본문/썸네일 이미지 업로드 전용 Presigned URL을 생성" + ) + public CommonResponse createPostImagePresignedUrl( + @RequestBody @Valid PresignedUrlRequestDto request + ) { + return CommonResponse.success( + PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS, + presignedUrlService.createPresignedUrl("posts/images", request) + ); + } + +} diff --git a/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java b/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java new file mode 100644 index 0000000..881eada --- /dev/null +++ b/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java @@ -0,0 +1,19 @@ +package com.campus.campus.global.oci.presentation; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.response.ResponseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PresignedUrlResponseCode implements ResponseCodeInterface { + + PRESIGNED_URL_SUCCESS(200, HttpStatus.OK, "Presigned URL 생성 성공"); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 09fa96f..6ab46e7 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -45,5 +45,14 @@ jwt: refresh: secret: ENC(9CJkZOxQcIIyeYZgy6nLMcwZfh/zzdzzveWEE9BMxPe8PXiCxEqs6uICqTeYJSC76OOOvjClj0V2SSMxGjCa+HAveQ9hFWN2x/ZmgV4zWkH6mKMmHdQjNcezY3KWRzj0dtdgl0AH+js=) expiration-seconds: 1209600 +oci: + tenancy-ocid: ENC(IM6NrtZ0dczO5m2C21JpRCpmnTjXtorkUxgk3sk1GKFvmY2QqFoC+AQ4bPTUY9GDcaEwAOuDbKhVP6v9g5hLKQhjHs0L6ahwx7/6FUD1rRSHKJyJ5lqckQ==) + user-ocid: ENC(AehvDLwN1zrwWa+dRVPZBdQNEHYrD2vE+ox8EQcoE6Pl0VaARXjL6yQW+GliYpKh/VyZe87v5c4/M3QUT4AQG/C/Yc7Ph+XQI67LnxXE86var33I0c7d/w==) + fingerprint: ENC(AWS+0JOo7epMgOlasdKHrKN0c5A+EyrWatGmzcCt92F5ZP/ooUF+U4OcL4UR6xoRx2Q/A3DItEA=) + private-key: ENC(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) + namespace: ENC(Y18R1Xnybk0+ciNUds5XklojoFARJ4b1) + bucket-name: ENC(VcEGq1Hq61qLzEnYzx3DEA==) + passphrase: + region: ENC(XcQzPRqLJTALjw6MGL97J1IeENalwcJw) server-uri: ENC(ZrgPccnQ3mEqVQTFEvGn6hzhP4xcNn6ISnp3TbBcd1J3jpZPb3hlzQ==) \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4612e2e..4b9c097 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -45,5 +45,14 @@ jwt: refresh: secret: ${JWT_SECRET} expiration-seconds: 1209600 +oci: + tenancy-ocid: ${OCI_TENANCY_OCID} + user-ocid: ${OCI_USER_OCID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + passphrase: ${OCI_PASSPHRASE:} + namespace: ${OCI_NAMESPACE} + bucket-name: ${BUCKET_NAME} + region: ${OCI_REGION} server-uri: http://localhost:8080 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7e019ef..0a0b4b7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -45,5 +45,13 @@ jwt: refresh: secret: ENC(9CJkZOxQcIIyeYZgy6nLMcwZfh/zzdzzveWEE9BMxPe8PXiCxEqs6uICqTeYJSC76OOOvjClj0V2SSMxGjCa+HAveQ9hFWN2x/ZmgV4zWkH6mKMmHdQjNcezY3KWRzj0dtdgl0AH+js=) expiration-seconds: 1209600 - +oci: + tenancy-ocid: ENC(IM6NrtZ0dczO5m2C21JpRCpmnTjXtorkUxgk3sk1GKFvmY2QqFoC+AQ4bPTUY9GDcaEwAOuDbKhVP6v9g5hLKQhjHs0L6ahwx7/6FUD1rRSHKJyJ5lqckQ==) + user-ocid: ENC(AehvDLwN1zrwWa+dRVPZBdQNEHYrD2vE+ox8EQcoE6Pl0VaARXjL6yQW+GliYpKh/VyZe87v5c4/M3QUT4AQG/C/Yc7Ph+XQI67LnxXE86var33I0c7d/w==) + fingerprint: ENC(AWS+0JOo7epMgOlasdKHrKN0c5A+EyrWatGmzcCt92F5ZP/ooUF+U4OcL4UR6xoRx2Q/A3DItEA=) + private-key: ENC(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) + namespace: ENC(Y18R1Xnybk0+ciNUds5XklojoFARJ4b1) + bucket-name: ENC(VcEGq1Hq61qLzEnYzx3DEA==) + passphrase: + region: ENC(XcQzPRqLJTALjw6MGL97J1IeENalwcJw) server-uri: ENC(pitqB0FTjbgREG33LepbDH3Pobg/8eTTzP882D0V14EEt9fTr1cv+w==) \ No newline at end of file diff --git a/src/test/java/com/campus/campus/CampusApplicationTests.java b/src/test/java/com/campus/campus/CampusApplicationTests.java index 42c82f9..ca1511c 100644 --- a/src/test/java/com/campus/campus/CampusApplicationTests.java +++ b/src/test/java/com/campus/campus/CampusApplicationTests.java @@ -9,6 +9,9 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import com.oracle.bmc.objectstorage.ObjectStorage; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @@ -19,6 +22,9 @@ class CampusApplicationTests { @Autowired TestRestTemplate restTemplate; + @MockitoBean + ObjectStorage objectStorage; + @Test void healthCheck() { ResponseEntity response = restTemplate.getForEntity("http://localhost:" + port + "/actuator/health", diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index cb151dd..ec35c40 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -40,6 +40,16 @@ jwt: refresh: secret: /QuR112ZbzLQwelZSdb1pJWutiBYEbjRmxf4AyGcPDq9rtyuDzean8abRxTxvWdvbghF+cMCKAkEF5atidssJA== expiration-seconds: 1209600 +oci: + enabled: false + region: dummy + namespace: dummy + bucket-name: dummy + tenancy-ocid: dummy + user-ocid: dummy + fingerprint: dummy + private-key: dummy + passphrase: "" server-uri: http://localhost