diff --git a/backend/.gitignore b/backend/.gitignore index ebea50a63..639bf54f5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -38,4 +38,4 @@ out/ application.properties -moadong.json \ No newline at end of file +moadong.json diff --git a/backend/build.gradle b/backend/build.gradle index f67b53e51..205680c18 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -43,6 +43,11 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // google drive + implementation 'com.google.api-client:google-api-client:2.0.0' + implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' + implementation 'com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0' } tasks.named('test') { diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index f157b61cf..5e4a70f39 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -15,6 +15,7 @@ public enum ErrorCode { TOO_MANY_FILES(HttpStatus.PAYLOAD_TOO_LARGE, "601-3", "이미지 파일이 최대치보다 많습니다."), IMAGE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "601-4", "이미지 삭제에 실패하였습니다"), KOREAN_FILE_NAME(HttpStatus.INTERNAL_SERVER_ERROR, "601-5", "파일명의 한국어를 인코딩할 수 없습니다."), + FILE_TRANSFER_ERROR(HttpStatus.BAD_REQUEST, "601-6", "파일을 올바른 형식으로 변경할 수 없습니다."), USER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "700-1","이미 존재하는 계정입니다."), USER_NOT_EXIST(HttpStatus.BAD_REQUEST, "700-2","존재하지 않는 계정입니다."), USER_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "700-3","올바르지 않은 유저 형식입니다."), diff --git a/backend/src/main/java/moadong/gcs/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java similarity index 92% rename from backend/src/main/java/moadong/gcs/controller/ClubImageController.java rename to backend/src/main/java/moadong/media/controller/ClubImageController.java index 3eab3e8be..577f995ec 100644 --- a/backend/src/main/java/moadong/gcs/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -1,13 +1,12 @@ -package moadong.gcs.controller; +package moadong.media.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import moadong.gcs.domain.FileType; -import moadong.gcs.dto.FeedUpdateRequest; -import moadong.gcs.service.ClubImageService; +import moadong.media.dto.FeedUpdateRequest; import moadong.global.payload.Response; +import moadong.media.service.ClubImageService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -46,7 +45,6 @@ public ResponseEntity deleteLogo(@PathVariable String clubId) { return Response.ok("success delete logo"); } - // TODO : Signed URL 을 통한 업로드로 추후 변경 @PostMapping(value = "/{clubId}/feed", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "피드 이미지 업로드", description = "피드에 사용할 이미지를 업로드하고 주소를 반환받습니다.") public ResponseEntity uploadFeed(@PathVariable String clubId, @RequestPart("feed") MultipartFile file) { diff --git a/backend/src/main/java/moadong/gcs/domain/FileType.java b/backend/src/main/java/moadong/media/domain/FileType.java similarity index 89% rename from backend/src/main/java/moadong/gcs/domain/FileType.java rename to backend/src/main/java/moadong/media/domain/FileType.java index ef41ae9c3..910cd6422 100644 --- a/backend/src/main/java/moadong/gcs/domain/FileType.java +++ b/backend/src/main/java/moadong/media/domain/FileType.java @@ -1,4 +1,4 @@ -package moadong.gcs.domain; +package moadong.media.domain; import lombok.Getter; diff --git a/backend/src/main/java/moadong/gcs/dto/FeedUpdateRequest.java b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java similarity index 75% rename from backend/src/main/java/moadong/gcs/dto/FeedUpdateRequest.java rename to backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java index dc16934bf..6a920d24f 100644 --- a/backend/src/main/java/moadong/gcs/dto/FeedUpdateRequest.java +++ b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java @@ -1,4 +1,4 @@ -package moadong.gcs.dto; +package moadong.media.dto; import java.util.List; diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java new file mode 100644 index 000000000..857dfa9e2 --- /dev/null +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -0,0 +1,19 @@ +package moadong.media.service; + +import java.util.List; +import moadong.club.entity.Club; +import org.springframework.web.multipart.MultipartFile; + +public interface ClubImageService { + + String uploadLogo(String clubId, MultipartFile file); + + void deleteLogo(String clubId); + + String uploadFeed(String clubId, MultipartFile file); + + void updateFeeds(String clubId, List newFeedImageList); + + void deleteFile(Club club, String filePath); + +} diff --git a/backend/src/main/java/moadong/gcs/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/GcsClubImageService.java similarity index 92% rename from backend/src/main/java/moadong/gcs/service/ClubImageService.java rename to backend/src/main/java/moadong/media/service/GcsClubImageService.java index 99f5c774b..f58953c33 100644 --- a/backend/src/main/java/moadong/gcs/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/GcsClubImageService.java @@ -1,37 +1,39 @@ -package moadong.gcs.service; +package moadong.media.service; + +import static moadong.media.util.ClubImageUtil.containsKorean; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Storage; -import java.text.Normalizer; import java.util.List; -import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import moadong.club.entity.Club; import moadong.club.repository.ClubRepository; -import moadong.gcs.domain.FileType; import moadong.global.exception.ErrorCode; import moadong.global.exception.RestApiException; import moadong.global.util.ObjectIdConverter; import moadong.global.util.RandomStringUtil; +import moadong.media.domain.FileType; import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -@Service +//@Service @RequiredArgsConstructor -public class ClubImageService { +public class GcsClubImageService implements ClubImageService { private final ClubRepository clubRepository; @Value("${google.cloud.storage.bucket.name}") private String bucketName; - private final Storage storage; - private final int MAX_FEED_COUNT = 5; + @Value("${server.feed.max-count}") + private int MAX_FEED_COUNT; + private final Storage storage; + @Override public String uploadLogo(String clubId, MultipartFile file) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) @@ -47,6 +49,7 @@ public String uploadLogo(String clubId, MultipartFile file) { return filePath; } + @Override public void deleteLogo(String clubId) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) @@ -57,6 +60,7 @@ public void deleteLogo(String clubId) { } } + @Override public String uploadFeed(String clubId, MultipartFile file) { ObjectId objectId = ObjectIdConverter.convertString(clubId); int feedImagesCount = clubRepository.findClubById(objectId) @@ -69,6 +73,7 @@ public String uploadFeed(String clubId, MultipartFile file) { return uploadFile(clubId, file, FileType.FEED); } + @Override public void updateFeeds(String clubId, List newFeedImageList) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) @@ -94,7 +99,6 @@ private void deleteFeedImages(Club club, List feedImages, List n } } - // TODO : Signed URL 을 통한 업로드 URL 반환으로 추후 변경 private String uploadFile(String clubId, MultipartFile file, FileType fileType) { if (file == null) { throw new RestApiException(ErrorCode.FILE_NOT_FOUND); @@ -110,6 +114,7 @@ private String uploadFile(String clubId, MultipartFile file, FileType fileType) return "https://storage.googleapis.com/" + bucketName + "/" + blobInfo.getName(); } + @Override public void deleteFile(Club club, String filePath) { // 삭제할 파일의 BlobId를 생성 BlobId blobId = BlobId.of(bucketName,splitPath(filePath)); @@ -152,9 +157,4 @@ private String splitPath(String path) { return path.split("/",5)[4]; } - private boolean containsKorean(String text) { - text = Normalizer.normalize(text, Normalizer.Form.NFC); - return Pattern.matches(".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*", text); - } - } diff --git a/backend/src/main/java/moadong/media/service/GoogleDriveClubImageService.java b/backend/src/main/java/moadong/media/service/GoogleDriveClubImageService.java new file mode 100644 index 000000000..147e17349 --- /dev/null +++ b/backend/src/main/java/moadong/media/service/GoogleDriveClubImageService.java @@ -0,0 +1,171 @@ +package moadong.media.service; + +import static moadong.media.util.ClubImageUtil.containsKorean; + +import com.google.api.client.http.FileContent; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.Permission; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.global.util.ObjectIdConverter; +import moadong.global.util.RandomStringUtil; +import moadong.media.domain.FileType; +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class GoogleDriveClubImageService implements ClubImageService { + + @Value("${google.drive.share-file-id}") + String shareFileId; + @Value("${server.feed.max-count}") + private int MAX_FEED_COUNT; + + private final Drive googleDrive; + private final ClubRepository clubRepository; + + @Override + public String uploadLogo(String clubId, MultipartFile file) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + Club club = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + + String filePath = uploadFile(clubId, file, FileType.LOGO); + club.updateLogo(filePath); + clubRepository.save(club); + return filePath; + } + + @Override + public void deleteLogo(String clubId) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + Club club = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + club.updateLogo(null); + clubRepository.save(club); + } + + @Override + public String uploadFeed(String clubId, MultipartFile file) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + int feedImagesCount = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)) + .getClubRecruitmentInformation().getFeedImages().size(); + + if (feedImagesCount + 1 > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + return uploadFile(clubId, file, FileType.FEED); + } + + @Override + public void updateFeeds(String clubId, List newFeedImageList) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + Club club = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (newFeedImageList.size() > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + + List feedImages = club.getClubRecruitmentInformation().getFeedImages(); + if (feedImages != null && !feedImages.isEmpty()) { + deleteFeedImages(club, feedImages, newFeedImageList); + } + club.updateFeedImages(newFeedImageList); + clubRepository.save(club); + } + + private void deleteFeedImages(Club club, List feedImages, List newFeedImages) { + for (String feedsImage : feedImages) { + if (!newFeedImages.contains(feedsImage)) { + deleteFile(club, feedsImage); + } + } + } + + @Override + public void deleteFile(Club club, String filePath) { + //"https://drive.google.com/file/d/{fileId}/view" -> {fileId} + String fileId = filePath.split("/")[5]; + try { + googleDrive.files() + .delete(fileId) + .setSupportsAllDrives(true) // 공유 드라이브(Shared Drive)도 지원할 경우 + .execute(); + } catch (IOException e) { + throw new RestApiException(ErrorCode.IMAGE_DELETE_FAILED); + } + } + + private String uploadFile(String clubId, MultipartFile file, FileType fileType) { + if (file == null) { + throw new RestApiException(ErrorCode.FILE_NOT_FOUND); + } + // MultipartFile → java.io.File 변환 + java.io.File tempFile; + try { + tempFile = java.io.File.createTempFile("upload-", file.getOriginalFilename()); + file.transferTo(tempFile); + } catch (IOException e) { + throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR); + } + + // 메타데이터 생성 + File fileMetadata = new File(); + String fileName = file.getOriginalFilename(); + if (containsKorean(fileName)) { + fileName = RandomStringUtil.generateRandomString(10); + } + + fileMetadata.setName(clubId + "/" + fileType + "/" + fileName); + fileMetadata.setMimeType(file.getContentType()); + // 공유 ID 설정 + fileMetadata.setParents(Collections.singletonList(shareFileId)); + + // 파일 업로드 + FileContent mediaContent = new FileContent(file.getContentType(), tempFile); + // 전체 공개 권한 설정 + Permission publicPermission = new Permission() + .setType("anyone") // 누구나 + .setRole("reader"); // 읽기 권한 + + File uploadedFile; + try { + uploadedFile= googleDrive.files().create(fileMetadata, mediaContent) + .setFields("id") + .execute(); + + googleDrive.permissions().create(uploadedFile.getId(), publicPermission) + .setFields("id") + .execute(); + } catch (Exception e) { + throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED); + }finally { + // 임시 파일 삭제 + tempFile.delete(); + } + // 공유 링크 반환 + String publicUrl = "https://drive.google.com/file/d/" + uploadedFile.getId() + "/view"; + return publicUrl; + } + +} diff --git a/backend/src/main/java/moadong/media/util/ClubImageUtil.java b/backend/src/main/java/moadong/media/util/ClubImageUtil.java new file mode 100644 index 000000000..22521f8db --- /dev/null +++ b/backend/src/main/java/moadong/media/util/ClubImageUtil.java @@ -0,0 +1,12 @@ +package moadong.media.util; + +import java.text.Normalizer; +import java.util.regex.Pattern; + +public class ClubImageUtil { + + public static boolean containsKorean(String text) { + text = Normalizer.normalize(text, Normalizer.Form.NFC); + return Pattern.matches(".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*", text); + } +} diff --git a/backend/src/main/java/moadong/media/util/GoogleDriveConfig.java b/backend/src/main/java/moadong/media/util/GoogleDriveConfig.java new file mode 100644 index 000000000..ad0bc5fff --- /dev/null +++ b/backend/src/main/java/moadong/media/util/GoogleDriveConfig.java @@ -0,0 +1,43 @@ +package moadong.media.util; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import java.io.InputStream; +import java.util.Collections; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.ResourceUtils; + +@Configuration +public class GoogleDriveConfig { + + @Value("${spring.cloud.gcp.credentials.location}") + private String credentialsLocation; + + @Value("${google.application.name}") + private String applicationName; + + @Bean + public Drive googleDriveService() throws Exception { + InputStream in = ResourceUtils.getURL(credentialsLocation).openStream(); + GoogleCredentials credentials = GoogleCredentials.fromStream(in) + .createScoped(Collections.singleton(DriveScopes.DRIVE)); + + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + JacksonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(credentials)) + .setApplicationName(applicationName) + .build(); + + } + + +}