diff --git a/photo-service/build.gradle.kts b/photo-service/build.gradle.kts index 500fe29..318d900 100644 --- a/photo-service/build.gradle.kts +++ b/photo-service/build.gradle.kts @@ -34,7 +34,7 @@ dependencies { implementation("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") implementation("com.github.f4b6a3:ulid-creator:5.2.3") - + implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4") } tasks.withType { diff --git a/photo-service/src/main/java/kr/mafoo/photo/config/NcpConfig.java b/photo-service/src/main/java/kr/mafoo/photo/config/NcpConfig.java new file mode 100644 index 0000000..0de7d61 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/config/NcpConfig.java @@ -0,0 +1,35 @@ +package kr.mafoo.photo.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class NcpConfig { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Value("${cloud.aws.s3.endpoint}") + private String endPoint; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey,secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endPoint, region)) + .build(); + } +} \ No newline at end of file diff --git a/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java b/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java index 7549765..fd49fbe 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java +++ b/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java @@ -1,8 +1,10 @@ package kr.mafoo.photo.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; @EnableWebFlux @@ -12,4 +14,15 @@ public class WebFluxConfig implements WebFluxConfigurer { public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { configurer.addCustomResolver(new MemberIdParameterResolver()); } + + @Bean("externalWebClient") + public WebClient externalServiceWebClient() { + return WebClient.builder() + .codecs(clientCodecConfigurer -> { + clientCodecConfigurer + .defaultCodecs() + .maxInMemorySize(16 * 1024 * 1024); // 16MB + }) + .build(); + } } diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/PhotoController.java b/photo-service/src/main/java/kr/mafoo/photo/controller/PhotoController.java index 79dad1c..cadc764 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/controller/PhotoController.java +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/PhotoController.java @@ -31,9 +31,9 @@ public Mono createPhoto( String memberId, PhotoCreateRequest request ){ - return Mono.just( - new PhotoResponse("test_photo_id", "photo_url", null) - ); + return photoService + .createNewPhoto(request.qrUrl(), memberId) + .map(PhotoResponse::fromEntity); } @Override diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/PhotoResponse.java b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/PhotoResponse.java index a70a1fa..3de86f7 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/PhotoResponse.java +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/PhotoResponse.java @@ -1,6 +1,7 @@ package kr.mafoo.photo.controller.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import kr.mafoo.photo.domain.BrandType; import kr.mafoo.photo.domain.PhotoEntity; @Schema(description = "사진 응답") @@ -11,6 +12,9 @@ public record PhotoResponse( @Schema(description = "사진 URL", example = "photo_url") String photoUrl, + @Schema(description = "사진 브랜드", example = "LIFE_FOUR_CUTS") + BrandType brand, + @Schema(description = "앨범 ID", example = "test_album_id") String albumId ) { @@ -20,6 +24,7 @@ public static PhotoResponse fromEntity( return new PhotoResponse( entity.getPhotoId(), entity.getPhotoUrl(), + entity.getBrand(), entity.getAlbumId() ); } diff --git a/photo-service/src/main/java/kr/mafoo/photo/domain/BrandType.java b/photo-service/src/main/java/kr/mafoo/photo/domain/BrandType.java new file mode 100644 index 0000000..1e2e7e9 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/domain/BrandType.java @@ -0,0 +1,32 @@ +package kr.mafoo.photo.domain; + +public enum BrandType { + LIFE_FOUR_CUTS("https://api.life4cut.net/"), + PHOTOISM("https://qr.seobuk.kr/"), + HARU_FILM( "http://haru6.mx2.co.kr/"), + DONT_LOOK_UP("https://x.dontlxxkup.kr/"), + ; + + private String urlFormat; + + private BrandType(String urlFormat) { + this.urlFormat = urlFormat; + } + + public String getUrlFormat() { + return urlFormat; + } + + public boolean matches(String qrUrl) { + return qrUrl.startsWith(this.urlFormat); + } + + public static BrandType matchBrandType(String qrUrl) { + for (BrandType brandType : BrandType.values()) { + if (brandType.matches(qrUrl)) { + return brandType; + } + } + return null; + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/domain/PhotoEntity.java b/photo-service/src/main/java/kr/mafoo/photo/domain/PhotoEntity.java index c33fed8..35db5ef 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/domain/PhotoEntity.java +++ b/photo-service/src/main/java/kr/mafoo/photo/domain/PhotoEntity.java @@ -23,6 +23,9 @@ public class PhotoEntity implements Persistable { @Column("url") private String photoUrl; + @Column("brand") + private BrandType brand; + @Column("owner_member_id") private String ownerMemberId; @@ -64,10 +67,11 @@ public PhotoEntity updateAlbumId(String albumId) { return this; } - public static PhotoEntity newPhoto(String photoId, String photoUrl, String ownerMemberId) { + public static PhotoEntity newPhoto(String photoId, String photoUrl, BrandType brandType, String ownerMemberId) { PhotoEntity photo = new PhotoEntity(); photo.photoId = photoId; photo.photoUrl = photoUrl; + photo.brand = brandType; photo.ownerMemberId = ownerMemberId; photo.isNew = true; return photo; diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java b/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java index 5c9d304..063c1b0 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java @@ -6,8 +6,13 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { + + REDIRECT_URI_NOT_FOUND("EX001", "리다이렉트 URI를 찾을 수 없습니다"), + ALBUM_NOT_FOUND("AE0001", "앨범을 찾을 수 없습니다"), PHOTO_NOT_FOUND("PE0001", "사진을 찾을 수 없습니다"), + PHOTO_BRAND_NOT_EXISTS("PE002", "사진 브랜드가 존재하지 않습니다"), + PHOTO_QR_URL_EXPIRED("PE003", "사진 저장을 위한 QR URL이 만료되었습니다"), ; private final String code; diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/PhotoBrandNotExistsException.java b/photo-service/src/main/java/kr/mafoo/photo/exception/PhotoBrandNotExistsException.java new file mode 100644 index 0000000..59baed9 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/PhotoBrandNotExistsException.java @@ -0,0 +1,7 @@ +package kr.mafoo.photo.exception; + +public class PhotoBrandNotExistsException extends DomainException { + public PhotoBrandNotExistsException() { + super(ErrorCode.PHOTO_BRAND_NOT_EXISTS); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/PhotoQrUrlExpiredException.java b/photo-service/src/main/java/kr/mafoo/photo/exception/PhotoQrUrlExpiredException.java new file mode 100644 index 0000000..b29a30c --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/PhotoQrUrlExpiredException.java @@ -0,0 +1,7 @@ +package kr.mafoo.photo.exception; + +public class PhotoQrUrlExpiredException extends DomainException{ + public PhotoQrUrlExpiredException() { + super(ErrorCode.PHOTO_QR_URL_EXPIRED); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/RedirectUriNotFoundException.java b/photo-service/src/main/java/kr/mafoo/photo/exception/RedirectUriNotFoundException.java new file mode 100644 index 0000000..4432609 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/RedirectUriNotFoundException.java @@ -0,0 +1,7 @@ +package kr.mafoo.photo.exception; + +public class RedirectUriNotFoundException extends DomainException { + public RedirectUriNotFoundException() { + super(ErrorCode.REDIRECT_URI_NOT_FOUND); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java b/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java new file mode 100644 index 0000000..8381fd9 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java @@ -0,0 +1,48 @@ +package kr.mafoo.photo.service; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ObjectStorageService { + + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + public Mono uploadFile(byte[] fileByte) { + String keyName = "/" + UUID.randomUUID(); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(fileByte.length); + objectMetadata.setContentType("image/jpeg"); + + return Mono.fromCallable(() -> { + try (InputStream inputStream = new ByteArrayInputStream(fileByte)) { + amazonS3Client.putObject( + new PutObjectRequest(bucketName, keyName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + + return "https://kr.object.ncloudstorage.com/" + bucketName + "/" + keyName; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + +} \ No newline at end of file diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/PhotoService.java b/photo-service/src/main/java/kr/mafoo/photo/service/PhotoService.java index b9d4939..b180b13 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/service/PhotoService.java +++ b/photo-service/src/main/java/kr/mafoo/photo/service/PhotoService.java @@ -1,10 +1,12 @@ package kr.mafoo.photo.service; +import kr.mafoo.photo.domain.BrandType; import kr.mafoo.photo.domain.PhotoEntity; import kr.mafoo.photo.exception.AlbumNotFoundException; import kr.mafoo.photo.exception.PhotoNotFoundException; import kr.mafoo.photo.repository.AlbumRepository; import kr.mafoo.photo.repository.PhotoRepository; +import kr.mafoo.photo.util.IdGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; @@ -16,6 +18,20 @@ public class PhotoService { private final PhotoRepository photoRepository; private final AlbumRepository albumRepository; + private final QrService qrService; + private final ObjectStorageService objectStorageService; + + public Mono createNewPhoto(String qrUrl, String requestMemberId) { + return qrService + .getFileFromQrUrl(qrUrl) + .flatMap(fileDto -> objectStorageService.uploadFile(fileDto.fileByte()) + .flatMap(photoUrl -> { + PhotoEntity photoEntity = PhotoEntity.newPhoto(IdGenerator.generate(), photoUrl, fileDto.type(), requestMemberId); + return photoRepository.save(photoEntity); + }) + ); + } + public Flux findAllByAlbumId(String albumId, String requestMemberId) { return albumRepository .findById(albumId) diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/QrService.java b/photo-service/src/main/java/kr/mafoo/photo/service/QrService.java new file mode 100644 index 0000000..6c1e099 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/QrService.java @@ -0,0 +1,143 @@ +package kr.mafoo.photo.service; + +import kr.mafoo.photo.domain.BrandType; +import kr.mafoo.photo.exception.PhotoBrandNotExistsException; +import kr.mafoo.photo.exception.PhotoQrUrlExpiredException; +import kr.mafoo.photo.exception.RedirectUriNotFoundException; +import kr.mafoo.photo.service.dto.FileDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class QrService { + + private final WebClient externalWebClient; + + public Mono getFileFromQrUrl(String qrUrl) { + BrandType brandType = Optional.ofNullable(BrandType.matchBrandType(qrUrl)) + .orElseThrow(PhotoBrandNotExistsException::new); + + return switch (brandType) { + case LIFE_FOUR_CUTS -> createFileDto(brandType, getLifeFourCutsFiles(qrUrl)); + case PHOTOISM -> createFileDto(brandType, getPhotoismFiles(qrUrl)); + case HARU_FILM -> createFileDto(brandType, getHaruFilmFiles(qrUrl)); + case DONT_LOOK_UP -> createFileDto(brandType, getDontLookUpFiles(qrUrl)); + }; + } + + private Mono createFileDto(BrandType brandType, Mono fileMono) { + return fileMono.map(file -> new FileDto(brandType, file)); + } + + private Mono getLifeFourCutsFiles(String qrUrl) { + + return getRedirectUri(qrUrl) + .flatMap(redirectUri -> { + String imageUrl = redirectUri.replace("index.html", "image.jpg"); + + // TODO : 추후 비디오 URL 추가 예정 + // String videoUrl = redirectUri.toString().replace("index.html", "video.mp4"); + + return getFileAsByte(imageUrl); + }) + .onErrorMap(e -> new PhotoQrUrlExpiredException()); + } + + private Mono getPhotoismFiles(String qrUrl) { + return getRedirectUri(qrUrl) + .flatMap(redirectUri -> { + String uid = extractValueFromUrl(redirectUri, "u="); + + return externalWebClient + .post() + .uri("https://cmsapi.seobuk.kr/v1/etc/seq/resource") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("uid", uid, "appUserId", null)) + .retrieve() + .bodyToMono(LinkedHashMap.class) + .flatMap(responseBody -> { + LinkedHashMap content = (LinkedHashMap) responseBody.get("content"); + LinkedHashMap fileInfo = (LinkedHashMap) content.get("fileInfo"); + String imageUrl = (String) fileInfo.get("picFile.path"); + + return getFileAsByte(imageUrl); + }); + }) + .onErrorMap(e -> new PhotoQrUrlExpiredException()); + } + + private Mono getHaruFilmFiles(String qrUrl) { + String albumCode = extractValueFromUrl(qrUrl, "/@"); + + String baseUrl = "http://haru6.mx2.co.kr/base_api?command=albumdn&albumCode="; + String imageUrl = baseUrl + albumCode + "&type=photo&file_name=output.jpg&max=10&limit=+24 hours"; + + // TODO : 추후 비디오 URL 추가 예정 + // String videoUrl = baseUrl + albumCode + "&type=video&max=10&limit=+24 hours"; + + return getFileAsByte(imageUrl) + .onErrorMap(e -> new PhotoQrUrlExpiredException()); + } + + private Mono getDontLookUpFiles(String qrUrl) { + String imageName = extractValueFromUrl(qrUrl, ".kr/"); + + String baseUrl = "https://x.dontlxxkup.kr/uploads/"; + String imageUrl = baseUrl + imageName; + + // TODO : 추후 비디오 URL 추가 예정 + // String videoName = imageName.replace("image", "video").replace(".jpg", ".mp4"); + // String videoUrl = baseUrl + videoName; + + return getRedirectUri(qrUrl) + .flatMap(redirectUri -> { + if (redirectUri.endsWith("/delete")) { + return Mono.error(new PhotoQrUrlExpiredException()); + } else { + return getFileAsByte(imageUrl); + } + }); + } + + private Mono getRedirectUri(String url) { + return externalWebClient + .get() + .uri(url) + .retrieve() + .toBodilessEntity() + .flatMap(response -> { + URI redirectUri = response.getHeaders().getLocation(); + if (redirectUri == null) { + return Mono.error(new RedirectUriNotFoundException()); + } else { + return Mono.just(redirectUri.toString()); + } + }); + } + + private String extractValueFromUrl(String url, String delimiter) { + return url.split(delimiter)[1]; + } + + private Mono getFileAsByte(String url) { + return externalWebClient + .get() + .uri(url) + .accept(MediaType.APPLICATION_OCTET_STREAM) + .retrieve() + .bodyToMono(byte[].class); + } + + +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/dto/FileDto.java b/photo-service/src/main/java/kr/mafoo/photo/service/dto/FileDto.java new file mode 100644 index 0000000..5dff696 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/dto/FileDto.java @@ -0,0 +1,9 @@ +package kr.mafoo.photo.service.dto; + +import kr.mafoo.photo.domain.BrandType; + +public record FileDto ( + BrandType type, + byte[] fileByte +) { +} diff --git a/photo-service/src/main/resources/application.yaml b/photo-service/src/main/resources/application.yaml index e96c8d7..fd8458b 100644 --- a/photo-service/src/main/resources/application.yaml +++ b/photo-service/src/main/resources/application.yaml @@ -11,3 +11,16 @@ spring: enabled: true user: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} + +cloud: + aws: + credentials: + access-key: ${NCP_ACCESS_KEY} + secret-key: ${NCP_SECRET_KEY} + stack: + auto: false + region: + static: kr-standard + s3: + endpoint: https://kr.object.ncloudstorage.com + bucket: ${NCP_BUCKET} \ No newline at end of file diff --git a/photo-service/src/main/resources/db/migration/V3__addBrandInPhotoTable.sql b/photo-service/src/main/resources/db/migration/V3__addBrandInPhotoTable.sql new file mode 100644 index 0000000..9e5a4b4 --- /dev/null +++ b/photo-service/src/main/resources/db/migration/V3__addBrandInPhotoTable.sql @@ -0,0 +1,2 @@ +ALTER TABLE `photo` + ADD `brand` VARCHAR(255) NOT NULL COMMENT '브랜드' after `url`; \ No newline at end of file