Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cf385da
#14 feat: 앨범 생성 API 1차 구현
zyovn Oct 15, 2025
24c4aae
#14 feat: 앨범 생성 이용 약관 동의 컬럼 추가
zyovn Oct 15, 2025
9337f70
#14 style: albumData -> albumDate 오타 수정
zyovn Oct 15, 2025
3cc12b8
#14 feat: 앨범 생성 예외 처리 코드 추가
zyovn Oct 15, 2025
3caf308
#14 rename: userAlbum -> albumParticipant로 수정
zyovn Oct 16, 2025
ceb25dc
#14 remove: userAlbumRole 삭제
zyovn Oct 16, 2025
778aa00
#14 fix: 성공 코드 수정
zyovn Oct 16, 2025
36cc5e9
#14 docs: swagger 작성 및 dto 이름 변경
zyovn Oct 16, 2025
5a5378f
#14 test: 앨범 코드 uuid v4, uuid v7 비교 벤치마크 테스트
zyovn Oct 18, 2025
992c2cc
#14 feat: 사용자 앨범 생성 주 3개 제한 로직 추가
zyovn Oct 20, 2025
877bf51
#14 fix: uuid v4 -> v7로 수정
zyovn Oct 20, 2025
70f909d
#14 fix: 비교연산자 및 앨범 에러 코드 수정
zyovn Oct 20, 2025
c1e267a
#14 fix: 앨범 생성 response 수정
zyovn Oct 20, 2025
e8c8799
#14 docs: swagger server url 세분화
zyovn Oct 20, 2025
d384070
#14 feat: 앨범 썸네일 이미지 예외 케이스 추가
zyovn Oct 20, 2025
47c5f00
#14 docs: 앨범 생성 request 행사 날짜 예시 수정
zyovn Oct 20, 2025
f26899c
#14 feat: 앨범 생성 행사 날짜 관련 예외 케이스 추가
zyovn Oct 20, 2025
2881b47
#14 fix: 피드백 반영
zyovn Oct 20, 2025
0760c57
#14 feat: 앨범 썸네일 이미지 -> 이모지로 수정
zyovn Oct 20, 2025
3281c2d
#14 refactor: emoji -> themeEmoji로 수정
zyovn Oct 20, 2025
08c241d
#14 fix: 피드백 반영
zyovn Oct 20, 2025
e76469f
#14 docs: swagger 수정
zyovn Oct 20, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ build/
application.yml
application-dev.yml
application-prod.yml
application-test.yml

### STS ###
.apt_generated
Expand Down
7 changes: 6 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ dependencies {

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// uuid v7
implementation 'com.github.f4b6a3:uuid-creator:5.2.0'
}

tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform {
excludeTags 'benchmark'
}
}
59 changes: 49 additions & 10 deletions src/main/java/com/cheeeese/album/application/AlbumService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,34 @@

import com.cheeeese.album.application.validator.AlbumValidator;
import com.cheeeese.album.domain.Album;
import com.cheeeese.album.dto.request.AlbumCreationRequest;
import com.cheeeese.album.dto.response.AlbumCreationResponse;
import com.cheeeese.album.dto.response.AlbumInvitationResponse;
import com.cheeeese.album.exception.AlbumException;
import com.cheeeese.album.exception.code.AlbumErrorCode;
import com.cheeeese.album.infrastructure.mapper.AlbumMapper;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.photo.application.PhotoService;
import com.cheeeese.album.domain.UserAlbum;
import com.cheeeese.album.domain.AlbumParticipant;
import com.cheeeese.album.dto.response.AlbumEnterResponse;
import com.cheeeese.album.dto.response.AlbumEnterResponse.AlbumHostInfo;
import com.cheeeese.album.dto.response.AlbumEnterResponse.AlbumParticipantResponse;
import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
import com.cheeeese.album.infrastructure.persistence.AlbumParticipantRepository;
import com.cheeeese.user.domain.User;
import com.cheeeese.user.exception.UserException;
import com.cheeeese.user.exception.code.UserErrorCode;
import com.cheeeese.user.infrastructure.persistence.UserRepository;
import com.github.f4b6a3.uuid.UuidCreator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;

Expand All @@ -34,10 +41,34 @@ public class AlbumService {

private final AlbumValidator albumValidator;
private final AlbumRepository albumRepository;
private final UserAlbumRepository userAlbumRepository;
private final AlbumParticipantRepository albumParticipantRepository;
private final UserRepository userRepository;
private final PhotoService photoService;

@Transactional
public AlbumCreationResponse createAlbum(User user, AlbumCreationRequest request) {
String code = UuidCreator.getTimeOrdered().toString();

long createdThisWeek = countUserAlbumsCreatedThisWeek(user);

albumValidator.validateAlbumCreation(createdThisWeek, request);

Album album = AlbumMapper.toEntity(
user.getId(),
request.title(),
code,
request.themeEmoji(),
request.participant(),
request.eventDate(),
true,
LocalDateTime.now().plusDays(7),
request.isTermsAgreement()
);
albumRepository.save(album);

return AlbumMapper.toCreationResponse(album);
}

public AlbumInvitationResponse getInvitationInfo(String code) {
Album album = albumValidator.validateAlbumCode(code);

Expand All @@ -56,26 +87,34 @@ public AlbumEnterResponse enterAlbum(String code, User currentUser) {
// 2. 앨범 입장 인가 검증 (만료, 블랙리스트, 정원 초과)
albumValidator.validateAlbumEntry(album, currentUser);

// 3. 사용자 앨범 참가 로직: 첫 입장 시 GUEST로 등록 및 참가자 수 증가
// 3. 사용자 앨범 참가 로직: 첫 입장 시 isBlacklisted = false로 등록 및 참가자 수 증가
Album freshAlbum = handleAlbumParticipation(album, currentUser);

// 4. 응답 DTO 생성
return createAlbumEnterResponse(freshAlbum);
}

private long countUserAlbumsCreatedThisWeek(User user) {
return albumRepository.countByUserAndCreatedAtBetween(
user.getId(),
LocalDate.now().with(DayOfWeek.MONDAY).atTime(LocalTime.MIN),
LocalDate.now().with(DayOfWeek.MONDAY).plusWeeks(1).atTime(LocalTime.now())
);
}

private Album handleAlbumParticipation(Album album, User currentUser) {
boolean isAlreadyParticipant = userAlbumRepository.findByUserIdAndAlbumId(currentUser.getId(), album.getId()).isPresent();
boolean isAlreadyParticipant = albumParticipantRepository.findByUserIdAndAlbumId(currentUser.getId(), album.getId()).isPresent();

if (isAlreadyParticipant) {
log.info("User {} is already a participant of album {}. Skipping registration.",
currentUser.getId(), album.getId());
return album;
}

// 첫 입장: UserAlbum에 GUEST로 저장하고, Album 참가자 수 증가
UserAlbum newUserAlbum = AlbumMapper.toGuestUserAlbum(currentUser, album);
// 첫 입장: AlbumParticipant에 isBlacklisted = false로 저장하고, Album 참가자 수 증가
AlbumParticipant newAlbumParticipant = AlbumMapper.toGuestUserAlbum(currentUser, album);
try {
userAlbumRepository.save(newUserAlbum);
albumParticipantRepository.save(newAlbumParticipant);

int updatedRows = albumRepository.incrementParticipantCountAtomically(album.getId());
if (updatedRows == 0) {
Expand Down Expand Up @@ -126,8 +165,8 @@ private User getHostUser(Long hostId) {
}

private List<AlbumParticipantResponse> getParticipantResponses(Long albumId) {
List<Long> participantUserIds = userAlbumRepository.findAllByAlbumId(albumId).stream()
.map(UserAlbum::getUserId)
List<Long> participantUserIds = albumParticipantRepository.findAllByAlbumId(albumId).stream()
.map(AlbumParticipant::getUserId)
.collect(Collectors.toList());

List<User> participants = userRepository.findAllById(participantUserIds);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,53 @@
package com.cheeeese.album.application.validator;

import com.cheeeese.album.domain.Album;
import com.cheeeese.album.domain.UserAlbumRole;
import com.cheeeese.album.dto.request.AlbumCreationRequest;
import com.cheeeese.album.exception.AlbumException;
import com.cheeeese.album.exception.code.AlbumErrorCode;
import com.cheeeese.album.infrastructure.persistence.AlbumRepository;
import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository;
import com.cheeeese.album.infrastructure.persistence.AlbumParticipantRepository;
import com.cheeeese.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;

@Component
@RequiredArgsConstructor
public class AlbumValidator {

private final AlbumRepository albumRepository;
private final UserAlbumRepository userAlbumRepository;
private final AlbumParticipantRepository albumParticipantRepository;

public void validateAlbumCreation(long createdThisWeek, AlbumCreationRequest request) {
if (!request.isTermsAgreement()) {
throw new AlbumException(AlbumErrorCode.ALBUM_REQUIRED_TERMS_NOT_AGREED);
}

if (request.themeEmoji() == null || request.themeEmoji().isBlank()) {
throw new AlbumException(AlbumErrorCode.ALBUM_THEME_EMOJI_NOT_SELECTED);
}

if (request.title() == null || request.title().isBlank()) {
throw new AlbumException(AlbumErrorCode.ALBUM_TITLE_REQUIRED);
}

if (request.eventDate() == null) {
throw new AlbumException(AlbumErrorCode.ALBUM_EVENT_DATE_REQUIRED);
}

if (request.eventDate().isAfter(LocalDate.now())) {
throw new AlbumException(AlbumErrorCode.ALBUM_EVENT_DATE_INVALID);
}

if (request.participant() < 1 || request.participant() > 64) {
throw new AlbumException(AlbumErrorCode.ALBUM_INVALID_CAPACITY);
}

if (createdThisWeek >= 3) {
throw new AlbumException(AlbumErrorCode.ALBUM_CREATION_LIMIT_EXCEEDED);
}
}

public Album validateAlbumCode(String code) {
return albumRepository.findByCode(code)
Expand All @@ -40,8 +72,8 @@ public void validateAlbumEntry(Album album, User user) {
* 사용자가 앨범의 블랙리스트에 등록되어 있는지 확인합니다.
*/
private void validateUserBlacklisted(Album album, User user) {
userAlbumRepository.findByAlbumIdAndUserIdAndRole(album.getId(), user.getId(), UserAlbumRole.BLACK)
.ifPresent(userAlbum -> {
albumParticipantRepository.findByAlbumIdAndUserIdAndIsBlacklistedTrue(album.getId(), user.getId())
.ifPresent(blacklisted -> {
throw new AlbumException(AlbumErrorCode.USER_IS_BLACKLISTED);
});
}
Expand Down
15 changes: 10 additions & 5 deletions src/main/java/com/cheeeese/album/domain/Album.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public class Album extends BaseEntity {
@Column(name = "code", nullable = false, unique = true)
private String code;

@Column(name = "theme_image_url")
private String themeImageUrl;
@Column(name = "theme_emoji")
private String themeEmoji;

@Column(name = "participant", nullable = false)
private int participant;
Expand All @@ -58,6 +58,9 @@ public class Album extends BaseEntity {
@Column(name = "status", nullable = false)
private AlbumStatus status;

@Column(name = "is_terms_agreement", nullable = false)
private boolean isTermsAgreement;

public enum AlbumStatus {
ACTIVE, EXPIRED, DELETED
}
Expand All @@ -71,20 +74,21 @@ private Album(
Long hostId,
String title,
String code,
String themeImageUrl,
String themeEmoji,
int participant,
int currentParticipant,
LocalDate eventDate,
int maxPhotoCount,
int currentPhotoCount,
boolean isInfoAvailable,
LocalDateTime expiredAt,
AlbumStatus status
AlbumStatus status,
boolean isTermsAgreement
) {
this.hostId = hostId;
this.title = title;
this.code = code;
this.themeImageUrl = themeImageUrl;
this.themeEmoji = themeEmoji;
this.participant = participant;
this.currentParticipant = currentParticipant;
this.eventDate = eventDate;
Expand All @@ -93,5 +97,6 @@ private Album(
this.isInfoAvailable = isInfoAvailable;
this.expiredAt = expiredAt;
this.status = status;
this.isTermsAgreement = isTermsAgreement;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
@Getter
@Table(name = "user_album", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "album_id"}))
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserAlbum extends BaseEntity {
public class AlbumParticipant extends BaseEntity {

@Id
@Column(name = "user_album_id", nullable = false)
Expand All @@ -26,14 +26,13 @@ public class UserAlbum extends BaseEntity {
@Column(name = "album_id", nullable = false)
private Long albumId;

@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private UserAlbumRole role;
@Column(name = "is_blacklisted", nullable = false)
private boolean isBlacklisted;

@Builder
private UserAlbum(Long userId, Long albumId, UserAlbumRole role) {
private AlbumParticipant(Long userId, Long albumId, boolean isBlacklisted) {
this.userId = userId;
this.albumId = albumId;
this.role = role;
this.isBlacklisted = isBlacklisted;
}
}
7 changes: 0 additions & 7 deletions src/main/java/com/cheeeese/album/domain/UserAlbumRole.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.cheeeese.album.dto.request;

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

import java.time.LocalDate;

@Builder
@Schema(description = "앨범 생성 API")
public record AlbumCreationRequest(
@Schema(description = "앨범 테마 이모지", example = "U+1F9C0")
String themeEmoji,

@Schema(description = "앨범 이름", example = "졸업식")
String title,

@Schema(description = "참여자 수", example = "64")
int participant,

@Schema(description = "행사 날짜", example = "2025-02-01")
LocalDate eventDate,

@Schema(description = "앨범 생성 필수 약관 동의", example = "true")
boolean isTermsAgreement
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.cheeeese.album.dto.response;

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

import java.time.LocalDate;

@Builder
@Schema(description = "앨범 생성 API")
public record AlbumCreationResponse(
@Schema(description = "앨범 테마 이모지", example = "U+1F9C0")
String themeEmoji,

@Schema(description = "행사 이름", example = "큐시즘 MT")
String title,

@Schema(description = "행사 날짜", example = "2025.02.01")
LocalDate eventDate,

@Schema(description = "현재까지 업로드된 사진 수", example = "1")
int currentPhotoCnt,

@Schema(description = "앨범 코드", example = "786ccd09-5f22-4aa9-a32b-f62dd2e94cc8")
String code
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public record AlbumEnterResponse(
@Schema(description = "앨범 제목", example = "졸업 여행 폴라로이드")
String title,

@Schema(description = "앨범 테마 이미지 URL", example = "http://example.com/theme.jpg")
String themeImageUrl,
@Schema(description = "앨범 테마 이모지", example = "U+1F9C0")
String themeEmoji,

@Schema(description = "이벤트 날짜", example = "2025-02-26")
String eventDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public record AlbumInvitationResponse(
@Schema(description = "앨범 제목", example = "경영학부 졸업식")
String title,

@Schema(description = "앨범 테마 이미지 URL", example = "http://example.com/theme.jpg")
String themeImageUrl,
@Schema(description = "앨범 테마 이모지", example = "U+1F9C0")
String themeEmoji,

@Schema(description = "이벤트 날짜", example = "2025-02-26")
String eventDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ public enum AlbumErrorCode implements BaseCode {
ALBUM_MAX_PARTICIPANT_REACHED(HttpStatus.BAD_REQUEST, "앨범의 최대 참가 인원수를 초과했습니다."),
USER_IS_BLACKLISTED(HttpStatus.FORBIDDEN, "앨범 관리자에 의해 접근이 금지된 사용자입니다."),
USER_ALREADY_JOINED_CONCURRENTLY(HttpStatus.CONFLICT, "동시성 오류: 이미 앨범에 참여 요청이 완료되었습니다."),
ALBUM_REQUIRED_TERMS_NOT_AGREED(HttpStatus.BAD_REQUEST, "앨범 생성 필수 약관에 동의하지 않았습니다."),
ALBUM_THEME_EMOJI_NOT_SELECTED(HttpStatus.BAD_REQUEST, "앨범 썸네일 이모지가 선택되지 않았습니다."),
ALBUM_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "행사 이름이 입력되지 않았습니다."),
ALBUM_EVENT_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "행사 날짜가 입력되지 않았습니다."),
ALBUM_EVENT_DATE_INVALID(HttpStatus.BAD_REQUEST, "행사 날짜는 오늘 또는 과거만 선택 가능합니다."),
ALBUM_INVALID_CAPACITY(HttpStatus.BAD_REQUEST, "앨범 인원은 최소 1명 이상 최대 64명 이하여야 합니다."),
ALBUM_CREATION_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "사용자는 일주일에 최대 3개의 앨범만 생성할 수 있습니다."),
;

private final HttpStatus httpStatus;
Expand Down
Loading