Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f37af7f
#39 feat: 띱을 많이 받은 4개의 사진 조회
dahyun24 Nov 5, 2025
109a5cd
#39 feat: 치즈네컷 조회 성공 추가
dahyun24 Nov 5, 2025
1b822b1
#39 feat: 치즈네컷 엔티티 추가
dahyun24 Nov 5, 2025
3203d06
#39 feat: 치즈네컷 예외 및 에러 코드 추가
dahyun24 Nov 5, 2025
8d9fc68
#39 feat: 치즈네컷 조회 response 구현
dahyun24 Nov 5, 2025
bed2019
#39 feat: 치즈네컷 조회 기능 구현
dahyun24 Nov 5, 2025
82f294a
Merge branch 'develop' into feat/#39-cheese4cut
dahyun24 Nov 5, 2025
719f604
#39 feat: 치즈네컷 조회 api 구현
dahyun24 Nov 5, 2025
febc6c6
#39 feat: 치즈네컷 확정 전 조회시 response에 띱 누른 참여자수 필드 추가
dahyun24 Nov 5, 2025
4431d77
#39 feat: 치즈네컷 PresignedUrl 발급 로직 구현
dahyun24 Nov 7, 2025
0dc9ae4
#39 feat: WHITE_LIST에 preview api 추가
dahyun24 Nov 7, 2025
aa71014
#39 feat: 앨범 만료 전 치즈네컷 확정 api 구현
dahyun24 Nov 7, 2025
de45785
#39 fix: 불필요한 import문 제거
dahyun24 Nov 7, 2025
0217caa
#39 fix: 베스트 컷 4개 정렬 수정
dahyun24 Nov 10, 2025
4a19408
#39 feat: 스케줄러 구현
dahyun24 Nov 10, 2025
f29427f
#39 feat: 7일 후에 자동 만료 후 치즈네컷 생성 로직 구현
dahyun24 Nov 10, 2025
00362d7
Merge branch 'develop' into feat/#39-cheese4cut
dahyun24 Nov 10, 2025
fe31ef7
#39 fix: 코드래빗 피드백 반영
dahyun24 Nov 11, 2025
119e17d
#39 fix: cheese4cut 테이블에 최종 프레임 컬럼 제거
dahyun24 Nov 11, 2025
ffee386
#39 fix: 피드백 반영
dahyun24 Nov 11, 2025
5089da5
#39 fix: 피드백 반영
dahyun24 Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/com/cheeeese/CheeeeseApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableRedisRepositories(basePackages = "com.cheeeese.auth.infrastructure.persistence")
@EnableScheduling
public class CheeeeseApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.cheeeese.album.application;

import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Set;

@Slf4j
@Component
@RequiredArgsConstructor
public class AlbumExpirationScheduler {

private final AlbumExpirationRedisRepository albumExpirationRedisRepository;
private final AlbumExpirationService albumExpirationService;

@Scheduled(fixedDelay = 1000L)
public void handleAlbumExpirations() {
Set<Long> trackedAlbumIds = albumExpirationRedisRepository.getTrackedAlbumIds();

if (trackedAlbumIds.isEmpty()) {
return;
}

for (Long albumId : trackedAlbumIds) {
if (!albumExpirationRedisRepository.isExpired(albumId)) {
continue;
}

try {
albumExpirationService.expireAlbum(albumId);
albumExpirationRedisRepository.unregister(albumId);
} catch (Exception exception) {
log.error("[AlbumExpiration] Failed to process album id={}", albumId, exception);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.cheeeese.album.application;

import com.cheeeese.album.domain.Album;
import com.cheeeese.album.exception.AlbumException;
import com.cheeeese.album.exception.code.AlbumErrorCode;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.cheese4cut.domain.Cheese4cut;
import com.cheeeese.cheese4cut.infrastructure.mapper.Cheese4cutMapper;
import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository;
import com.cheeeese.photo.domain.PhotoStatus;
import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class AlbumExpirationService {

private static final int CHEESE4CUT_PHOTO_COUNT = 4;
// TODO: 최종 프레임 백엔드에서 저장안함에 따라 photo 4장만 저장

private final AlbumRepository albumRepository;
private final PhotoRepository photoRepository;
private final Cheese4cutRepository cheese4cutRepository;

@Transactional
public void expireAlbum(Long albumId) {
Album album = albumRepository.findById(albumId)
.orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND));

if (album.getStatus() != Album.AlbumStatus.EXPIRED) {
album.expire();
log.info("[AlbumExpiration] Album id={} status updated to EXPIRED", albumId);
}

if (cheese4cutRepository.findByAlbumId(albumId).isPresent()) {
return;
}

List<Long> topPhotoIds = photoRepository.findTop4CompletedPhotoIdsByLikes(
albumId,
PhotoStatus.COMPLETED,
PageRequest.of(0, CHEESE4CUT_PHOTO_COUNT)
);

if (topPhotoIds.size() < CHEESE4CUT_PHOTO_COUNT) {
log.warn(
"[AlbumExpiration] Album id={} does not have enough photos to create cheese4cut (found={})",
albumId,
topPhotoIds.size()
);
return;
}

Cheese4cut cheese4cut = Cheese4cutMapper.toEntity(album, topPhotoIds);

cheese4cutRepository.save(cheese4cut);
log.info("[AlbumExpiration] Cheese4cut created automatically for album id={}", albumId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.cheeeese.album.infrastructure.mapper.AlbumMapper;
import com.cheeeese.album.infrastructure.mapper.UserAlbumMapper;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository;
import com.cheeeese.global.security.CustomUserDetails;
import com.cheeeese.photo.application.PhotoService;
import com.cheeeese.album.domain.UserAlbum;
Expand Down Expand Up @@ -48,6 +49,7 @@ public class AlbumService {
private final UserAlbumRepository userAlbumRepository;
private final UserRepository userRepository;
private final PhotoService photoService;
private final AlbumExpirationRedisRepository albumExpirationRedisRepository;

@Transactional
public AlbumCreationResponse createAlbum(User user, AlbumCreationRequest request) {
Expand Down Expand Up @@ -75,6 +77,8 @@ public AlbumCreationResponse createAlbum(User user, AlbumCreationRequest request
Role.MAKER
));

albumExpirationRedisRepository.registerAlbum(album.getId());

return AlbumMapper.toCreationResponse(album);
}

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/cheeeese/album/domain/Album.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ public boolean isExpired() {
return this.expiredAt.isBefore(LocalDateTime.now()) || this.status == AlbumStatus.EXPIRED;
}

public void expire() {
this.status = AlbumStatus.EXPIRED;
}

@Builder
private Album(
Long makerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum AlbumErrorCode implements BaseCode {
ALBUM_INVALID_CAPACITY(HttpStatus.BAD_REQUEST, "앨범 인원은 최소 1명 이상 최대 64명 이하여야 합니다."),
ALBUM_CREATION_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "사용자는 일주일에 최대 3개의 앨범만 생성할 수 있습니다."),
USER_NOT_PARTICIPANT(HttpStatus.FORBIDDEN, "사용자는 해당 앨범의 참가자가 아닙니다."),
USER_NOT_MAKER(HttpStatus.FORBIDDEN, "해당 사용자는 MAKER가 아닙니다.")
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.cheeeese.album.infrastructure.persistence;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class AlbumExpirationRedisRepository {

private static final String TRACKING_KEY = "expired:album:tracking";
private static final Duration ALBUM_TTL = Duration.ofDays(7);

@Qualifier("cacheRedisTemplate")
private final RedisTemplate<String, Object> cacheRedisTemplate;

public void registerAlbum(Long albumId) {
String key = buildAlbumKey(albumId);
cacheRedisTemplate.opsForValue().set(key, albumId.toString(), ALBUM_TTL);
cacheRedisTemplate.opsForSet().add(TRACKING_KEY, albumId.toString());
}

public Set<Long> getTrackedAlbumIds() {
Set<Object> members = cacheRedisTemplate.opsForSet().members(TRACKING_KEY);
if (members == null || members.isEmpty()) {
return Collections.emptySet();
}
return members.stream()
.map(Object::toString)
.map(Long::valueOf)
.collect(Collectors.toSet());
}

public boolean isExpired(Long albumId) {
Long ttl = cacheRedisTemplate.getExpire(buildAlbumKey(albumId), TimeUnit.SECONDS);
if (ttl == null) {
return true;
}

if (ttl == -2) {
return true;
}

if (ttl == -1) {
return false;
}

return ttl <= 0;
}

public void unregister(Long albumId) {
cacheRedisTemplate.opsForSet().remove(TRACKING_KEY, albumId.toString());
}

private String buildAlbumKey(Long albumId) {
return "expired:album:" + albumId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.cheeeese.cheese4cut.application;

import com.cheeeese.album.application.validator.AlbumValidator;
import com.cheeeese.album.domain.Album;
import com.cheeeese.album.exception.AlbumException;
import com.cheeeese.album.exception.code.AlbumErrorCode;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.cheese4cut.application.validator.Cheese4cutValidator;
import com.cheeeese.cheese4cut.domain.Cheese4cut;
import com.cheeeese.cheese4cut.dto.request.Cheese4cutFixedRequest;
import com.cheeeese.cheese4cut.dto.response.Cheese4cutPresignedUrlResponse;
import com.cheeeese.cheese4cut.dto.response.Cheese4cutResponse;
import com.cheeeese.cheese4cut.exception.Cheese4cutException;
import com.cheeeese.cheese4cut.exception.code.Cheese4cutErrorCode;
import com.cheeeese.cheese4cut.infrastructure.mapper.Cheese4cutMapper;
import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository;
import com.cheeeese.photo.application.PresignedUrlService;
import com.cheeeese.photo.domain.Photo;
import com.cheeeese.photo.domain.PhotoStatus;
import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository;
import com.cheeeese.photo.infrastructure.persistence.PhotoRepository;
import com.cheeeese.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional
public class Cheese4cutService {

private final Cheese4cutRepository cheese4cutRepository;
private final AlbumRepository albumRepository;
private final PhotoRepository photoRepository;
private final PhotoLikesRepository photoLikesRepository;
private final AlbumValidator albumValidator;
private final PresignedUrlService presignedUrlService;
private final Cheese4cutValidator cheese4cutValidator;

@Transactional(readOnly = true)
public Cheese4cutResponse getCheese4cutByAlbumCode(String code) {
Album album = albumRepository.findByCode(code)
.orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND));

Optional<Cheese4cut> cheese4cutOptional = cheese4cutRepository.findByAlbumId(album.getId());

// TODO: 최종 확정된 4장의 photo 제공으로 변경
if (cheese4cutOptional.isPresent()) {
return Cheese4cutMapper.toFinalResponse();
}

return getPreviewResponse(album.getId(), album.getParticipant());
}

private Cheese4cutResponse getPreviewResponse(Long albumId, int participant) {
List<Long> topPhotoIds = photoRepository.findTop4CompletedPhotoIdsByLikes(
albumId,
PhotoStatus.COMPLETED,
PageRequest.of(0, 4)
);

if (topPhotoIds.size() < 4) {
throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT);
}

List<Photo> topPhotos = photoRepository.findAllById(topPhotoIds);

Map<Long, Photo> photoMap = topPhotos.stream()
.collect(Collectors.toMap(Photo::getId, Function.identity()));

List<Photo> orderedPhotos = topPhotoIds.stream()
.map(photoId -> {
Photo photo = photoMap.get(photoId);
if (photo == null) {
throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT);
}
return photo;
})
.toList();

long uniqueLikesCount = photoLikesRepository.countDistinctUserIdsByPhotoIds(topPhotoIds);

return Cheese4cutMapper.toPreviewResponse(orderedPhotos, uniqueLikesCount, participant);
}

@Transactional(readOnly = true)
public Cheese4cutPresignedUrlResponse createCheese4cutPresignedUrl(User user, String code) {
Album album = albumValidator.validateAlbumCode(code);
albumValidator.validateUploadPermission(album, user);

String uploadUrl = presignedUrlService.generateCheese4cutPresignedPutUrl(code);

return Cheese4cutMapper.toPresignedUrlResponse(uploadUrl);
}

public void finalizeCheese4cut(User user, String code, Cheese4cutFixedRequest request) {
Album album = albumValidator.validateAlbumCode(code);

if (album.isExpired()) {
throw new AlbumException(AlbumErrorCode.ALBUM_EXPIRED);
}

cheese4cutValidator.validateUserIsMaker(album, user);

if (cheese4cutRepository.findByAlbumId(album.getId()).isPresent()) {
throw new Cheese4cutException(Cheese4cutErrorCode.CHEESE4CUT_ALREADY_FINALIZED);
}

cheese4cutValidator.validateFinalizePhotos(album, request.photoIds());

Cheese4cut cheese4cut = Cheese4cutMapper.toEntity(album, request);

cheese4cutRepository.save(cheese4cut);
}
}
Loading