diff --git a/build.gradle b/build.gradle index f0e18d8e..8a4623d3 100644 --- a/build.gradle +++ b/build.gradle @@ -77,7 +77,6 @@ dependencies { // Spring AI implementation "org.springframework.ai:spring-ai-starter-model-openai" - // 크롤링 implementation("org.jsoup:jsoup:1.21.2") @@ -87,6 +86,12 @@ dependencies { // Mysql driver implementation 'mysql:mysql-connector-java:8.0.33' + // AWS SDK for S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.0' + + // Playwright for Java + implementation 'com.microsoft.playwright:playwright:1.54.0' + } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 4930ded8..0ab283d2 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -33,6 +33,8 @@ public String main() { String kakaoLoginUrl = "/oauth2/authorization/kakao"; String googleLoginUrl = "/oauth2/authorization/google"; String logoutUrl = "/api/v1/auth/logout"; + String testS3UploadUrl = "/test/upload-file"; + String testThumbnailUrl = "/test/generate-thumbnail"; return """

API 서버

@@ -56,6 +58,23 @@ public String main() { - """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl); + +

S3 파일 업로드 테스트

+
+ +

+ +

+ +
+ +
+

썸네일 생성 테스트

+
+ + + +
+ """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl, testS3UploadUrl, testThumbnailUrl); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java index 8944a7fa..5309da40 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java @@ -84,7 +84,8 @@ public RsData getMyInvites( List invitationInfos = invitations.stream() .map(membership -> new SpaceMembershipInfoWithoutAuthority( membership.getSpace().getId(), - membership.getSpace().getName() + membership.getSpace().getName(), + membership.getSpace().getThumbnailUrl() )) .toList(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java index a39a6b09..9d5b9bc3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java @@ -1,5 +1,7 @@ package org.tuna.zoopzoop.backend.domain.space.membership.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; @@ -12,10 +14,12 @@ public interface MembershipRepository extends JpaRepository { boolean existsByMemberAndSpace(Member member, Space space); - List findAllByMemberAndAuthority(Member member, Authority authority); + Page findAllByMemberAndAuthority(Member member, Authority authority, Pageable pageable); + Page findAllByMemberAndAuthorityIsNot(Member member, Authority authority, Pageable pageable); + Page findAllByMember(Member member, Pageable pageable); + List findAllByMemberAndAuthority(Member member, Authority authority); List findAllByMemberAndAuthorityIsNot(Member member, Authority authority); - List findAllByMember(Member member); boolean existsByMemberAndSpaceAndAuthorityIsNot(Member member, Space space, Authority authority); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java index 0d646f5c..d296384a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java @@ -5,6 +5,8 @@ import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -50,6 +52,22 @@ public Membership findByMemberAndSpace(Member member, Space space) { .orElseThrow(() -> new NoResultException("해당 멤버는 스페이스에 속해있지 않습니다.")); } + /** + * 멤버가 속한 스페이스 목록 조회 + * @param member 조회할 멤버 + * @param state 멤버의 가입 상태로 필터링 (PENDING, JOINED, ALL) + * @return 멤버가 속한 스페이스 목록 + */ + public Page findByMember(Member member, String state, Pageable pageable) { + if (state.equalsIgnoreCase("PENDING")) { + return membershipRepository.findAllByMemberAndAuthority(member, Authority.PENDING, pageable); + } else if (state.equalsIgnoreCase("JOINED")) { + return membershipRepository.findAllByMemberAndAuthorityIsNot(member, Authority.PENDING, pageable); + } else { + return membershipRepository.findAllByMember(member, pageable); + } + } + /** * 멤버가 속한 스페이스 목록 조회 * @param member 조회할 멤버 diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java index 0057a3e8..7e00a289 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -4,16 +4,24 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; import org.tuna.zoopzoop.backend.domain.space.space.dto.req.ReqBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceInfo; import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceList; import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceListPage; import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; @@ -22,6 +30,7 @@ import java.nio.file.AccessDeniedException; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController @@ -99,33 +108,49 @@ public RsData updateSpaceName( ); } + @PutMapping(path = "/thumbnail/{spaceId}", consumes = {"multipart/form-data"}) + @Operation(summary = "스페이스 썸네일 이미지 갱신") + public RsData updateSpaceThumbnail( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId, + @RequestPart(value = "image", required = false) MultipartFile image + ) { + Member member = userDetails.getMember(); + + spaceService.updateSpaceThumbnail(spaceId, member, image); + + return new RsData<>( + "200", + "스페이스 썸네일 이미지가 갱신됐습니다.", + null + ); + } + @GetMapping - @Operation(summary = "스페이스 목록 조회") - public RsData getAllSpaces( + @Operation(summary = "나의 스페이스 목록 조회") + public RsData getAllSpaces( @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestParam(required = false) JoinState state + @RequestParam(required = false) JoinState state, + @PageableDefault(size = 10, sort = "createDate", direction = Sort.Direction.DESC) Pageable pageable ) { // 현재 로그인한 사용자 정보 가져오기 Member member = userDetails.getMember(); // 멤버가 속한 스페이스 목록 조회 - List memberships; - if (state == null) { - memberships = membershipService.findByMember(member, "ALL"); - } - else { - memberships = membershipService.findByMember(member, state.name()); - } - - // 반환 값 생성 - List spaceInfos = memberships.stream() - .map(membership -> new SpaceMembershipInfo( - membership.getSpace().getId(), - membership.getSpace().getName(), - membership.getAuthority() - )) - .collect(Collectors.toList()); - ResBodyForSpaceList resBody = new ResBodyForSpaceList(spaceInfos); + String stateStr = (state == null) ? "ALL" : state.name(); + Page membershipsPage = membershipService.findByMember(member, stateStr, pageable); + + // Page를 Page로 변환 + // Page 객체의 map() 메서드를 사용하면 페이징 정보는 그대로 유지하면서 내용물만 쉽게 바꿀 수 있습니다. + Page spaceInfosPage = membershipsPage.map(membership -> new SpaceMembershipInfo( + membership.getSpace().getId(), + membership.getSpace().getName(), + membership.getSpace().getThumbnailUrl(), + membership.getAuthority() + )); + + // 새로운 응답 DTO 생성 + ResBodyForSpaceListPage resBody = new ResBodyForSpaceListPage(spaceInfosPage); return new RsData<>( "200", @@ -134,6 +159,32 @@ public RsData getAllSpaces( ); } + @GetMapping("/{spaceId}") + @Operation(summary = "스페이스 단건 조회") + public RsData getSpace( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId + ) { + Member member = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + // 해당 스페이스에 속한 멤버인지 확인 + Membership membership = membershipService.findByMemberAndSpace(member, space); + + ResBodyForSpaceInfo resBody = new ResBodyForSpaceInfo( + space.getId(), + space.getName(), + space.getThumbnailUrl(), + membership.getAuthority().name(), + space.getSharingArchive().getId() + ); + + return new RsData<>( + "200", + String.format("%s - 스페이스가 조회됐습니다.", space.getName()), + resBody + ); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java index 83de0386..1ee8ec16 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java @@ -5,6 +5,7 @@ public record SpaceMembershipInfo( Integer id, String name, + String thumbnailUrl, Authority authority ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java index a27b2624..f5452b40 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java @@ -2,6 +2,7 @@ public record SpaceMembershipInfoWithoutAuthority( Integer id, - String name + String name, + String thumbnailUrl ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java new file mode 100644 index 00000000..c143b926 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; + +public record ResBodyForSpaceInfo ( + Integer spaceId, + String spaceName, + String thumbnailUrl, + String userAuthority, + Integer sharingArchiveId +){ +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java new file mode 100644 index 00000000..4e3f1dd4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java @@ -0,0 +1,26 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; + +import lombok.Getter; +import org.springframework.data.domain.Page; +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo; + +import java.util.List; + +@Getter +public class ResBodyForSpaceListPage { + private final List spaces; // 현재 페이지의 데이터 + private final int page; // 현재 페이지 번호 (0부터 시작) + private final int size; // 페이지 크기 + private final long totalElements; // 전체 요소 수 + private final int totalPages; // 전체 페이지 수 + private final boolean isLast; // 마지막 페이지 여부 + + public ResBodyForSpaceListPage(Page page) { + this.spaces = page.getContent(); + this.page = page.getNumber(); + this.size = page.getSize(); + this.totalElements = page.getTotalElements(); + this.totalPages = page.getTotalPages(); + this.isLast = page.isLast(); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java index 1b03349a..5d0db6ea 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java @@ -26,6 +26,9 @@ public class Space extends BaseEntity { @OneToOne(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) private SharingArchive sharingArchive; + @Column(nullable = true) + private String thumbnailUrl; + //연결된 MemberShip //Space 삭제시 cascade.all @OneToMany(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) @@ -36,11 +39,12 @@ public Space() { } @Builder - public Space(String name, Boolean active) { + public Space(String name, Boolean active, String thumbnailUrl) { this.name = name; - if (active != null) this.active = active; + if( thumbnailUrl != null) + this.thumbnailUrl = thumbnailUrl; this.sharingArchive = new SharingArchive(this); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java index 0073949f..35d2658a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java @@ -3,17 +3,25 @@ import jakarta.persistence.NoResultException; import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; +import org.hibernate.service.spi.ServiceException; import org.hibernate.validator.constraints.Length; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; +import org.tuna.zoopzoop.backend.global.aws.S3Service; @Service @RequiredArgsConstructor public class SpaceService { private final SpaceRepository spaceRepository; + private final S3Service s3Service; + private final MembershipService membershipService; // ======================== 스페이스 조회 ======================== // @@ -23,6 +31,7 @@ public class SpaceService { * @return 조회된 스페이스 * @throws NoResultException 스페이스가 존재하지 않을 경우 */ + @Transactional(readOnly = true) public Space findById(Integer spaceId) { return spaceRepository.findById(spaceId) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -34,6 +43,7 @@ public Space findById(Integer spaceId) { * @return 조회된 스페이스 * @throws NoResultException 스페이스가 존재하지 않을 경우 */ + @Transactional(readOnly = true) public Space findByName(String name) { return spaceRepository.findByName(name) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -46,6 +56,7 @@ public Space findByName(String name) { * @param name 스페이스 이름 * @return 생성된 스페이스 */ + @Transactional public Space createSpace(@NotBlank @Length(max = 50) String name) { Space newSpace = Space.builder() .name(name) @@ -60,12 +71,35 @@ public Space createSpace(@NotBlank @Length(max = 50) String name) { } } + /** + * 스페이스 생성 + * @param name 스페이스 이름 + * @param thumbnailUrl 스페이스 썸네일 이미지 URL + * @return 생성된 스페이스 + */ + @Transactional + public Space createSpace(@NotBlank @Length(max = 50) String name, String thumbnailUrl) { + Space newSpace = Space.builder() + .name(name) + .thumbnailUrl(thumbnailUrl) + .build(); + + try{ + return spaceRepository.save(newSpace); + }catch (DataIntegrityViolationException e) { + throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다."); + } catch (Exception e) { + throw e; + } + } + /** * 스페이스 삭제 (hard delete) * @param spaceId 스페이스 ID * @return 삭제된 스페이스 이름 * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 */ + @Transactional public String deleteSpace(Integer spaceId) { Space space = spaceRepository.findById(spaceId) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -84,6 +118,7 @@ public String deleteSpace(Integer spaceId) { * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 * @throws DuplicateSpaceNameException 새로운 스페이스 이름이 중복될 경우 */ + @Transactional public Space updateSpaceName(Integer spaceId, @NotBlank @Length(max = 50) String name) { Space space = spaceRepository.findById(spaceId) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -98,4 +133,49 @@ public Space updateSpaceName(Integer spaceId, @NotBlank @Length(max = 50) String throw e; } } + + /** + * 스페이스 썸네일 이미지 변경 + * @param spaceId 스페이스 ID + * @param image 새로운 썸네일 이미지 + * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 + */ + @Transactional + public void updateSpaceThumbnail(Integer spaceId, Member requester, MultipartFile image) { + // 이미지가 null이거나 비어있는 경우 예외 처리 + if(image == null || image.isEmpty()) { + return; + } + + // 파일 크기 제한 (5MB) + if (image.getSize() > (5 * 1024 * 1024)) // 5MB + throw new IllegalArgumentException("이미지 파일 크기는 5MB를 초과할 수 없습니다."); + + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + + if (requester == null) { + throw new IllegalArgumentException("사용자 정보가 없습니다."); + } + + if (!membershipService.isMemberJoinedSpace(requester, space)) { + throw new IllegalArgumentException("스페이스의 구성원이 아닙니다."); + } + + try { + //String fileName = "space/" + spaceId + "/thumbnail/" + System.currentTimeMillis() + "_" + + // S3 저장 시 파일 이름 고정 (덮어쓰기) + String fileName = "space-thumbnail/space_" + spaceId ; + String baseImageUrl = s3Service.upload(image, fileName); + + // DB 용으로 현재 시간을 쿼리 파라미터에 추가 (캐시 무효화) + String finalImageUrl = baseImageUrl + "?v=" + System.currentTimeMillis(); + + // DB 갱신 + space.setThumbnailUrl(finalImageUrl); + spaceRepository.save(space); + } catch (Exception e) { + throw new RuntimeException("스페이스 썸네일 이미지 업로드에 실패했습니다."); + } + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java new file mode 100644 index 00000000..c372609b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java @@ -0,0 +1,70 @@ +package org.tuna.zoopzoop.backend.global.aws; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class S3Service { + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + /** + * S3에 파일 업로드 메서드 + * @param multipartFile 업로드할 파일 + * @param fileName S3에 저장될 파일 이름 + * @return 업로드된 파일의 URL 주소 + * @throws IOException 파일 처리 중 발생할 수 있는 예외 + */ + public String upload(MultipartFile multipartFile, String fileName) throws IOException { + // 1. PutObjectRequest 객체 생성 (빌더 패턴 사용) + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(multipartFile.getContentType()) + .build(); + + // 2. S3에 파일 업로드 (InputStream을 직접 사용) + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize())); + + // 3. 업로드된 파일의 URL 주소 반환 + return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fileName)).toString(); + } + + /** + * S3에 파일 업로드 (byte[])💡 + * @param bytes 업로드할 파일의 바이트 배열 + * @param fileName S3에 저장될 파일 이름 + * @param contentType 파일의 MIME 타입 (e.g., "image/png") + * @return 업로드된 파일의 URL + */ + public String upload(byte[] bytes, String fileName, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(contentType) + .contentLength((long) bytes.length) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes)); + + return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fileName)).toString(); + } + + /** + * S3에서 파일 삭제 메서드 + * @param fileName 삭제할 파일 이름 + */ + public void delete(String fileName) { + s3Client.deleteObject(builder -> builder.bucket(bucket).key(fileName).build()); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java new file mode 100644 index 00000000..f37b241a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java @@ -0,0 +1,42 @@ +package org.tuna.zoopzoop.backend.global.aws; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +import static org.springframework.http.MediaType.TEXT_HTML_VALUE; + +@RestController +@RequiredArgsConstructor +public class S3TestController { + private final S3Service s3Service; + + @PostMapping(value = "/test/upload-file", produces = TEXT_HTML_VALUE) + @Operation(summary = "S3 파일 업로드 테스트") + public String uploadFile(@RequestParam("fileName") String fileName, + @RequestParam("file") MultipartFile file) { + try { + String fileUrl = s3Service.upload(file, fileName); + return """ +

업로드 성공! ✅

+

파일명: %s

+

업로드된 URL: %s

+
+ 메인으로 돌아가기 + """.formatted(fileName, fileUrl, fileUrl); + } catch (IOException e) { + // e.printStackTrace(); // 실제 운영 환경에서는 로그를 남기는 것이 좋습니다. + return """ +

업로드 실패 ❌

+

오류: %s

+
+ 메인으로 돌아가기 + """.formatted(e.getMessage()); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java new file mode 100644 index 00000000..b573bbc6 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java @@ -0,0 +1,21 @@ +package org.tuna.zoopzoop.backend.global.headlessBrowser; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") // "/test" 라는 공통 경로를 사용 +@RequiredArgsConstructor +public class TestThumbnailController { + private final ThumbnailGeneratorService thumbnailGeneratorService; + + @GetMapping("/generate-thumbnail") // GET /test/generate-thumbnail 요청을 처리 + public String testGenerateThumbnail() { + // 테스트 목적으로 workspaceId는 임의의 값(예: 1)을 사용합니다. + thumbnailGeneratorService.generateAndUploadThumbnail(1); + + return "썸네일 생성 및 업로드 요청을 보냈습니다. 서버 로그와 S3를 확인해주세요."; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java new file mode 100644 index 00000000..94ddd79d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java @@ -0,0 +1,50 @@ +package org.tuna.zoopzoop.backend.global.headlessBrowser; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.global.aws.S3Service; + +@Service +@RequiredArgsConstructor +public class ThumbnailGeneratorService { + private final S3Service s3Service; + + @Async // 비동기 실행 + public void generateAndUploadThumbnail(Integer workspaceId) { + try (Playwright playwright = Playwright.create()) { + Browser browser = playwright.chromium().launch(); // Chromium 브라우저 실행 + Page page = browser.newPage(); + + // 1. 썸네일 생성용 내부 URL로 이동 + // (인증을 위한 임시 토큰 등을 쿼리 파라미터로 추가할 수 있습니다.) + //String thumbnailUrl = "http://localhost:8080/internal/render/workspace/" + workspaceId + "?auth_token=TEMP_TOKEN"; + String thumbnailUrl = "https://www.naver.com"; // 테스트용 URL + page.navigate(thumbnailUrl); + + // 2. 대시보드 컨텐츠가 모두 로드될 때까지 대기 + //page.waitForSelector("#dashboard-container"); // 대시보드 컨테이너의 CSS 선택자 + page.waitForSelector("#header"); + + // 3. 특정 요소만 스크린샷으로 찍기 + //Locator dashboardElement = page.locator("#dashboard- + Locator dashboardElement = page.locator("#header"); + byte[] screenshotBytes = dashboardElement.screenshot(); + + // 4. S3에 업로드 + // 파일 이름은 유니크하게 설정 (e.g., workspace_1_thumbnail.png) + //String fileName = "thumbnails/workspace_" + workspaceId + ".png"; + String fileName = "thumbnails/test_thumbnail.png"; // 테스트용 파일 이름 + String s3Url = s3Service.upload(screenshotBytes, fileName, "image/png"); + + browser.close(); + } catch (Exception e) { + // 에러 처리 로직 + e.printStackTrace(); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index 8b60296f..8e4e08a8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -12,6 +12,7 @@ import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; @Configuration diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 93eb25bc..29d81c91 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/oauth/**", "/webjars/**", "/api/v1/**", // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. + "/test/**", // 테스트용으로 모두 허용. 차후 삭제 필요. "/actuator/health" // health 체크용 ).permitAll() .anyRequest().authenticated() diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template index 040cd1e8..4ff88092 100644 --- a/src/main/resources/application-secrets.yml.template +++ b/src/main/resources/application-secrets.yml.template @@ -28,6 +28,17 @@ spring: token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub + cloud: + aws: + credentials: + access-key: {AWS_ACCESS_KEY} + secret-key: {AWS_SECRET_KEY} + region: + static: {AWS_REGION} + s3: + bucket: {AWS_S3_BUCKET_NAME} + stack: + auto: false naver: client_id: {NAVER_CLIENT_ID} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 60a0d4f7..f6cd5b26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,10 @@ spring: use_sql_comments: true config: import: optional:classpath:application-secrets.yml + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB ai: openai: base-url: https://api.groq.com/openai # 내부 서버를 groq으로 diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java index 88ffb0f4..2c16db5d 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java @@ -44,9 +44,8 @@ void setUp() { } void setUpSpace() { - spaceService.createSpace("기존 스페이스 1_forInviteControllerTest"); - spaceService.createSpace("기존 스페이스 2_forInviteControllerTest"); - + Space space1 = spaceService.createSpace("기존 스페이스 1_forInviteControllerTest", "dummyUrl1"); + Space space2 = spaceService.createSpace("기존 스페이스 2_forInviteControllerTest", "dummyUrl2"); } void setUpMember() { @@ -281,8 +280,10 @@ void getMyInvites_Success() throws Exception { .andExpect(jsonPath("$.data.spaces.length()").value(2)) .andExpect(jsonPath("$.data.spaces[0].id").value(space1.getId())) .andExpect(jsonPath("$.data.spaces[0].name").value(space1.getName())) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value(space1.getThumbnailUrl())) .andExpect(jsonPath("$.data.spaces[1].id").value(space2.getId())) - .andExpect(jsonPath("$.data.spaces[1].name").value(space2.getName())); + .andExpect(jsonPath("$.data.spaces[1].name").value(space2.getName())) + .andExpect(jsonPath("$.data.spaces[1].thumbnailUrl").value(space2.getThumbnailUrl())); } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java index 57d8db11..b270d411 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java @@ -14,6 +14,7 @@ import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; @@ -43,8 +44,8 @@ void setUp() { } void setUpSpace() { - spaceService.createSpace("기존 스페이스 1_forSpaceControllerTest"); - spaceService.createSpace("기존 스페이스 2_forSpaceControllerTest"); + Space space1 = spaceService.createSpace("기존 스페이스 1_forSpaceControllerTest", "thumbnailUrl1"); + Space space2 = spaceService.createSpace("기존 스페이스 2_forSpaceControllerTest", "thumbnailUrl2"); } void setUpMember() { @@ -375,7 +376,7 @@ void modifySpaceName_Fail_NoAdminAuthority() throws Exception { .andExpect(jsonPath("$.data").value(nullValue())); } - // ======================= Read ======================= // + // ======================= Read List ======================= // @Test @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) @@ -401,9 +402,11 @@ void getMySpaces_Success() throws Exception { .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest")) .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value("thumbnailUrl1")) .andExpect(jsonPath("$.data.spaces[1].id").isNumber()) .andExpect(jsonPath("$.data.spaces[1].name").value("기존 스페이스 2_forSpaceControllerTest")) - .andExpect(jsonPath("$.data.spaces[1].authority").value("PENDING")); + .andExpect(jsonPath("$.data.spaces[1].authority").value("PENDING")) + .andExpect(jsonPath("$.data.spaces[1].thumbnailUrl").value("thumbnailUrl2")); } @Test @@ -429,7 +432,8 @@ void getInvitedSpaces_Success() throws Exception { resultActions .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 2_forSpaceControllerTest")) - .andExpect(jsonPath("$.data.spaces[0].authority").value("PENDING")); + .andExpect(jsonPath("$.data.spaces[0].authority").value("PENDING")) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value("thumbnailUrl2")); } @Test @@ -455,7 +459,8 @@ void getJoinedSpaces_Success() throws Exception { resultActions .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest")) - .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")); + .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value("thumbnailUrl1")); } // TODO : Spring Security 설정 이후 테스트 코드 활성화 @@ -489,6 +494,68 @@ void getMySpaces_Fail_InvalidState() throws Exception { ); } + // ======================= Read ======================= // + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 단건 조회 - 성공") + void getSpace_Success() throws Exception { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"); + Integer spaceId = space.getId(); + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performGet(url); + + // Then + expectOk( + resultActions, + "기존 스페이스 1_forSpaceControllerTest - 스페이스가 조회됐습니다." + ); + resultActions + .andExpect(jsonPath("$.data.spaceId").value(spaceId)) + .andExpect(jsonPath("$.data.spaceName").value("기존 스페이스 1_forSpaceControllerTest")) + .andExpect(jsonPath("$.data.thumbnailUrl").value("thumbnailUrl1")) + .andExpect(jsonPath("$.data.userAuthority").value("ADMIN")) + .andExpect(jsonPath("$.data.sharingArchiveId").value(space.getSharingArchive().getId())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 단건 조회 - 실패 : 존재하지 않는 스페이스") + void getSpace_Fail_NotFound() throws Exception { + // Given + Integer spaceId = 9999; // 존재하지 않는 스페이스 ID + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performGet(url); + + // Then + resultActions.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 스페이스입니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc3333", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 단건 조회 - 실패 : 스페이스 멤버가 아닌 사용자") + void getSpace_Fail_NotMember() throws Exception { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"); + Integer spaceId = space.getId(); + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performGet(url); + + // Then + expectNotFound(resultActions, "해당 멤버는 스페이스에 속해있지 않습니다."); + } + + // ======================= TEST DATA FACTORIES ======================== // private String createDefaultSpaceCreateRequestBody() { @@ -500,4 +567,5 @@ private String createDefaultSpaceCreateRequestBody() { } + } \ No newline at end of file