diff --git a/build.gradle b/build.gradle index 9175fd97..82badb15 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,10 @@ dependencies { // RestDocs testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // S3 + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' } tasks.named('test') { @@ -121,6 +125,7 @@ jacocoTestReport { "**/*DynamicQuery*", "**/*BaseTimeEntity*", "**/*HealthCheckController*", + "**/*S3Manager*", ] + Qdomains) }) ) @@ -152,7 +157,8 @@ sonar { property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml' property 'sonar.coverage.exclusions', '**/test/**, **/Q*.java, **/*Doc*.java, **/resources/** ' + ',**/*Application*.java , **/*Config*.java, **/*Request*.java, **/*Response*.java ,**/*Exception*.java ' + - ',**/*ErrorMessage*.java, **/*Mapper*.java, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController*' + ',**/*ErrorMessage*.java, **/*Mapper*.java, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController* ' + + ', **/*S3Manager*.java' property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml' } } diff --git a/src/main/java/com/moabam/api/application/ImageService.java b/src/main/java/com/moabam/api/application/ImageService.java new file mode 100644 index 00000000..0fdaac6d --- /dev/null +++ b/src/main/java/com/moabam/api/application/ImageService.java @@ -0,0 +1,54 @@ +package com.moabam.api.application; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.api.domain.resizedimage.ImageName; +import com.moabam.api.domain.resizedimage.ImageResizer; +import com.moabam.api.domain.resizedimage.ImageType; +import com.moabam.api.infrastructure.s3.S3Manager; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImageService { + + private final S3Manager s3Manager; + + @Transactional + public List uploadImages(List multipartFiles, ImageType imageType) { + + List result = new ArrayList<>(); + + List imageResizers = multipartFiles.stream() + .map(multipartFile -> this.toImageResizer(multipartFile, imageType)) + .toList(); + + imageResizers.forEach(resizer -> { + resizer.resizeImageToFixedSize(imageType); + result.add(s3Manager.uploadImage(resizer.getResizedImage().getName(), resizer.getResizedImage())); + }); + + return result; + } + + private ImageResizer toImageResizer(MultipartFile multipartFile, ImageType imageType) { + ImageName imageName = ImageName.of(multipartFile, imageType); + + return ImageResizer.builder() + .image(multipartFile) + .fileName(imageName.getFileName()) + .build(); + } + + @Transactional + public void deleteImage(String imageUrl) { + s3Manager.deleteImage(imageUrl); + } +} diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/RoomService.java index 9c35148f..598eeb84 100644 --- a/src/main/java/com/moabam/api/application/RoomService.java +++ b/src/main/java/com/moabam/api/application/RoomService.java @@ -1,17 +1,22 @@ package com.moabam.api.application; import static com.moabam.api.domain.entity.enums.RoomType.*; +import static com.moabam.api.domain.resizedimage.ImageType.*; import static com.moabam.global.error.model.ErrorMessage.*; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.Period; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import com.moabam.api.domain.entity.Certification; import com.moabam.api.domain.entity.DailyMemberCertification; @@ -20,8 +25,13 @@ import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.entity.Room; import com.moabam.api.domain.entity.Routine; +import com.moabam.api.domain.entity.enums.BugType; +import com.moabam.api.domain.entity.enums.RequireExp; import com.moabam.api.domain.entity.enums.RoomType; +import com.moabam.api.domain.repository.CertificationRepository; import com.moabam.api.domain.repository.CertificationsSearchRepository; +import com.moabam.api.domain.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.repository.DailyRoomCertificationRepository; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; import com.moabam.api.domain.repository.RoomRepository; @@ -37,6 +47,8 @@ import com.moabam.api.dto.RoutineMapper; import com.moabam.api.dto.RoutineResponse; import com.moabam.api.dto.TodayCertificateRankResponse; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.common.util.UrlSubstringParser; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ForbiddenException; import com.moabam.global.error.exception.NotFoundException; @@ -54,7 +66,12 @@ public class RoomService { private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; private final CertificationsSearchRepository certificationsSearchRepository; + private final DailyMemberCertificationRepository dailyMemberCertificationRepository; + private final DailyRoomCertificationRepository dailyRoomCertificationRepository; + private final CertificationRepository certificationRepository; private final MemberService memberService; + private final ImageService imageService; + private final ClockHolder clockHolder; @Transactional public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @@ -151,6 +168,63 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { todayCertificateRankResponses, completePercentage); } + @Transactional + public void certifyRoom(Long memberId, Long roomId, List multipartFiles) { + LocalDate today = LocalDate.now(); + Participant participant = getParticipant(memberId, roomId); + Room room = participant.getRoom(); + Member member = memberService.getById(memberId); + BugType bugType = switch (room.getRoomType()) { + case MORNING -> BugType.MORNING; + case NIGHT -> BugType.NIGHT; + }; + int roomLevel = room.getLevel(); + + validateCertifyTime(clockHolder.times(), room.getCertifyTime()); + validateAlreadyCertified(memberId, roomId, today); + + DailyMemberCertification dailyMemberCertification = CertificationsMapper.toDailyMemberCertification(memberId, + roomId, participant); + dailyMemberCertificationRepository.save(dailyMemberCertification); + + member.increaseTotalCertifyCount(); + + List result = imageService.uploadImages(multipartFiles, CERTIFICATION); + saveNewCertifications(result, memberId); + + Optional dailyRoomCertification = + certificationsSearchRepository.findDailyRoomCertification(roomId, today); + + if (dailyRoomCertification.isEmpty()) { + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room.getCurrentUserCount()); + + if (completePercentage >= 75) { + DailyRoomCertification createDailyRoomCertification = CertificationsMapper.toDailyRoomCertification( + roomId, today); + + dailyRoomCertificationRepository.save(createDailyRoomCertification); + + int expAppliedRoomLevel = getRoomLevelAfterExpApply(roomLevel, room); + + List memberIds = dailyMemberCertifications.stream() + .map(DailyMemberCertification::getMemberId) + .toList(); + + memberService.getRoomMembers(memberIds) + .forEach(completedMember -> completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); + + return; + } + } + + if (dailyRoomCertification.isPresent()) { + member.getBug().increaseBug(bugType, roomLevel); + } + } + public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); @@ -307,4 +381,46 @@ private double calculateCompletePercentage(int certifiedMembersCount, int curren return Math.round(completePercentage * 100) / 100.0; } + + private void validateCertifyTime(LocalDateTime now, int certifyTime) { + LocalTime targetTime = LocalTime.of(certifyTime, 0); + LocalDateTime minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); + LocalDateTime plusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).plusMinutes(10); + + if (now.isBefore(minusTenMinutes) || now.isAfter(plusTenMinutes)) { + throw new BadRequestException(INVALID_CERTIFY_TIME); + } + } + + private void validateAlreadyCertified(Long memberId, Long roomId, LocalDate today) { + if (certificationsSearchRepository.findDailyMemberCertification(memberId, roomId, today).isPresent()) { + throw new BadRequestException(DUPLICATED_DAILY_MEMBER_CERTIFICATION); + } + } + + private void saveNewCertifications(List imageUrls, Long memberId) { + List certifications = new ArrayList<>(); + + for (String imageUrl : imageUrls) { + Long routineId = Long.parseLong(UrlSubstringParser.parseUrl(imageUrl, "_")); + Routine routine = routineRepository.findById(routineId).orElseThrow(() -> new NotFoundException( + ROUTINE_NOT_FOUND)); + + Certification certification = CertificationsMapper.toCertification(routine, memberId, imageUrl); + certifications.add(certification); + } + + certificationRepository.saveAll(certifications); + } + + private int getRoomLevelAfterExpApply(int roomLevel, Room room) { + int requireExp = RequireExp.of(roomLevel).getTotalExp(); + room.gainExp(); + + if (room.getExp() == requireExp) { + room.levelUp(); + } + + return room.getLevel(); + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index 13029124..bee72c09 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -112,4 +112,8 @@ public void exitNightRoom() { public int getLevel() { return (int)(totalCertifyCount / LEVEL_DIVISOR) + 1; } + + public void increaseTotalCertifyCount() { + this.totalCertifyCount++; + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Room.java b/src/main/java/com/moabam/api/domain/entity/Room.java index b80d708f..10a9c13c 100644 --- a/src/main/java/com/moabam/api/domain/entity/Room.java +++ b/src/main/java/com/moabam/api/domain/entity/Room.java @@ -49,9 +49,14 @@ public class Room extends BaseTimeEntity { @Column(name = "password", length = 8) private String password; + @ColumnDefault("0") @Column(name = "level", nullable = false) private int level; + @ColumnDefault("0") + @Column(name = "exp", nullable = false) + private int exp; + @Enumerated(value = EnumType.STRING) @Column(name = "room_type") private RoomType roomType; @@ -65,7 +70,7 @@ public class Room extends BaseTimeEntity { @Column(name = "max_user_count", nullable = false) private int maxUserCount; - @Column(name = "announcement", length = 255) + @Column(name = "announcement", length = 100) private String announcement; @ColumnDefault(ROOM_LEVEL_0_IMAGE) @@ -78,6 +83,7 @@ private Room(Long id, String title, String password, RoomType roomType, int cert this.title = requireNonNull(title); this.password = password; this.level = 0; + this.exp = 0; this.roomType = requireNonNull(roomType); this.certifyTime = validateCertifyTime(roomType, certifyTime); this.currentUserCount = 1; @@ -87,6 +93,11 @@ private Room(Long id, String title, String password, RoomType roomType, int cert public void levelUp() { this.level += 1; + this.exp = 0; + } + + public void gainExp() { + this.exp += 1; } public void changeAnnouncement(String announcement) { diff --git a/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java b/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java new file mode 100644 index 00000000..96233fd9 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java @@ -0,0 +1,43 @@ +package com.moabam.api.domain.entity.enums; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 방 경험치 + * 방 레벨 - 현재 경험치 / 전체 경험치 + * 레벨0 - 0 / 1 + * 레벨1 - 0 / 3 + * 레벨2 - 0 / 5 + * 레벨3 - 0 / 10 + */ + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum RequireExp { + + ROOM_LEVEL_0(0, 1), + ROOM_LEVEL_1(1, 5), + ROOM_LEVEL_2(2, 10), + ROOM_LEVEL_3(3, 20), + ROOM_LEVEL_4(4, 40), + ROOM_LEVEL_5(5, 80); + + private static final Map requireExpMap = Collections.unmodifiableMap( + Stream.of(values()) + .collect(Collectors.toMap(RequireExp::getLevel, RequireExp::name)) + ); + + private final int level; + private final int totalExp; + + public static RequireExp of(int level) { + return RequireExp.valueOf(requireExpMap.get(level)); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java index ee921314..f8c5488c 100644 --- a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Repository; @@ -33,6 +34,18 @@ public List findCertifications(Long roomId, LocalDate date) { .fetch(); } + public Optional findDailyMemberCertification(Long memberId, Long roomId, LocalDate date) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(dailyMemberCertification) + .where( + dailyMemberCertification.memberId.eq(memberId), + dailyMemberCertification.roomId.eq(roomId), + dailyMemberCertification.createdAt.between(date.atStartOfDay(), date.atTime(LocalTime.MAX)) + ) + .fetchOne() + ); + } + public List findSortedDailyMemberCertifications(Long roomId, LocalDate date) { return jpaQueryFactory .selectFrom(dailyMemberCertification) @@ -47,6 +60,16 @@ public List findSortedDailyMemberCertifications(Long r .fetch(); } + public Optional findDailyRoomCertification(Long roomId, LocalDate date) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(dailyRoomCertification) + .where( + dailyRoomCertification.roomId.eq(roomId), + dailyRoomCertification.certifiedAt.eq(date) + ) + .fetchOne()); + } + public List findDailyRoomCertifications(Long roomId, LocalDate date) { return jpaQueryFactory .selectFrom(dailyRoomCertification) diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java new file mode 100644 index 00000000..45ee6d4a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java @@ -0,0 +1,31 @@ +package com.moabam.api.domain.resizedimage; + +import static com.moabam.global.common.util.GlobalConstant.*; + +import java.time.LocalDate; +import java.util.UUID; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ImageName { + + private static final String CERTIFICATION_PATH = "certifications" + DELIMITER + LocalDate.now() + DELIMITER; + private static final String PROFILE_IMAGE = "members/profile" + DELIMITER; + private static final String DEFAULT = "moabam/default" + DELIMITER; + + private final String fileName; + + public static ImageName of(MultipartFile file, ImageType imageType) { + return switch (imageType) { + case CERTIFICATION -> new ImageName(CERTIFICATION_PATH + file.getName() + "_" + UUID.randomUUID()); + case PROFILE_IMAGE -> new ImageName(PROFILE_IMAGE + file.getName() + "_" + UUID.randomUUID()); + case DEFAULT -> new ImageName(DEFAULT + file.getName()); + }; + } +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java new file mode 100644 index 00000000..409aa4e5 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java @@ -0,0 +1,121 @@ +package com.moabam.api.domain.resizedimage; + +import static com.moabam.global.common.util.GlobalConstant.DELIMITER; +import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE; +import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE_SIZE; +import static com.moabam.global.error.model.ErrorMessage.S3_RESIZE_ERROR; +import static java.util.Objects.requireNonNull; + +import java.awt.Graphics; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.global.error.exception.BadRequestException; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Getter +@Slf4j +public class ImageResizer { + + private static final int MAX_IMAGE_SIZE = 1024 * 1024 * 10; + private static final String IMAGE_FORMAT_PREFIX = "image/"; + private static final int FORMAT_INDEX = 1; + + private final MultipartFile image; + private final String fileName; + private MultipartFile resizedImage; + + @Builder + public ImageResizer(MultipartFile image, String fileName) { + this.image = validate(image); + this.fileName = fileName; + } + + public MultipartFile validate(MultipartFile image) { + if (isNotImage(image)) { + throw new BadRequestException(S3_INVALID_IMAGE); + } + if (image.getSize() > MAX_IMAGE_SIZE) { + throw new BadRequestException(S3_INVALID_IMAGE_SIZE); + } + + return image; + } + + private boolean isNotImage(MultipartFile image) { + String contentType = requireNonNull(image.getContentType()); + + return !contentType.startsWith(IMAGE_FORMAT_PREFIX); + } + + public void resizeImageToFixedSize(ImageType imageType) { + ImageSize imageSize = switch (imageType) { + case PROFILE_IMAGE -> ImageSize.PROFILE_IMAGE; + case CERTIFICATION -> ImageSize.CERTIFICATION_IMAGE; + case DEFAULT -> ImageSize.CAGE; + }; + + BufferedImage bufferedImage = getBufferedImage(); + + int width = imageSize.getWidth(); + int height = getResizedHeight(width, bufferedImage); + BufferedImage scaledImage = resize(bufferedImage, width, height); + + byte[] bytes = toByteArray(scaledImage); + this.resizedImage = toMultipartFile(bytes); + } + + private int getResizedHeight(int width, BufferedImage bufferedImage) { + double ratio = (double)width / bufferedImage.getWidth(); + + return (int)(bufferedImage.getHeight() * ratio); + } + + private BufferedImage resize(BufferedImage image, int width, int height) { + BufferedImage canvas = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + + Graphics graphics = canvas.getGraphics(); + graphics.drawImage(image.getScaledInstance(width, height, Image.SCALE_SMOOTH), 0, 0, null); + graphics.dispose(); + + return canvas; + } + + private byte[] toByteArray(final BufferedImage result) { + try { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + ImageIO.write(result, getFormat(), byteArrayOutputStream); + + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + log.error("이미지 리사이징 에러", e); + throw new BadRequestException(S3_RESIZE_ERROR); + } + } + + private String getFormat() { + return requireNonNull(image.getContentType()).split(DELIMITER)[FORMAT_INDEX]; + } + + private BufferedImage getBufferedImage() { + try { + return ImageIO.read(image.getInputStream()); + } catch (IOException e) { + log.error("이미지 리사이징 에러", e); + throw new BadRequestException(S3_RESIZE_ERROR); + } + } + + private ResizedImage toMultipartFile(byte[] bytes) { + return ResizedImage.of(fileName, image.getContentType(), bytes); + } +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java new file mode 100644 index 00000000..c6c5ed06 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java @@ -0,0 +1,17 @@ +package com.moabam.api.domain.resizedimage; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ImageSize { + + CAGE(450), + BIRD_SKIN(150), + COUPON_EVENT(420), + PROFILE_IMAGE(150), + CERTIFICATION_IMAGE(220); + + private final int width; +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java new file mode 100644 index 00000000..cf9936e5 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java @@ -0,0 +1,8 @@ +package com.moabam.api.domain.resizedimage; + +public enum ImageType { + + PROFILE_IMAGE, + CERTIFICATION, + DEFAULT +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java b/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java new file mode 100644 index 00000000..6920382c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java @@ -0,0 +1,67 @@ +package com.moabam.api.domain.resizedimage; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ResizedImage implements MultipartFile { + + private final String name; + private final String contentType; + private final long size; + private final byte[] bytes; + + public static ResizedImage of(String name, String contentType, byte[] bytes) { + return new ResizedImage(name, contentType, bytes.length, bytes); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return name; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public long getSize() { + return size; + } + + @Override + public byte[] getBytes() throws IOException { + return bytes; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + try (FileOutputStream fileOutputStream = new FileOutputStream(dest)) { + fileOutputStream.write(this.getBytes()); + } + } +} diff --git a/src/main/java/com/moabam/api/dto/CertificationsMapper.java b/src/main/java/com/moabam/api/dto/CertificationsMapper.java index 866ecf40..bbaf37f4 100644 --- a/src/main/java/com/moabam/api/dto/CertificationsMapper.java +++ b/src/main/java/com/moabam/api/dto/CertificationsMapper.java @@ -1,12 +1,15 @@ package com.moabam.api.dto; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import com.moabam.api.domain.entity.Certification; +import com.moabam.api.domain.entity.DailyMemberCertification; +import com.moabam.api.domain.entity.DailyRoomCertification; import com.moabam.api.domain.entity.Member; -import com.moabam.api.dto.CertificationImageResponse; -import com.moabam.api.dto.TodayCertificateRankResponse; +import com.moabam.api.domain.entity.Participant; +import com.moabam.api.domain.entity.Routine; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -16,6 +19,7 @@ public final class CertificationsMapper { public static List toCertificateImageResponses(Long memberId, List certifications) { + List cftImageResponses = new ArrayList<>(); List filteredCertifications = certifications.stream() .filter(certification -> certification.getMemberId().equals(memberId)) @@ -36,6 +40,7 @@ public static List toCertificateImageResponses(Long public static TodayCertificateRankResponse toTodayCertificateRankResponse(int rank, Member member, int contributionPoint, String awakeImage, String sleepImage, List certificationImageResponses) { + return TodayCertificateRankResponse.builder() .rank(rank) .memberId(member.getId()) @@ -47,4 +52,29 @@ public static TodayCertificateRankResponse toTodayCertificateRankResponse(int ra .certificationImage(certificationImageResponses) .build(); } + + public static DailyMemberCertification toDailyMemberCertification(Long memberId, Long roomId, + Participant participant) { + + return DailyMemberCertification.builder() + .memberId(memberId) + .roomId(roomId) + .participant(participant) + .build(); + } + + public static DailyRoomCertification toDailyRoomCertification(Long roomId, LocalDate today) { + return DailyRoomCertification.builder() + .roomId(roomId) + .certifiedAt(today) + .build(); + } + + public static Certification toCertification(Routine routine, Long memberId, String image) { + return Certification.builder() + .routine(routine) + .memberId(memberId) + .image(image) + .build(); + } } diff --git a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java index 017fc877..ecef68bc 100644 --- a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java @@ -12,7 +12,7 @@ public record ModifyRoomRequest( @NotBlank @Length(max = 20) String title, - @Length(max = 255, message = "방 공지의 길이가 너무 깁니다.") String announcement, + @Length(max = 100, message = "방 공지의 길이 100자 이하여야 합니다.") String announcement, @NotNull @Size(min = 1, max = 4) List routines, @Pattern(regexp = "^(|\\d{4,8})$") String password, @Range(min = 0, max = 23) int certifyTime, diff --git a/src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java b/src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java new file mode 100644 index 00000000..45bbb1b3 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java @@ -0,0 +1,47 @@ +package com.moabam.api.infrastructure.s3; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.global.error.exception.BadRequestException; + +import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class S3Manager { + + private final S3Template s3Template; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + @Value("${spring.cloud.aws.s3.url}") + private String s3BaseUrl; + + @Value("${spring.cloud.aws.cloud-front.url}") + private String cloudFrontUrl; + + public String uploadImage(String key, MultipartFile file) { + try { + s3Template.upload(bucket, key, file.getInputStream(), + ObjectMetadata.builder().contentType("image/png").build()); + + return cloudFrontUrl + key; + } catch (IOException e) { + throw new BadRequestException(S3_UPLOAD_FAIL); + } + } + + public void deleteImage(String objectUrl) { + String s3Url = objectUrl.replace(cloudFrontUrl, s3BaseUrl); + s3Template.deleteObject(s3Url); + } +} diff --git a/src/main/java/com/moabam/api/presentation/HealthCheckController.java b/src/main/java/com/moabam/api/presentation/HealthCheckController.java index 4f67e4c2..5d72ea28 100644 --- a/src/main/java/com/moabam/api/presentation/HealthCheckController.java +++ b/src/main/java/com/moabam/api/presentation/HealthCheckController.java @@ -1,5 +1,7 @@ package com.moabam.api.presentation; +import java.time.LocalDateTime; + import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseStatus; @@ -13,4 +15,10 @@ public class HealthCheckController { public String healthCheck() { return "Health Check Success"; } + + @GetMapping("/serverTime") + @ResponseStatus(HttpStatus.OK) + public String serverTimeCheck() { + return LocalDateTime.now().toString(); + } } diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index f7489136..a03d6d19 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -1,5 +1,7 @@ package com.moabam.api.presentation; +import java.util.List; + import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -8,8 +10,10 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import com.moabam.api.application.RoomService; import com.moabam.api.dto.CreateRoomRequest; @@ -42,7 +46,7 @@ public void modifyRoom(@Valid @RequestBody ModifyRoomRequest modifyRoomRequest, @PostMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) - public void enterRoom(@Valid @RequestBody EnterRoomRequest enterRoomRequest, @PathVariable("roomId") Long roomId) { + public void enterRoom(@PathVariable("roomId") Long roomId, @Valid @RequestBody EnterRoomRequest enterRoomRequest) { roomService.enterRoom(1L, roomId, enterRoomRequest); } @@ -57,4 +61,10 @@ public void exitRoom(@PathVariable("roomId") Long roomId) { public RoomDetailsResponse getRoomDetails(@PathVariable("roomId") Long roomId) { return roomService.getRoomDetails(1L, roomId); } + + @PostMapping("/{roomId}/certification") + @ResponseStatus(HttpStatus.CREATED) + public void certifyRoom(@PathVariable("roomId") Long roomId, @RequestPart List multipartFiles) { + roomService.certifyRoom(1L, roomId, multipartFiles); + } } diff --git a/src/main/java/com/moabam/global/common/util/ClockHolder.java b/src/main/java/com/moabam/global/common/util/ClockHolder.java new file mode 100644 index 00000000..414ce25c --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/ClockHolder.java @@ -0,0 +1,8 @@ +package com.moabam.global.common.util; + +import java.time.LocalDateTime; + +public interface ClockHolder { + + LocalDateTime times(); +} diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 5d111d65..8f43ec67 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -7,6 +7,9 @@ public class GlobalConstant { public static final String BLANK = ""; + public static final String COMMA = ","; + public static final String UNDER_BAR = "_"; + public static final String DELIMITER = "/"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; public static final int ONE_HOUR = 1; diff --git a/src/main/java/com/moabam/global/common/util/SystemClockHolder.java b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java new file mode 100644 index 00000000..8662d0da --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java @@ -0,0 +1,14 @@ +package com.moabam.global.common.util; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; + +@Component +public class SystemClockHolder implements ClockHolder { + + @Override + public LocalDateTime times() { + return LocalDateTime.now(); + } +} diff --git a/src/main/java/com/moabam/global/common/util/UrlSubstringParser.java b/src/main/java/com/moabam/global/common/util/UrlSubstringParser.java new file mode 100644 index 00000000..52f2cba2 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/UrlSubstringParser.java @@ -0,0 +1,25 @@ +package com.moabam.global.common.util; + +import static com.moabam.global.common.util.GlobalConstant.*; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UrlSubstringParser { + + public static String parseUrl(String url, String distinctToken) { + + int lastSlashTokenIndex = url.lastIndexOf(DELIMITER); + int distinctTokenIndex = url.indexOf(distinctToken); + + if (lastSlashTokenIndex == -1 || distinctTokenIndex == -1 || lastSlashTokenIndex > distinctTokenIndex) { + throw new BadRequestException(ErrorMessage.INVALID_REQUEST_URL); + } + + return url.substring(lastSlashTokenIndex + 1, distinctTokenIndex); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 6a9904f6..a1bc6d53 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -22,6 +22,10 @@ public enum ErrorMessage { ROOM_MAX_USER_REACHED("방의 인원수가 찼습니다."), ROOM_DETAILS_ERROR("방 정보를 불러오는데 실패했습니다."), ROUTINE_LENGTH_ERROR("루틴의 길이가 잘못 되었습니다."), + DUPLICATED_DAILY_MEMBER_CERTIFICATION("이미 오늘의 인증을 완료하였습니다."), + ROUTINE_NOT_FOUND("루틴을 찾을 수 없습니다"), + INVALID_REQUEST_URL("잘못된 URL 요청입니다."), + INVALID_CERTIFY_TIME("현재 인증 시간이 아닙니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), @@ -52,7 +56,12 @@ public enum ErrorMessage { INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), - NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."); + NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), + + S3_UPLOAD_FAIL("S3 업로드를 실패했습니다."), + S3_INVALID_IMAGE("올바른 이미지(파일) 형식이 아닙니다."), + S3_INVALID_IMAGE_SIZE("파일의 용량이 너무 큽니다."), + S3_RESIZE_ERROR("이미지 리사이징에서 에러가 발생했습니다."); private final String message; } diff --git a/src/main/resources/config b/src/main/resources/config index f392e58a..73b984ec 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit f392e58aefb231e765995b30c8c0194a67756b8c +Subproject commit 73b984ec52bfcc872a0acbe4b5a038dcc1d79262 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index b3b98263..89b81eb9 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -2132,11 +2132,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-<<<<<<< HEAD
-Content-Length: 194
-=======
 Content-Length: 192
->>>>>>> b5f9a450e8eb3b4f4527d412d519e6afc9c16365
 Host: localhost:8080
 
 {
diff --git a/src/test/java/com/moabam/api/application/ImageServiceTest.java b/src/test/java/com/moabam/api/application/ImageServiceTest.java
new file mode 100644
index 00000000..2a76c5f7
--- /dev/null
+++ b/src/test/java/com/moabam/api/application/ImageServiceTest.java
@@ -0,0 +1,62 @@
+package com.moabam.api.application;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.BDDMockito.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.moabam.api.domain.resizedimage.ImageType;
+import com.moabam.api.domain.resizedimage.ResizedImage;
+import com.moabam.api.infrastructure.s3.S3Manager;
+import com.moabam.support.fixture.RoomFixture;
+
+@ExtendWith(MockitoExtension.class)
+class ImageServiceTest {
+
+	@InjectMocks
+	private ImageService imageService;
+
+	@Mock
+	private S3Manager s3Manager;
+
+	@DisplayName("이미지 리사이징 이후 업로드 성공")
+	@Test
+	void image_resize_upload_success() {
+		// given
+		List multipartFiles = new ArrayList<>();
+		ImageType imageType = ImageType.CERTIFICATION;
+		MockMultipartFile image1 = RoomFixture.makeMultipartFile1();
+		List images = List.of(image1);
+
+		given(s3Manager.uploadImage(anyString(), any(ResizedImage.class))).willReturn(image1.getName());
+
+		// when
+		List result = imageService.uploadImages(images, imageType);
+
+		// then
+		assertThat(image1.getName()).isEqualTo(result.get(0));
+	}
+
+	@DisplayName("이미지 삭제 성공")
+	@Test
+	void delete_image_success() {
+		// given
+		String imageUrl = "test";
+
+		// when
+		imageService.deleteImage(imageUrl);
+
+		// then
+		verify(s3Manager).deleteImage(imageUrl);
+	}
+}
diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java
index dd626f18..f8b438ad 100644
--- a/src/test/java/com/moabam/api/application/RoomServiceTest.java
+++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java
@@ -4,26 +4,44 @@
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.BDDMockito.*;
 
+import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentMatchers;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.Spy;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.multipart.MultipartFile;
 
+import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.entity.Member;
 import com.moabam.api.domain.entity.Participant;
 import com.moabam.api.domain.entity.Room;
 import com.moabam.api.domain.entity.Routine;
 import com.moabam.api.domain.repository.CertificationRepository;
+import com.moabam.api.domain.repository.CertificationsSearchRepository;
+import com.moabam.api.domain.repository.DailyMemberCertificationRepository;
+import com.moabam.api.domain.repository.DailyRoomCertificationRepository;
 import com.moabam.api.domain.repository.ParticipantRepository;
+import com.moabam.api.domain.repository.ParticipantSearchRepository;
 import com.moabam.api.domain.repository.RoomRepository;
 import com.moabam.api.domain.repository.RoutineRepository;
+import com.moabam.api.domain.resizedimage.ImageType;
 import com.moabam.api.dto.CreateRoomRequest;
 import com.moabam.api.dto.RoomMapper;
+import com.moabam.global.common.util.ClockHolder;
+import com.moabam.support.fixture.MemberFixture;
+import com.moabam.support.fixture.RoomFixture;
 
 @ExtendWith(MockitoExtension.class)
 class RoomServiceTest {
@@ -31,17 +49,69 @@ class RoomServiceTest {
 	@InjectMocks
 	private RoomService roomService;
 
+	@Mock
+	private MemberService memberService;
+
 	@Mock
 	private RoomRepository roomRepository;
 
 	@Mock
 	private RoutineRepository routineRepository;
 
+	@Mock
+	private ParticipantRepository participantRepository;
+
 	@Mock
 	private CertificationRepository certificationRepository;
 
 	@Mock
-	private ParticipantRepository participantRepository;
+	private CertificationsSearchRepository certificationsSearchRepository;
+
+	@Mock
+	private ParticipantSearchRepository participantSearchRepository;
+
+	@Mock
+	private DailyRoomCertificationRepository dailyRoomCertificationRepository;
+
+	@Mock
+	private DailyMemberCertificationRepository dailyMemberCertificationRepository;
+
+	@Mock
+	private ImageService imageService;
+
+	@Mock
+	private ClockHolder clockHolder;
+
+	@Spy
+	private Room room;
+
+	@Spy
+	private Participant participant;
+
+	private Member member1;
+	private Member member2;
+	private Member member3;
+	private LocalDate today;
+	private Long memberId;
+	private Long roomId;
+
+	@BeforeEach
+	void init() {
+		room = spy(RoomFixture.room());
+		participant = spy(RoomFixture.participant(room, 1L));
+		member1 = MemberFixture.member(1L, "회원1");
+		member2 = MemberFixture.member(2L, "회원2");
+		member3 = MemberFixture.member(3L, "회원3");
+
+		lenient().when(room.getId()).thenReturn(1L);
+		lenient().when(participant.getRoom()).thenReturn(room);
+
+		today = LocalDate.now();
+		memberId = 1L;
+		roomId = room.getId();
+		room.levelUp();
+		room.levelUp();
+	}
 
 	@DisplayName("비밀번호 없는 방 생성 성공")
 	@Test
@@ -92,4 +162,71 @@ void create_room_with_password_success() {
 		assertThat(result).isEqualTo(expectedRoom.getId());
 		assertThat(expectedRoom.getPassword()).isEqualTo("1234");
 	}
+
+	@DisplayName("이미 인증되어 있는 방에서 루틴 인증 성공")
+	@Test
+	void already_certified_room_routine_success() {
+		// given
+		List routines = RoomFixture.routines(room);
+		DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today);
+		MockMultipartFile image = RoomFixture.makeMultipartFile1();
+		List images = List.of(image, image, image);
+		List uploadImages = new ArrayList<>();
+		uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd");
+		uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd");
+
+		given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages);
+		given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58));
+		given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant));
+		given(memberService.getById(memberId)).willReturn(member1);
+		given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0)));
+		given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1)));
+		given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)).willReturn(
+			Optional.of(dailyRoomCertification));
+
+		// when
+		roomService.certifyRoom(memberId, roomId, images);
+
+		// then
+		assertThat(member1.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member1.getTotalCertifyCount()).isEqualTo(1);
+	}
+
+	@DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공")
+	@Test
+	void not_certified_room_routine_success() {
+		// given
+		List routines = RoomFixture.routines(room);
+		MockMultipartFile image = RoomFixture.makeMultipartFile1();
+		List dailyMemberCertifications =
+			RoomFixture.dailyMemberCertifications(roomId, participant);
+		List images = List.of(image, image, image);
+		List uploadImages = new ArrayList<>();
+		uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd");
+		uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd");
+
+		given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages);
+		given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58));
+		given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant));
+		given(memberService.getById(memberId)).willReturn(member1);
+		given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0)));
+		given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1)));
+		given(certificationsSearchRepository.findDailyRoomCertification(roomId, today))
+			.willReturn(Optional.empty());
+		given(certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today))
+			.willReturn(dailyMemberCertifications);
+		given(memberService.getRoomMembers(anyList())).willReturn(List.of(member1, member2, member3));
+
+		// when
+		roomService.certifyRoom(memberId, roomId, images);
+
+		// then
+		assertThat(member1.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member2.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member3.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member3.getBug().getNightBug()).isEqualTo(20);
+		assertThat(member3.getBug().getGoldenBug()).isEqualTo(30);
+		assertThat(room.getExp()).isEqualTo(1);
+		assertThat(room.getLevel()).isEqualTo(2);
+	}
 }
diff --git a/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java
new file mode 100644
index 00000000..bedca849
--- /dev/null
+++ b/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java
@@ -0,0 +1,109 @@
+package com.moabam.api.domain.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.moabam.api.domain.entity.Certification;
+import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.entity.Participant;
+import com.moabam.api.domain.entity.Room;
+import com.moabam.api.domain.entity.Routine;
+import com.moabam.support.annotation.QuerydslRepositoryTest;
+import com.moabam.support.fixture.RoomFixture;
+
+@QuerydslRepositoryTest
+class CertificationsSearchRepositoryTest {
+
+	@Autowired
+	private CertificationsSearchRepository certificationsSearchRepository;
+
+	@Autowired
+	private CertificationRepository certificationRepository;
+
+	@Autowired
+	private DailyMemberCertificationRepository dailyMemberCertificationRepository;
+
+	@Autowired
+	private DailyRoomCertificationRepository dailyRoomCertificationRepository;
+
+	@Autowired
+	private RoomRepository roomRepository;
+
+	@Autowired
+	private RoutineRepository routineRepository;
+
+	@Autowired
+	private ParticipantRepository participantRepository;
+
+	@DisplayName("방에서 당일 유저들의 인증 조회")
+	@Test
+	void find_certifications_test() {
+		// given
+		Room room = RoomFixture.room();
+		List routines = RoomFixture.routines(room);
+		Certification certification1 = RoomFixture.certification(routines.get(0));
+		Certification certification2 = RoomFixture.certification(routines.get(1));
+
+		Room savedRoom = roomRepository.save(room);
+		routineRepository.save(routines.get(0));
+		routineRepository.save(routines.get(1));
+		certificationRepository.save(certification1);
+		certificationRepository.save(certification2);
+
+		// when
+		List actual = certificationsSearchRepository.findCertifications(savedRoom.getId(),
+			LocalDate.now());
+
+		//then
+		assertThat(actual).hasSize(2)
+			.containsExactly(certification1, certification2);
+	}
+
+	@DisplayName("당일 유저가 특정 방에서 인증 여부 조회")
+	@Test
+	void find_daily_member_certification() {
+		// given
+		Room room = roomRepository.save(RoomFixture.room());
+		Participant participant = participantRepository.save(RoomFixture.participant(room, 1L));
+
+		DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(1L,
+			room.getId(), participant);
+		dailyMemberCertificationRepository.save(dailyMemberCertification);
+
+		// when
+		Optional actual = certificationsSearchRepository.findDailyMemberCertification(1L,
+			room.getId(), LocalDate.now());
+
+		// then
+		assertThat(actual)
+			.isPresent()
+			.contains(dailyMemberCertification);
+	}
+
+	@DisplayName("당일 방의 인증 여부 조회")
+	@Test
+	void find_daily_room_certification() {
+		// given
+		Room room = roomRepository.save(RoomFixture.room());
+		DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(room.getId(),
+			LocalDate.now());
+		dailyRoomCertificationRepository.save(dailyRoomCertification);
+
+		// when
+		Optional actual = certificationsSearchRepository.findDailyRoomCertification(
+			room.getId(), LocalDate.now());
+
+		// then
+		assertThat(actual)
+			.isPresent()
+			.contains(dailyRoomCertification);
+	}
+}
diff --git a/src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java b/src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java
new file mode 100644
index 00000000..cfaac3cd
--- /dev/null
+++ b/src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java
@@ -0,0 +1,41 @@
+package com.moabam.global.common.util;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.model.ErrorMessage;
+
+class UrlSubstringParserTest {
+
+	@DisplayName("UrlSubstringParser 성공적으로 parse 하는지")
+	@ParameterizedTest
+	@CsvSource({
+		"https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd, 1",
+		"https://image.moabam.com/certifications/20231108/5_fwjo39ug-fi2og90-fkw0d, 5"
+	})
+	void url_substring_parser_success(String url, Long result) {
+		// given, when
+		String parseUrl = UrlSubstringParser.parseUrl(url, "_");
+
+		// then
+		Assertions.assertThat(Long.parseLong(parseUrl)).isEqualTo(result);
+	}
+
+	@DisplayName("UrlSubstringParser 실패하면 예외 던지는지")
+	@ParameterizedTest
+	@CsvSource({
+		"https:image.moabam.com.certifications.20231108.1_asdfsdfxcv-4815vcx-asfd",
+		"https://image.moabam.com/certifications/20231108/5-fwjo39ug-fi2og90-fkw0d",
+		"https://image.moabam.com/certifications/20231108/5_fwjo39ug-fi2og90-fkw0d/",
+		"https://image.moabam.com/certifications/20231108/5-fwjo39ug-fi2og90-fkw0d_/"
+	})
+	void url_substring_parser_success(String url) {
+		// given, when, then
+		Assertions.assertThatThrownBy(() -> UrlSubstringParser.parseUrl(url, "_"))
+			.isInstanceOf(BadRequestException.class)
+			.hasMessage(ErrorMessage.INVALID_REQUEST_URL.getMessage());
+	}
+}
diff --git a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java
index fb7d6d8f..a30f0e02 100644
--- a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java
+++ b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java
@@ -4,6 +4,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
 
+import com.moabam.api.domain.repository.CertificationsSearchRepository;
 import com.moabam.api.domain.repository.InventorySearchRepository;
 import com.moabam.api.domain.repository.ItemSearchRepository;
 import com.querydsl.jpa.impl.JPAQueryFactory;
@@ -32,4 +33,9 @@ public ItemSearchRepository itemSearchRepository() {
 	public InventorySearchRepository inventorySearchRepository() {
 		return new InventorySearchRepository(jpaQueryFactory());
 	}
+
+	@Bean
+	public CertificationsSearchRepository certificationsSearchRepository() {
+		return new CertificationsSearchRepository(jpaQueryFactory());
+	}
 }
diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java
index b65518b4..95732c76 100644
--- a/src/test/java/com/moabam/support/fixture/RoomFixture.java
+++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java
@@ -1,9 +1,23 @@
 package com.moabam.support.fixture;
 
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.mock.web.MockMultipartFile;
+
+import com.moabam.api.domain.entity.Certification;
+import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.entity.Participant;
 import com.moabam.api.domain.entity.Room;
+import com.moabam.api.domain.entity.Routine;
 import com.moabam.api.domain.entity.enums.RoomType;
 
-public final class RoomFixture {
+public class RoomFixture {
 
 	public static Room room() {
 		return Room.builder()
@@ -22,4 +36,108 @@ public static Room room(int certifyTime) {
 			.maxUserCount(8)
 			.build();
 	}
+
+	public static Participant participant(Room room, Long memberId) {
+		return Participant.builder()
+			.room(room)
+			.memberId(memberId)
+			.build();
+	}
+
+	public static List routines(Room room) {
+		List routines = new ArrayList<>();
+
+		Routine routine1 = Routine.builder()
+			.room(room)
+			.content("물 마시기")
+			.build();
+		Routine routine2 = Routine.builder()
+			.room(room)
+			.content("코테 풀기")
+			.build();
+
+		routines.add(routine1);
+		routines.add(routine2);
+
+		return routines;
+	}
+
+	public static Certification certification(Routine routine) {
+		return Certification.builder()
+			.routine(routine)
+			.memberId(1L)
+			.image("test1")
+			.build();
+	}
+
+	public static DailyMemberCertification dailyMemberCertification(Long memberId, Long roomId,
+		Participant participant) {
+		return DailyMemberCertification.builder()
+			.memberId(memberId)
+			.roomId(roomId)
+			.participant(participant)
+			.build();
+	}
+
+	public static List dailyMemberCertifications(Long roomId, Participant participant) {
+
+		List dailyMemberCertifications = new ArrayList<>();
+		dailyMemberCertifications.add(DailyMemberCertification.builder()
+			.roomId(roomId)
+			.memberId(1L)
+			.participant(participant)
+			.build());
+		dailyMemberCertifications.add(DailyMemberCertification.builder()
+			.roomId(roomId)
+			.memberId(2L)
+			.participant(participant)
+			.build());
+		dailyMemberCertifications.add(DailyMemberCertification.builder()
+			.roomId(roomId)
+			.memberId(3L)
+			.participant(participant)
+			.build());
+
+		return dailyMemberCertifications;
+	}
+
+	public static DailyRoomCertification dailyRoomCertification(Long roomId, LocalDate today) {
+		return DailyRoomCertification.builder()
+			.roomId(roomId)
+			.certifiedAt(today)
+			.build();
+	}
+
+	public static MockMultipartFile makeMultipartFile1() {
+		try {
+			File file = new File("src/test/resources/image.png");
+			FileInputStream fileInputStream = new FileInputStream(file);
+
+			return new MockMultipartFile("1", "image.png", "image/png", fileInputStream);
+		} catch (final IOException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	public static MockMultipartFile makeMultipartFile2() {
+		try {
+			File file = new File("src/test/resources/image.png");
+			FileInputStream fileInputStream = new FileInputStream(file);
+
+			return new MockMultipartFile("2", "image.png", "image/png", fileInputStream);
+		} catch (final IOException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	public static MockMultipartFile makeMultipartFile3() {
+		try {
+			File file = new File("src/test/resources/image.png");
+			FileInputStream fileInputStream = new FileInputStream(file);
+
+			return new MockMultipartFile("3", "image.png", "image/png", fileInputStream);
+		} catch (final IOException e) {
+			throw new RuntimeException(e);
+		}
+	}
 }
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index d49e1d93..c140a299 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -20,6 +20,21 @@ spring:
       host: 127.0.0.1
       port: 6379
 
+  # AWS
+  cloud:
+    aws:
+      region:
+        static: ap-test-test
+      s3:
+        bucket: test
+        url: test
+      cloud-front:
+        url: test
+      credentials:
+        access-key: test
+        secret-key: test
+      max-request-size: 10MB # 요청 당 최대 사이즈
+
 oauth2:
   client:
     provider: test
diff --git a/src/test/resources/image.png b/src/test/resources/image.png
new file mode 100755
index 00000000..0950368c
Binary files /dev/null and b/src/test/resources/image.png differ