Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
import com.cheeeese.album.exception.code.AlbumErrorCode;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
import com.cheeeese.photo.domain.Photo;
import com.cheeeese.photo.exception.PhotoException;
import com.cheeeese.photo.exception.code.PhotoErrorCode;
import com.cheeeese.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.List;

@Component
@RequiredArgsConstructor
Expand Down Expand Up @@ -69,6 +73,20 @@ public void validateAlbumCapacity(Album album) {
}

public void validateUploadPermission(Album album, User user) { // [NEW]
validateAlbumParticipant(album, user);
}

public void validateDownloadPermission(Album album, User user, List<Photo> photos) {
validateAlbumParticipant(album, user);

boolean existsPhotoInAlbum = photos.stream().allMatch(photo -> photo.getAlbum().getId().equals(album.getId()));

if (!existsPhotoInAlbum) {
throw new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND_IN_ALBUM);
}
}

private void validateAlbumParticipant(Album album, User user) {
validateAlbumExpiration(album);

validateUserBlacklisted(album, user);
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/cheeeese/global/domain/BaseEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ public abstract class BaseEntity {
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;

protected void markUpdated() {
this.updatedAt = LocalDateTime.now();
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/cheeeese/global/util/S3Util.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.cheeeese.global.util;

import java.net.URI;
import java.net.URISyntaxException;

public class S3Util {

public static String extractObjectKey(String imageUrl) {
if (imageUrl == null) {
throw new NullPointerException("image url is null");
}

try {
URI uri = new URI(imageUrl);
String path = uri.getPath();
if (path != null && !path.isBlank()) {
return path.startsWith("/") ? path.substring(1) : path;
}
} catch (URISyntaxException e) {

}
if (imageUrl.startsWith("album/")) {
return imageUrl;
}
return imageUrl;
}

public static String extractFileName(String imageUrl) {
if (imageUrl == null || imageUrl.isBlank()) {
return "unnamed.jpg";
}
String normalized = imageUrl.replace('\\', '/');

int lastSlashIdx = normalized.lastIndexOf('/');
String fileName = (lastSlashIdx >= 0)
? normalized.substring(lastSlashIdx + 1)
: normalized;

int underscoreIdx = fileName.indexOf('_');
if (underscoreIdx >= 0 && underscoreIdx < fileName.length() - 1) {
fileName = fileName.substring(underscoreIdx + 1);
}

return fileName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.cheeeese.global.util.resolver;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class CdnUrlResolver {

@Value("${cdn.original-domain}")
private String originalDomain;

@Value("${cdn.thumbnail-domain}")
private String thumbnailDomain;

@Value("${cdn.4cut-domain}")
private String cutDomain;

public String resolveOriginal(String path) {
return resolve(originalDomain, path);
}

public String resolveThumbnail(String path) {
return resolve(thumbnailDomain, path);
}

public String resolveCut(String path) {
return resolve(cutDomain, path);
}

private String resolve(String domain, String path) {
if (path == null || path.isBlank()) return null;
if (path.startsWith("http")) return path;
if (path.startsWith("/")) path = path.substring(1);
return domain + "/" + path;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.cheeeese.album.domain.type.AlbumSorting;
import com.cheeeese.global.util.RedisCacheUtil;
import com.cheeeese.global.util.resolver.CdnUrlResolver;
import com.cheeeese.photo.domain.Photo;
import com.cheeeese.photo.dto.response.*;
import com.cheeeese.photo.exception.PhotoException;
Expand Down Expand Up @@ -32,6 +33,7 @@ public class PhotoQueryService {
private final PhotoLikesRepository photoLikesRepository;
private final PhotoHistoryRepository photoHistoryRepository;
private final RedisCacheUtil redisCacheUtil;
private final CdnUrlResolver cdnUrlResolver;

private static final String PHOTO_KEY = "album:%s:photos:page:%d:version:%d";
private static final String VERSION_KEY = "album:%s:version";
Expand Down Expand Up @@ -94,19 +96,22 @@ public PhotoDetailResponse getPhotoDetail(User user, String code, Long photoId)
Photo photo = photoRepository.findByIdAndAlbum_Code(photoId, code)
.orElseThrow(() -> new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND));

String resolveOriginalUrl = cdnUrlResolver.resolveOriginal(photo.getImageUrl());
String resolveThumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl());

boolean isLiked = photoLikesRepository.existsByUserIdAndPhotoId(user.getId(), photo.getId());
boolean isDownloaded = photoHistoryRepository.existsByUserIdAndPhotoId(user.getId(), photo.getId());
boolean isRecentlyDownloaded = photoHistoryRepository.existsByUserIdAndPhotoIdAndCreatedAtAfter(
user.getId(), photo.getId(), LocalDateTime.now().minusHours(1)
);

return PhotoMapper.toPhotoDetailResponse(photo, isLiked, isDownloaded, isRecentlyDownloaded);
return PhotoMapper.toPhotoDetailResponse(photo, resolveOriginalUrl, resolveThumbnailUrl, isLiked, isDownloaded, isRecentlyDownloaded);
}

private PhotoPageResponse getPhotoPageFromDB(String code, int page, int size, AlbumSorting albumSorting) {
PageRequest pageRequest = PageRequest.of(page, size, getPhotoSortingOption(albumSorting));
Slice<Photo> photos = photoRepository.findAllByAlbumCode(code, pageRequest);
return PhotoMapper.toPhotoPageResponse(photos);
return PhotoMapper.toPhotoPageResponse(photos, cdnUrlResolver);
}

private PhotoPageResponse attachUserStatus(User user, PhotoPageResponse response) {
Expand Down Expand Up @@ -162,9 +167,10 @@ private List<PhotoLikedResponse> buildPhotoLikedResponses(List<Photo> photos, Se
return photos.stream()
.map(photo -> {
Long id = photo.getId();
String resolvedThumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl());
boolean isDownloaded = downloaded.contains(id);
boolean isRecentlyDownloaded = recent.contains(id);
return PhotoMapper.toPhotoLikedResponse(photo, isDownloaded, isRecentlyDownloaded);
return PhotoMapper.toPhotoLikedResponse(photo, resolvedThumbnailUrl, isDownloaded, isRecentlyDownloaded);
})
.toList();
}
Expand Down
62 changes: 62 additions & 0 deletions src/main/java/com/cheeeese/photo/application/PhotoService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
import com.cheeeese.album.application.validator.AlbumValidator;
import com.cheeeese.album.domain.Album;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.global.util.S3Util;
import com.cheeeese.photo.application.validator.PhotoValidator;
import com.cheeeese.photo.domain.Photo;
import com.cheeeese.photo.domain.PhotoHistory;
import com.cheeeese.photo.domain.PhotoLikes;
import com.cheeeese.photo.domain.PhotoStatus;
import com.cheeeese.photo.dto.request.PhotoDownloadRequest;
import com.cheeeese.photo.dto.request.PhotoPresignedUrlRequest;
import com.cheeeese.photo.dto.request.PhotoUploadReportRequest;
import com.cheeeese.photo.dto.response.PhotoDownloadResponse;
import com.cheeeese.photo.dto.response.PhotoPresignedUrlResponse;
import com.cheeeese.photo.exception.PhotoException;
import com.cheeeese.photo.exception.code.PhotoErrorCode;
import com.cheeeese.photo.infrastructure.mapper.PhotoHistoryMapper;
import com.cheeeese.photo.infrastructure.mapper.PhotoLikesMapper;
import com.cheeeese.photo.infrastructure.mapper.PhotoMapper;
import com.cheeeese.photo.infrastructure.persistence.PhotoHistoryRepository;
import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository;
import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
import com.cheeeese.user.application.UserService;
Expand All @@ -24,7 +30,9 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
Expand All @@ -35,6 +43,7 @@ public class PhotoService {
private final UserService userService;
private final PhotoRepository photoRepository;
private final PhotoLikesRepository photoLikesRepository;
private final PhotoHistoryRepository photoHistoryRepository;
private final PhotoValidator photoValidator;
private final AlbumValidator albumValidator;
private final AlbumRepository albumRepository;
Expand Down Expand Up @@ -75,6 +84,36 @@ public PhotoPresignedUrlResponse createPresignedUrls(User user, PhotoPresignedUr
return PhotoMapper.toPresignedUrlResponse(presignedUrls);
}

@Transactional
public PhotoDownloadResponse getDownloadPresignedUrls(User user, PhotoDownloadRequest request) {
Album album = validateAlbumAndPermission(user, request.code());

List<Photo> photos = photoRepository.findAllByIdIn(request.photoIds());

albumValidator.validateDownloadPermission(album, user, photos);

Set<Long> recentDownloadIds = photoHistoryRepository.findRecentlyDownloadedPhotoIds(
user.getId(),
request.photoIds(),
LocalDateTime.now().minusHours(1)
);

List<PhotoDownloadResponse.DownloadFileInfo> presignedUrls = generateDownloadPresignedUrls(
photos, recentDownloadIds
);

photos.stream()
.filter(photo -> !recentDownloadIds.contains(photo.getId()))
.forEach(photo -> photoHistoryRepository.findByUserIdAndPhotoId(user.getId(), photo.getId())
.ifPresentOrElse(
PhotoHistory::touch,
() -> photoHistoryRepository.save(PhotoHistoryMapper.toEntity(user, photo))
)
);

return PhotoMapper.toPhotoDownloadResponse(presignedUrls);
}

@Transactional
public void reportUploadResult(User user, PhotoUploadReportRequest request) {
List<Long> failurePhotoIds = request.failurePhotoIds().stream()
Expand Down Expand Up @@ -139,6 +178,15 @@ private List<PhotoPresignedUrlResponse.PresignedUrlInfo> generatePresignedUrls(
.collect(Collectors.toList());
}

private List<PhotoDownloadResponse.DownloadFileInfo> generateDownloadPresignedUrls(
List<Photo> photos,
Set<Long> recentDownloadedIds
) {
return photos.stream()
.map(photo -> createPresignedUrlForDownload(photo, recentDownloadedIds))
.toList();
}

private PhotoPresignedUrlResponse.PresignedUrlInfo createPresignedUrlForFile(
User user,
Album album,
Expand All @@ -162,6 +210,20 @@ private PhotoPresignedUrlResponse.PresignedUrlInfo createPresignedUrlForFile(
return PhotoMapper.toPresignedUrlInfo(photo.getId(), uploadUrl);
}

private PhotoDownloadResponse.DownloadFileInfo createPresignedUrlForDownload(Photo photo, Set<Long> recentDownloadedIds) {
String fileName = S3Util.extractFileName(photo.getImageUrl());

// 1시간 이내 다운로드 O -> null 반환
if (recentDownloadedIds.contains(photo.getId())) {
return PhotoMapper.toDownloadPresignedUrlInfo(photo, fileName, null);
}

String objectKey = S3Util.extractObjectKey(photo.getImageUrl());
String url = presignedUrlService.generatePresignedGetUrl(objectKey);

return PhotoMapper.toDownloadPresignedUrlInfo(photo, fileName, url);
}

private String sanitizeFileName(String raw) {
String name = raw == null ? "unnamed" : raw;
name = name.replace('\\', '/'); // 구분자 통일
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;

import java.time.Duration;
Expand Down Expand Up @@ -33,4 +35,19 @@ public String generatePresignedPutUrl(String uniqueKey, String contentType) {

return presignedRequest.url().toString();
}

public String generatePresignedGetUrl(String uniqueKey) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(uniqueKey)
.build();

PresignedGetObjectRequest presignedRequest =
s3Presigner.presignGetObject(r -> r
.signatureDuration(Duration.ofMinutes(10))
.getObjectRequest(getObjectRequest)
);

return presignedRequest.url().toString();
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/cheeeese/photo/domain/PhotoHistory.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ private PhotoHistory(User user, Photo photo) {
this.user = user;
this.photo = photo;
}

public void touch() {
this.markUpdated();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.cheeeese.photo.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.util.List;

@Builder
@Schema(description = "사진 다운로드 presigned url 발급 API")
public record PhotoDownloadRequest(
@Schema(description = "앨범 코드", example = "1f0b7ea8-fab6-6581-95e3-0720bc07603e")
String code,

@Schema(description = "사진 고유 ID", example = "[1, 2, 3]")
List<Long> photoIds
) {
}
Loading