From 43d18ce6f15a3e7b07b8a021252806b9b6b86dd5 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 13 Nov 2023 16:56:55 +0900 Subject: [PATCH] =?UTF-8?q?=08feat:=20=EB=A3=A8=ED=8B=B4=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 서버 시간 체크 컨트롤러 구현 * feat: 루틴 인증 기능 및 ClockHolder 구현 * feat: UrlSubstringParser 구현 * test: 루틴 인증 관련 테스트 구현 * refactor: 방 공지 길이 수정 * feat: constant 및 error 작성 * feat: s3 이미지 업로드 기능 구현 * test: s3 이미지 업로드 테스트 * chore: build.gradle s3 추가 * Merge branch 'develop' into feature/#8-upload-image * refactor: build 오류 수정 * test: CertificationsSearchRepository 테스트 * chore: s3Manager 커버리지 제외 * refactor: UrlParser 코드스멜 제거 * refactor: 코드 리뷰 반영 --------- Co-authored-by: ymkim97 Co-authored-by: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> --- build.gradle | 8 +- .../moabam/api/application/ImageService.java | 54 +++++++ .../moabam/api/application/RoomService.java | 116 +++++++++++++++ .../com/moabam/api/domain/entity/Member.java | 4 + .../com/moabam/api/domain/entity/Room.java | 13 +- .../api/domain/entity/enums/RequireExp.java | 43 ++++++ .../CertificationsSearchRepository.java | 23 +++ .../api/domain/resizedimage/ImageName.java | 31 ++++ .../api/domain/resizedimage/ImageResizer.java | 121 +++++++++++++++ .../api/domain/resizedimage/ImageSize.java | 17 +++ .../api/domain/resizedimage/ImageType.java | 8 + .../api/domain/resizedimage/ResizedImage.java | 67 +++++++++ .../moabam/api/dto/CertificationsMapper.java | 34 ++++- .../com/moabam/api/dto/ModifyRoomRequest.java | 2 +- .../api/infrastructure/s3/S3Manager.java | 47 ++++++ .../presentation/HealthCheckController.java | 8 + .../api/presentation/RoomController.java | 12 +- .../global/common/util/ClockHolder.java | 8 + .../global/common/util/GlobalConstant.java | 3 + .../global/common/util/SystemClockHolder.java | 14 ++ .../common/util/UrlSubstringParser.java | 25 ++++ .../global/error/model/ErrorMessage.java | 11 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 4 - .../api/application/ImageServiceTest.java | 62 ++++++++ .../api/application/RoomServiceTest.java | 139 +++++++++++++++++- .../CertificationsSearchRepositoryTest.java | 109 ++++++++++++++ .../common/util/UrlSubstringParserTest.java | 41 ++++++ .../support/config/TestQuerydslConfig.java | 6 + .../moabam/support/fixture/RoomFixture.java | 120 ++++++++++++++- src/test/resources/application.yml | 15 ++ src/test/resources/image.png | Bin 0 -> 52270 bytes 32 files changed, 1153 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/ImageService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageName.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageType.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java create mode 100644 src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java create mode 100644 src/main/java/com/moabam/global/common/util/ClockHolder.java create mode 100644 src/main/java/com/moabam/global/common/util/SystemClockHolder.java create mode 100644 src/main/java/com/moabam/global/common/util/UrlSubstringParser.java create mode 100644 src/test/java/com/moabam/api/application/ImageServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java create mode 100755 src/test/resources/image.png 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 0000000000000000000000000000000000000000..0950368c3e8259058e328e9c713cc71766f4da93
GIT binary patch
literal 52270
zcmd?S2~?BU);AvKp#_U5s7w_K6cG@SF%YUEhzzMxnTJZ05JHH|Qv%-FGLu|InISDx
zL?$7DFa@d%DKbO|1W6=F5F$eeAq;^K_{aA4eeZqWcYW*oukYG+t@m*)o;>@UXYb$H
zXP;@G{Tz2*@4N+kcFo$>8n9~@0I*B)53s`oTn6mlyKn!#z5Dm?+kfD|{)2}{IL@!<_
zfddB)A3Q93_^|Akr{qq3`TzUc`57Q{aNm!I0eg0-0(Q&n+9R`TrxT#~0p`9BaR1}l
zwMPQ?!0v;RREN)Y0e0`&y=UK^L;LqgO0j1jK=QO#X5VT3{YNie50*W4>wxNcgTFm^
z_*3l-r*A@@4<1*$bnt?2#^ll$-xcxYP0NbvkBOMlBjTpll&b9;L|;l
zw`BIn04xC;A}8F1fb*SqTCKc&RdqlC-u%4vG0S7QHS789RLiFsPiM}Ex{(vNw{FD#
zb}QLk)9NQ)1b1mlZfoGKGhh8jhJpAG`95Ued`Qt+&))&G^$1I?_93IGqJ?%0__y0P
zU)}siQpxXGuI}^skcLb7H484~n|sc`CiMz`%hLN#T(0v>cL3-N{(z
zw)s`gKM1=HZ?8VVZMXSQw9_q5uww#GK7$D{1UbdQ2Kz8-z~~q8T{1v06~f8hLE5sP-Ww*^=xNu+{A<=bi>vKYDiDng>5jo&HG9||joGX=K=#7i(te0on*#YE8bOf&%p$`Vk5jrU****GVJNRk@k-?}G7!3G@*z-23zWVUi
zzewBF_`7^?dq8_Kbq8?X`RBl<^>G)zY5R=r2N@D<9{Xo{aT3w8648aCr`wvwTRk6W
z*bfk8@+1oWjjKn(2ik8CKjc&UkneAGoDUHFBsRBLA(U7%#(8VNZ|3E;_yeP{Er(IE=^6;$NySp&GvvDK*|Ty
z@y!!CeMUb}{*%zeW3;mgJAgjiBI)((|40&YM86eb>K~kbl^(lRBg~%=s3Sk1_?10J
zqSAk`m%LK-GeZ{cP-WEG$ACuX6wKUtxA)$Ebi03@AG@n7#2h^Vb*`kmdU$?*K0R
zW&~;N{gzMvw;4fMv3N>P0G=x=f3mrS`(LU6&0iIOn_rHXn8K~k|ALM6BSI?wt=u)A
zt^fYx=)I39?ytvsmy_}6yKR{Go00S4@Et&I{LORge?62R6VX2V39tAW+=v6uc+_X3
z^sVOKKc=+59`D%x)zNKObYf+@L=9QlhrasjLHwAA*0bhowuS^p+~S&d0R4LB{iVz8+hS((4X$4gbf|YVYxPmo-sO9LJ&Yd_(Jr1IT~hm;
z=kuC$AG$Yccl1{uQQBXS^?Js<-n<^@h#RPYe24S1=A*wL#*YZ;Nc6Aees22Vro7Ai
z$B!uMFUUHkXN5ce<&wkp|IX64(@0OvW#jS4Z=wzrehs+y7p$R=2I)YB7#LIh+<4P@
z)#hH#G~nPzlk`_)9Me98`{o*Qg)?$gbi`#dbS~Lp`!V^tD&i&J
zvwtu5zc{zmcs%IIBj&8fpI-5YzWX|g`|ekDeesq0gI7x+_3Br1hPe*Pfz5%6%eSi{
zQ+5Co*bMF0LA`!ly`zf*!WGwu22|ViX%#*=k@=3-EM+9EQrc1WYAVe{-+H(-z9yfV
z)*EDQ5Dywqxj7&9${tdQMDmYhX=s?GQ>HK9n|e$BDQ^}wJ~&swt?dk}T&af)#n?RR
zaDZoL7nIUzoa$nw5L)uRy~ck`EozKIhaXN{L)%`G*x|K5M&|z@WtZkpZ`Bm9alBIvXeVN2GFQolO1wBUrw7(ld*ltlLlF9&mx
zJ#X&DQLS5SpEeZhDv{ab@d_COQcSzl8YV92r?KjN#S80)%Tlj+ae-ofu`4
zSjjU+xqwfxj#m%$S#_i2@s_5^jJTe$fJV?bCo7FQ2V~xKXNwB%tMJT!VwVO5gtvweS&0vdB_U
zNe2DTHZQ}8sHDgv58L`FhbCn@!!IyBDr@>Ds?EJ1T#^siEz~E2wQt*kVSXZ{b0AB8
zV{A>FREP^)7WrHRo8~aoUaA@;Vb#+uj0lqSo^G-p5iRA{8rS12so=gqdUBD
zod&CFI85DK?xwYOYmbg1Q)~MA1_T4BVUsxQ0qIw;758ggJdM`m=!NowP8k>&%vHbl
zrHmcP3J>}T5fRkqmP~Xu#zFlyR44U+S{Scy(r!S6uVWL;ma%P-xH<)D
zBBjg5wvoZ%vYKU!CdjQGBPAL}t*@ory5yy(_C<5gCnJt4BY3>4W9=y7AyYj%HsPh1~?pPOBxBQn(4k1IoSa+c8g9bp$HTY+{z
zb&x{A7=50yffAHkz~Lfkqp`>(L0l=_QOVr5a)TuF{81if{s!>Ljq}dKg;~ip6Ftj|
z)#QF>_YHT50Bvi_Se+>2<-}f02-^zvN{E^2j}g=A%F4_;$@r409Y9EXhw|=QZma6B=>RKeefic4%NH%$
zDI413)G8bjF$G&(sYElW+kSn9i>)T&NS9Lxbv%ouw%IVzvsXHG=BUbN2k-6x9&cWW
zy_M+;$~+rN+IHRUKi!6^vmSq1x$zeN{#AY-uGz*cIca)<>c~7&u}!V4RVBr%V`jljjV@6AN#Ony9ZAB#mBn%=;5Z>XHs9
zfjri)Vrfw+&;oqLIc=g{K_s5{*T)Okw
zpJ@vFA$6X{TRy}XspF)SJH0Z|%Qe{PqnkDXqX9#;?#@P4kP@qqB++R>;%$@4yN-kY
z^<&xw*yGzM8}EhXf;OOiTPiK!9Q;D#s+l>lAK$CA5Q#S-#@k{tWCt|lh%{wy?UC>v
zqZ01g4q$a*A1U2d>6Qw7MB9jXC5E@ROf7n>GtNz?J%DWvzppaezUq&_PVY%}N?EJ%
zdD0cMcA`C0af4nvo-Fb`HJ=mBqUlw=*B{U^)H3h9sDv5^zY8VQ!hJ9S6_(TXQ#32e
z=Gw-nNHrP-eLLf>C@<+w-cn?ZdZH?0`r2|Q8Hy-_#Mu=SP>sV?itO)QN#JwGc_e56
z{A5*7MiZY2MJ_ciy}RdwlFYI{SdIm%Xa^vMb+w)3s^q=*kslDZx5qbly2o!{nOb~|
z0Drbxn1Nlbu}o;)oj3l+bRWux9@$vEJZ&@$Q2!Vv`G3Pxit@}%TBcj`W{0qIl4cXh
z@EvjVr^I;RBVp721?$X?pz6o3+xq8Yf#v=7o_5lN@BPx*58C+1Ppm?*iCUbs%irIc
zV2t|n9L46_($lY{E+5YbQ?X(2*F3zb)zjNAcy
zDhYMk?3S7db2E(%*qFkd6x8@=h?c<&#pZnZx1N^}rwY#6zs<7&y#t$p@vcJTlI!!J
zF$S9DG92SGJhl0H1FtU(`&`~z;*HtGQvuR7OL}L#2LmhwnE6!|GBsAbmV;L4T8*20
zVc6W1jfH%-j&)6D3dRH)b`CWL7JfGk4Q5Vr$(pGF?|QZ_|9rT{_y5KO{@?Rxe4S%c
z_h&8Q4zxybW3Ry6>hUF32dACgoqn2DY&-QGIya(Qq2
z0))8z_f6v~9T!fWx)K(nV{`)hnxY^UL%EMRF%cZtyYW-7^{gYB7255aQ9FRvMkq8^
zi_tGfQm(o9Qyb1Aa&f{Ct~8N$r5W5cEYiMDG$}R|*;IDn+FciK_-;3z
zRB_f%Yk^FA8QY0S1)tILaM*=m+A({P#XgvAPGJkh1FW|&PnZX)asBgUVB;*>>Q-|M
zy}mLE)mt;^zO52nMzYJ>SKOhl(f$;ZcoTQIf>J)jdQD&x-Lo(r_MjhoF*&u4=EK$Y
zq4caucTKux#y3+Ie|zf*CI@_5pbXmqcpsWxqsN!^8`0cNvSJ0pp}*6Ptf`zq0)$X1LeGKTJMYa3d90p#kJ0Fh&{<@ldZ^I
zuPNz=I|8E_1Y`@Buis5)ER75cZU{9t?RY8^JAgMv)&AGyy+4blmW96cdOhS3%2V}Lcl%g3ob
zJ~qX}^P!{C8T;+W5HGc__4SD{k2~V7)nr}4_tcEWDqZ1_GmgVpDRh+a#B7{n`p=EJ
z_x}b%Cw@xnHpymeXhgPw-L?x9%DQ#{uoxT@Nm_s#z
z;>VcS&4oFO`~}kCwexEr!LxdM!4T20
zA2#c^B*Siu8VX3u6S(SrX6h1%?y_$Q7QPv$Uy{SKmhv`ZFn0hm
z$?4nr`sQztKB>A=ekk`e?_NVaeN;RFxDv`^N1+u;JEu$Bn@`J(@rY
z`+pxD{;xP3e4k?#_WjF0#R%1Ta=<%1CuU`hNM9!LCyFWGH;?vc-hs%O1uve)0Cxb}
z1DTkNp%8RNt4+7@Qu=bKAB}hhUjJzDRz68GsNc3Im
z#g2Vo5y?`E#_F``wAsl|I(!9fa%8p+wrVn^q4=P_+(5q-opE$}{OQK|9=SIBz1#W3
zd9%O<%vSu_<T%t!l#UnTS}
z$B*@JxC3Ullw=EcMlvEWT1Ytp{b4B_7ms7D;b!
zN*2G+rP(UibZ@V^IX?(42J$$5nMbPZ1(ENo1R~AbkfY6MbAo9f`+f
zS^!LH?SjF<*SZCDPQ4|9q%9kUe}x@l9p!_QdS{aXef@$4I6{5M-EQIAu(K&y=^6X)V%zV#u~Nj=PE&w!P0A5H)lcs*>Avuo|eSWfw&zDs$HLCSPz%XmriRkUilDd5mAV!-QL~
zz7gEe0)Dke<6q+f?QAx#FSG_@l+5j}TwNEdYqrab>V7HqBcm-;HKQE-;QEFIWoGBd
zzN-wE3ue9V1=j8$mu!Zg5%ab&_OWh1_`sC%dI@Fx6h`eamqt
zxG{Wge%OvWb_aj--e+(A=eqc}Qva+D<*4GQyTyKj$3v}R+H`+xNXk$K?18MlD~qKZ
z6_?8($4P-qcs@Gp2P!ACXpMJi4)^77@*|U_!J|X9#5jz{Ys(CAL`E{K5huSmJGF00
z{oHaMV{pKjS%O^KQgVjo&J<=S^tEc1*S{998f!hGFkkQPeR=2-dKaWCWT@HW#-JRz
z*-D6Vy~2}gopAHdVQI^_x?LjUqr=aQHvApXRnL>+$hosrHIaw>h>R`M8EwnyiJ%PH
zLZ-A@bW#!v`rKt)5arum)>i?;oT{6u+6cU?(y<&+p-k0PU=Yl!f;`Zb*a@9M#G9>b
zk>+hcPvt*X92ol$0Eq>^m`Y!gf*wh#eAny6k1T%w4inCqobuW#6-3~r9gJcp`)$jv
zxx)jPk2)z{Q_oj5-_OKrqchW9S5b(D7;$vKtTM>WvP7IQ1-v;7nOID$S{Zdpb1pEl
z<(tRrT7Tb%82%>qj~JW&96Q>jlUGZ|(j)S8co@ij?6J<<)VK!*lQL75`w2>M(mR0V
zTPf$(G%penT%C-w+$jSDX_fJ8ETv$L;aN?nZb)@l)fa$HPkkS;=ibsB9r+!jua?*^
zpz7=ZGELEZf^ctXm9tD>L2_NmhLJ}pSwba2*XPb74Yq3vJ~8Yj=-Q8QO*JCZ*{Z11K~?6>E9n)V?P@-${l46Igk7`ckmBbT
z(<}dcMIhGz@1*Edw9oaBAlSpF@_2~i*Sl(6?*6m2004Vi?=+ab?KexRdS(rbbtNKY
z9q>`v{B%;R0~BsW`MwB6pae0k+5)(sEZd@~+)8uf>t5C=dToji3lgVC*8}xQVA$eT+siu)q@gu9%nlT`ytp8#!5n*todN#zx=oE|4YXAAJ)_+Cu
z5uSdX|JoOsI5d6r$Auv8z8L-DF09`3vb0iA@%S_EK@Y!_ND)qngt?>v)5>+b&P{Cjt>GJu!Hyzu*5Bm{%|ED68JVbThkW?BzIEn=j$gn`
z?~1kq37a>~5B%hly9<%Sw|ip25wuq4_L1tfcRI>Yp3P(T);9!_kgeM!Dln
z75ZQ9%6;}BTfPI()41_aI(H_PFjefc@^k!<2qg;C?CKqAxY8{kGU%$bCgOzVp-K$*v`M(JJ
z_dnWL_4b7&mRsHZBR4K&6Vsw$u}!QkD5NNp_WKt=*BX)Q4o#;5N9%$iZr
z6|AHQv6r8P>dCEws)hJn8~F-}F4rGsyj&@<*=|81d-9#4SWqa?`0HF&@Fc|ux3)d6
z@(^*r|H9TK1BdhMNn@-4Vti)e5m#T99%nf|GWEP>4M?o#f-i>YLk*-aX1&_gx7V}h_$s)Hy(0!#B^zf_AWmr&F$PfW*m3kP_pCqm1Ewqcc~(?$SF!tpiMxd
zhP2L$Wp=&x$siSC9aZ~S%EpzIMH+~9aNH^3(vqiFa1h?A^bb_3tPpiJ`x3c|Cw
zkC$yfawMQ13j6BNzj~(+`Kz4uRGTnSR#wC0%?vV|qOs$QXtcy2mXR-G$htZa8IsCV
zn55cvA-@Ofh#@tvJJe(6&$ZLSol1sX^f=U|jk{iaw49?-=c>E8{#&Ah8niEFQGreOXp5ZqWe+@Ucg(=gdP19nbbcVg
zLHcB?%~0#WNKK_=SzD;|Vs*p+0GHN4Sey}<8wi#_$RGX=W%8MHo
z;QGNgpQc@3TV6rS{>1djK-KDlm)|Ku@6VfnV%YQ{En^lehT7jS9=!vI#Kq9ELquMAL`4OMZX$?R!K#&IRm}q337k(O^6o9^D&afs8(A3g+`00-
zGAvU5z}(1aq!SFQ+va13*0BlKQF^f4u@+yCI*{-lVwcdgbR&ZpIcs1nl$Oj
zpCb`?i&=gHF#b+L+`t-mA0lu}{{{hMmYjtBKGapD+d;EXMWoQL}(HC*_oNC|(
z!^=K;wK*!qVOB;e!xCYlarT?m0CFjk*5pQ-ReXeGR!iBl2130dNc^#!r;l4MkLA!}
ziJa#qnlla-`Rj4qW|XsgTwjFGb{tvTWvJY_8!(h=I0{LazU1Z`vAC=CbWP$`~$d+S<;fdPLaV^7{Y
zk&Aoev-xM<+s>7p;JJauXJCVKHF;hm!F>)e4;!|ZtX;P-sOAd@ZT{}<)^5K0yX1l_
zfj<$^iqQR}Gs?03hucgSd=C~j{jio*Gv*)jyjxg^gLTvlTSdp(64A6)BggM_I7*M^
z=X!k$`<=Ct!-Levlx3=V@^n`C_G895WF12e=ViorUtwMT=vS*zRuFK2~WVO!$uf@hdK
z4mcn1&kbL8o_$XdKAo2k+pgSEQ|hNxL6LOBe5u^An7hGz4{f8XO#{m*Pa#_y!rBR2#}r|14)$H%U-96ym>
z7w}5#4j^%My~}oSLWk3Gjrxd+dtv(Qar%rJ<@w5i9!cZZUy?q6LCURj9UvMA&ihV5
zN+-&gGu2`q#w*Bgs~Lt|yx(RSw$vS=`OHP)1Cyb28lKvGH|*Sig{Bb38NR-eIh?6?
ztaP!>rAH~&uQs|#9$9Yj<+-RI_g{l~F3k>(UYBPZ_M!~aMcEq;_+Io>7T)k#s!dqn
zSn3y3M0YiGUP2zpbR`NeoqDmX>pRfX+SW+O6v?Ltj%(P0>xf4BQ;)^<>+?1j=~w;J
zb-xr4N`soJvbm{Tl>~?z!&|?yYe6s{;$X}IzrUR>jEI*H6RD-jS&b
ztD)cUg;fqaLrS{sLbm8VkTJIz1??xj-a7hXw?K@da;js9`ihWVLwZ$Ji
zD(?0*p8h4wu^Uw|&33JYNA+_{hrEHW`INIcYE;}+oT*5yGl>Iyi;-=_FLZhj;zhf
z3*{F`jsZbl8D+Ig7RFm`@R8;Ohagyvxb|mtse!NlkJ{YLOnJ{qS|a7Lc
zA$~b#2Ve(R&1xuQZQr7PhLbouzW+Z;{x>ALyr&f1nQ6bIT>1yP;rDh1Nm?5(D)?Q0
zZuYaGvGc4!_ZzjX44P!QIdErX{TJ#lN*4$vQ|QLh5KelX?_{WqAwJC!$-oKCY%p7s
z)$1wN%L9+{&h7xlrrrgepQ1(J>vIvaz}v6<>qZ}~WcNnl+Eim!mnUZ3hHeqJ7Q%3#
zYqh}et-_X9#m4pc@b*&M^fF`DJ8xT!jkZUx;7I~V@^+dZzBXSLBrrR3BmQj;epUyg
zA@w#b3!{$LNCiJMBCEz1Um8W_&mvTW7S4gr6QyiDm7#beLT8Ii*Ge8!&MW|?Y=Vc0
z@S8yku^Z-<5c)K}ZPBrUW~RtzlLXVKvhs@wPeIL22^PZ1$spDsYHabn>Qj;M0
z?yV=5>G1((@i3S<^4%Jt%~2+=$pW3}9WK(WjDriCe?YLem7Tn1&4on>eBpb^gvyN5
z*fjdy&=Y7rNuO*}ER^w|24xI6vnp1J?#79adsBo}%qYg!yWM~O-A;Dtx$-A;iW=wP
z?lD&&(GQ`wJ00-;(I>fjyQ-cIZRU?w%^2s>YkPTokNHL;dpFoA*9LcYBTj>;!zw__
zfKVKl^`sOyQ+c(qzi9Z|j+3E~{P4VW)79~yak=D4Yhev0f*AO>FYR1^7YG1+qHFr%
z8NW@Ye3Fsg(o`Wp=(mUPR}gF9X@gIuZrdXjj3#>njnf_#l&R>!SDM1y`xt?%xzkgIiC4Y=x2Wws>mPdd1^9L@r?dWFi>nYC0IR!;(UDK<2zxe!;9_EfV_6Pc42CG|R
z9gMykBt1)>MHO0X%uC|@!B`9Sxq5bf9hXR9&bVv#;BIk$j>=5M@cpY+g*uWGyby}D
zK4PQxZnG{31+i%lMDF8e)=1Z#5B9Py*8LLm4dt4ZqhPy=ZK;*O&!~NKbaincQ85bX
zmW>;!Si?Qbzz}vLavSsFcRU{F4QYXgitIcZ;=wF8vie#(DecN|zRo(RisQdc?B!6Iw9g
zY=LH_RaaY?!1A%%OYM&5E6s}+pMXH`#0^5ca>=BQl+PUCK?ubY9tGZ(v?+|kyff3eS{hC4u_?%rY9h&8kCm+jTlYI%qK8jv@@rhnHwudAP=qhXE5$u_dQ(<_%>%fPF?TW@n}CytAWE*hdfx~Vf{AifT}s|?lyE19m=M!3}@UDL&yH6=#Zu2i_D
zf@4?)G7UQb-@;;x3gBh(P;-R&qs=U=)-p4GH1q`|7n1(akj=Sxx;@Vx{{qfV-Ag4z1?n;cc)aRp6AZ+h30d69wP&m2J`lt*RFS7
z7>~&BFTSZA8q;&1s$|foWn|j8BqN+dr!Uf??@)_D!Z7%_pm)IMC&pN0iMZ0xM7eI51yS;gr@;~0I?h;7W&|~5
z7;8bolACS5{o;1|U;nR{Bnn%5Q_7k^Fd=-4kIVHWplX%iZqw~$
zV)U&}6}vU8a*Z)mm)T3~jdeR2^2<;?Z
zBNJvR?EOXR$kl(a|46q#qc1+b663ka^^?-TN(tsdS^DfQObcTzhoP!g+1K-2rbXgA
zAy98$Fo&nkHByfb4^)_-2PKBG{ds9g6*G10xn$l%!c}j$~LTN_D-y@=vm~d
zgP)F!a@tc9Z*zTNM7|5=`)mU_6$=Z5E*u`2@PG(
zZT)G>3iHjwYdmb0ap`7-@kmMlhl`7Z4$s!kJ@b)9h4o-0UBO^0_;qQAiC;#-t$cUI
z6HAaBmhS^eF#j2pLdtAZ1E#q^T{K+iB>NH$a#B9}OVBhp3|+S_+%9ye8--1bzt1XM
zxyVZPWww5ilZ5eSoe(2sO4T%Uj6<(il*VqqIB(?+i3{BjmZ{$PVcGdX>&xXazDv;R
zyh5K95_IMPaNyOXInoKn&f%p>qZJ*&u!Rs-WU7tD#!9%O6eC@_y4lmM3T=eHfa-Ir
zFw8?iUihLWp}M)jbe-iXe>0Yaw+EXTv9<$ffn>8JF&>NAM6lJ<@|3bn8sR~vPpI+B%ot7VbR-jt!K-kEENwKvfg?v
zmTlz{1=tgX55J2JpPn0f-)FT4WXt
zFHOS+v(B={Lc09OOh_uK*QQ(9&`b}3FFP|AI|o_oB;G-p=1yt1oEqs1O3>pR
zWR&W#&RiI);Y&$-$vG}Mlx3_Sl{6H-!;lnj4??SIyImLi*3FROQiycyjfl0-aIt}0
zfJ1R-$2IZSt3V{sit+-AF7$FOU}+38fxmz9@8IX_3wcDbUA~KZ3_9Q~!|<4<(OODI
zC?txEVnyrL9HnI214y{Oabr@yQ>2-t13_(S$Hol2nIXL7Z-U2kPR@^eZ7cC4AC@{i
zP3aSou)@0+4OD^;>y^CxNFDe;*zR?Ce|fldRPyH!+cw<|sYLmFwF9V=KR9VtJrwMy
z{|7=EbYu66J)#<$d-iIY1VyiICO
zjCKYXHt;L^P;cTld?Qx#>_als))+0ulhuNjAK|(j)|uyBMjVL!paa})KLtYOSj
z|0$y5en3?)3RXhKa~
z-J1NR7t>TR;q7zj75x0l0s&98^ka5Xf55_HuT+l&qpi(P3T9V?RSWFLqzFG>Xd$0N
zdP5wHs7Zr`46!y)#LCY8^H$&(a}qN`^%+a_)BtP
zI&~!6@=3!rR`kZwd)(IZve;W)i}dFnTW@mwnCr%vS`Ia+DztX<4I^&&?cy9z5*}`!
zMY4n`ZU}^(_A&K5vY}*ytK(aVoZAv`4%j5R7NPt~nV3p*$(G#Ydux_}e*2g{UckUn3^#
z$Y+DoG-#!d^0j_H&6m#l-V9WsXF&w+fa{R`4c|D$UD^YBKlXH(V2W*WBO8DrB(8G|
znE8ouTtlk$(uQuJb4N5KN^o#j#tj#DsR##oVt#Nd!zd|dx=kFh)i(0<1f$lU;h}%0
zLHkNM(9TlnCk%In;h&078n0?aPBpVkSRQ`QO;w?CrmU43trmGqW^YufsvrHsqL#44
zl66oo+bGKkSzcV8_St-TyX@@sj;50SD!RL>4$-DLjc*a;%7d*K7kZZY`f1|aM2F`Z
ztG#ae<=RrPK~Y@AD$amgPL81P>P-2nw&Klkv4$Jx?6Xn2W3jTAk$+F=$n6s6wxP%p
zE|5|}wJB7q9Jw_#N!D0tv(5EOwHCQc;vAN-a!xMA&nvKkS(apTSn>^(g}2&e+rilC
zz^q=3W(?y^S)qqP^)+i6!O=mtD7HdwyTbc;$2rKFdk(pd63GVZSK7o37ql>~2Q6=_MnxQ=
z;PDX%PZc?oE8kd~KHyxqp$9T^O8U&Rdutec_9oTJe>9Tu0pmQtua{
zzKifCXKolwnjkUSmGm2i&4n%N%U1SQyeaoc?RmE_ZFkj(9L^l)4kkb
zWMXJKzus(lY%(`+Q?WMnB7XQzPU;ms85tNu4Lzd?W3C{F1AGZ%{!7zgap|Z=NuQ75
zk1N_}I~;(UfRCoU?J7^PR>THw?}8_
zyyd>dtY>C8{x!((?c|{@-?bhYS-FA@8e&}(6Ubn>_*!;VCNb4(-<4)#top1&+4)v{c5G
zvf7tsQ8o_S=Vw+w4K_@TCF4pAL+u+n%TN|*Z
zp*sMFpaIE_WHp9xf7PHYXPJetH0%N*o>HH1mFt?5Ns-4zk1DBy)q_JI-4TU8$^X;v
zGVve}#t%O06-*7`%P6u@k*iEz;GlPX4`&0b>%(X1K78GBprrpPS6TtKDwFU!zE85H
z&LV55@sdPd4&G;&ihUw;rXiM^||V5PLnLb-kd0$K{MqubU1O74}-p%CagQS8*nV!Ipt)Hc|P(
zpNehp4HkE5{F&jO%dw*N-q#*`-x^TIR5r!sjORZf_N$_NHKz!^U0F(`((|M0t>Vs)cQh2h``)WQ*;?3#O
zAE3E%{Fza!5Wau7F%I^WKE0O5J5G72WhphHd6`w&?ciNa&z|QRC5evt)ng-zs{;uN
zYueMpkghj8CXCE}pddLWVhpTIlJgR$xeGiZ5w6S_AyR)zW&8(fo=eC1ZYito;(d6s
zYnwoh)5ZrKZJ(5l!o
zHhBnr2cgq29EQiWnH!90d1fP0zqnHFpPkzm65t8Gyl4uy?UB#3-m1{ATg;Dll25MJ
zcR<09GoW18`TC~thEjq(>X*e)f5z$1{b#eeoWwol^I^@Ev7_rcUwK8p{e4t8V7?JB
z6zh2C#}O;!La2u#^~Ip9%C^CJBeQ+0Y<9}&H8~JLJw`i=&LGX6e=E!8aMG(cZOWF#
z4#%uSj=$_GpZJ~AN4Z~#ON&uIzN!EIk
zzV!oF=B3W~@w^-6Z7Gxsi_Tia8gr(nQxNjip*~mvUx0
z_~TwC`wv3~`Pxn{Gc%>&Za}g6D{p?O>A@t4IZmg)pJ4s={1QZV1{$DdCswf
z-Y5oY1fC*UeJA9PUsmEz9LK}0f+%+#EsKXEC_1wf$-{3;we<%y24xLC1U#wZ*D5Ro
zYxlk2@9K-1G#;~JtiqCn`V54%m#+Kt^XM|yWX9m+OUPz9vfaD7TgUu{cyMWhPO6x=
ziQhElpjox7*F29K^r{LV-B)sC(Dy=Pa7NqdP6Iw`nv1)M9izr=7`Q`@g=3t6jsjxz
zYJj<=oP{+>`s@ZoPBQF{<}m&Fh+Jvf-R(Vr?s4*oy7tFgnII1YtA}ocwPn7g+*BIj
zCb2wXju%V2_Gf4!e4{qPKOL)`<$LJDujtc_>#KlPHu*&ABVD!9{*Gc>)7aX~!l4|U
z1SCcrkwgqD%xKSgnivp!D^;n^AO4+zlSZMh#LG;&pPYSd9P-p5@pM9K2HiH
zk;r`>=rWqJj6v_znr`g%=Sz~!_fzi)A~Yyqi|J4VCAZNOD3Wh$G|ygXQC4pLG^=AX
ziHX<&JROMAJC=4m>y4p-Ek6ah8R{5xsPir?Bp?(P;%Efv)wKPiYH>9!c2pEGK3pQ;9}AX_`ba)dQN
zy-v-9V6tvJe1RX?(u67_aMlE(6U978V0iAD$jEo(9VOR**V?(bxyPZ3HmBwBZNL2k
zeLCSNziZ`0BTl-%gxVge9>FpP>Ds)mmNB{p8yfmXt1j^+HZIR{t;ow?8l^jQ_Bq_bO&pH8Qtzg#}vwY>Hv$
zA5~cF5aV8(B5?2A?iLv0T2>HmE*l>oS{s!^MNN_Q6XpC%wwRqfgV7X_>TE}QYh%1VniP0pY
zPGa4+Rt$&)iE+hsrb!ev$|M>W6lg>-iK10O+>j=U)VQFcs1PBFOHdK_UGqNeIrE-*
z&vmZ%oNLanbIn|xKfD0txu5%fyqE9t`Fy^Ks9kIP=0kT@Zejs>6E7N@+v_OI=!^@o
zEmB#Q{%ByGRDnt2qSV3mjk)#wWmWyWqcTq-S{-D0FIq>|LCO1|oNtG@2=-<&hT`!I
z>?gwPT>o8s4OJtwpqR7*C?v@_mO(DbT_cm@@~H2M4aVc864lg*DMyF&(@7AmVZk0
z7Cm$Tqkgjd3Foj!$K-w=h!f~$q*Xf?}_u#s1^a4uSjThOQ$!AB$0
zKiT9z2yHs&bHbOs<3bbgV$lOqOp)qe%ypy#rZD^Dz`33cdv=%wfgxgHS4e5{_1ay{MWy}^|KXFSY1^6
z-6ogV0Lw3?S2+Ly=Z>GL-v0#u^PT5^AT3&dD2$Sf-AvW$u*wOhdR`e?%RO^)lQg?o
zy;@Z5BnrjBoZ80}O106OS>aV>!}D^GFTFOxdzf=(!(HlH=>Dh4H!6$S;|eI1q2rS~
z-`Y@kIqNJ64dV2-AMADRaN}w#X2#n+S64o<1$83C8MC>ILNm2BD#ccu8nx@{mAXIZ
zcXfUoshPf2zO#Qm*QlnilQ}vPfiN`M%oGE?>CY3EU`;mO_T#s1c9+LTU#ok$
zgpoNGi1`*6D6_b6P#b_bkRa1@Zk^`e*Q5j|lNZz`1XCnx!U_;dlLWp|DVu$o`Y<$N
zg?&tap~1h{x|ob`+?{RdvvM=8vY=Roo(p9R4eDCo)f@p(f(bnCE%?YQ0Y7mqPqXZy
zNZ@||GDVlu;O*CeV;c;~Vs$NJMA@OWpMpsRufCQwuT5@cf75-1OP$5G#jFu-t5rTL
z@hkwLR>a1a<^ImAKP}gol-!0-ovgc9hdy^PYSeg;ZB?wim5(SUD*RT*1y#A{xEpHh
z<2I74gfxx5@=^gY3Zsr&&y@4AXX?HcEJ?I<{8J(^?ne=os%6`&!3CwG<-PvKgF3Tt
zOq+I^&Z1smOD6yMEqKM5HG0-wrxQPjX6NGW7ENy2q*p|;&7d{dlK1SLR<`}-lPo(L
z;{02d^eJp|zEXn0R*OqJd3Sa!CH$LDWI9)fSNh}u-U#d!r3Q(Wb12=LebTk9*Lo)y
z6afkzCM)?Bh^W5G=O$gr+Ld9ghSdWL0Ix*U%~SU+`F1I~sAv}qQM^b!YSnX64xVV=
zxATqh8JU0LLAA^}RQ6n-EY)mEux>^pz}L`fH=_F=&S-Pv0W4H6cJQZG7wWT)?f%Nk
z6-)&{)Js0BdtrjT2Vrz}ChN4$c)aVADUZHrz+Q}2j||)-Q;-{812dE=#$GGAa=>%_
zYJ>}s5e4p670hP7*H6t@I6qP{H#En)(g~9clNYf9ij!NJ?G2il#2Bp(v0x-4y~+kg
z-KcGo6s#6)r5^#uPz8DPsGP_TC;jPAx4>_Q-O1x7&6rw$1uN08>aRD0K3QO{^T3UE
zA`=TCp5fCKYVTJgTxm;Whq;&Nbc$UU!Om9{5il{XdLNGM4?J)_+5NzvHee{Rz)kPL
zR*xh8uJj|tEK9GQHKi61K5IpU*9b!HCGe>Llz%Ex>&D45K+;plS6{3`iP@mZ<+<6Z
zXkjuQ;D;!Xc^6!9gpWjyUq}4l1VDV^-l$kqW#5y7H%s1~=32I69L#)+b|#h%SoAiN
zR?wt?a;JgxRM1u1(6aBT&97tds-0JMIy@FL-rAZ_&Bljs6mrRs;XYLEeqYjrVQO9v
zM%Ewnffs>O(H)Q={I;)K?Aqxwt~d`ayh>x&^Ogwu<7?9ZL+wXcrx0OMed_|pPhy>1
zVw8jhEpO!JJ=mZd;us3Gc5!2k_VX3g54=X2eesqAf#arrI(lAfMZU&e4OMo+U@-qs
zH2)c6a_VG*GsN56VFyoELZ|!pF6M7|wsk_`{{)%5R(S=iP)-v}n3ffqqsgHSV&`ioHe!!xIX%wu+!8rpWRg`1%tW@5AL|1Yg^oE7~gLt8J?U$9ENLG|oI;0`EF{f8tXD
z=mgW$kE!sjE$wx}E%IJlwvetP)&<^83!KDc%#7Lr0%y!9&Jop;^I11myImX(byJU7
zvXNg5nX4xNF!AcdA4ECsx=<{d&W?^Sh_BHBsrloaM0SfjAaz8wTDg4hc(DXc$nV|h
z-6+^a(c>MheYhE&t0GfkA(M(L7fy^R_a$3^1V@t6+W>1-?af9Y^DZ*!fSjBa#g;U;
z=KMy*8Z!&;dDglfQs{X2J>|g90`ipibfhyBPn6{<(>99)ldk~V8$6LoYEisuzQQ&8
z^cAw_#*Xn?@1u_rNzO-&p@+3sGv2db4a+*LucVE>W_lClu`RI1+&b7u@qu5w%L_w~
z0(grVB<V~?&0s`Ngg73Bm4-ZAnbzjbn
zZR9`s+4VA=SffP7q)v+ZO>SeVv=kA$Fr|L}a~jR2uztND#1>#ReD>p?pKgBtUw<6`
zj~3sBiL4pDB3y5;5^P|$4j=rPw)EAnE@k;H8(uBhsK&PO&B>`p_ohSY{d0wz?WW(|
zPxk-Fx$3mPZ2;QoVsdk@lKVfcE6`Zg7#wI6o0d
z+L9*79kJZTmG+)@#5C=MdXs$2^WU$gOTN+)AQ%%
z&u?+toG@$*7=Grfru}0TAS0^wM6vX0Bh9ZW9%ZcPyC6O+{|JE
zsx8seTX(~o?nKCZU}ULonQ{Bju!M6mrwz`>Cq`?LY)0yAcbp2ZNVsSp{x+;37o{aV%L}k6+p2-YiZQd$QH`8^+wBh9F@-7ytHV7NjI}OnoZ>YK5Q%O0
zHXuj3me**zuYJ0+7oryYc~y1Hy;1z$Ok~rPy5^K!b8a3HKps8yR=N;2J7mP#z0t$#
zjsQdA&dP@aAz$=c><1f|pfMoE60e+Xu)?V8AdX>ARGMe)ryaY6)ccsbHPhBLk=8ih
zY$N=#yU`wp%_`w4e8rJb4Z#EQIiD(Cx(TXH(Jo$mdGi$zrTJa)D>EPT8$I3F}J>Dl(x6xbJ#UCRaXsYj3
zJ_Dl>!vSm%qgSB=fM3L}9n6-;_XN0V>_jn>X3okT7&pRmX>#E
zX7P#c7-30N2FHm)5q3PkyY|~r$ZF^0m^LS+?(2z@^<0ys^gcw^j_d%kI4D?=Xg4}Q
zDf$D?(hVms!EKa)B#}h?{>gj!6w*sPUfd5~3ZT`PgMu1wZs@8TpN`IJ#ici4Cf_}q
zQlrpR?Z5fDCvnuO_RQ&%#;Jw-o`8sFYKkU3p&D!9d=%>De2JH9|AG^4^G4;^tCOaw
zpA%DPmx_5AI`Oq>_gwBIUw@GlAa=m&0Fm~7H_h+rbYC9l*iy6@U1I+0+UHFlXervO
zJOMUx*0nrHfG5!eRRmU02n;jYZ);cB?ef0B#`w8C
zd(VQp@Cvjr)TCeytaEtOXXcD+#%1x*pqOoWZm}sW&)+9pg64;(H}gQgjJ=kV%6Z!GFl`r5G
zY0ib?l3t$P&m6wKG9W6Fi8`Zs54?U$fY~H$H@i!3@fwmpdl`SVmD;HjZQwgHqQ
z6)INxQa*M{`+8`!yi20+XItd?1o(X6yrNX9?t~LXp@R%wP(NFT-{8RPdxp-w9ks93
zx_z$8v=1j5LkZ9>e&UW-Qpg4F&6`m5l90zhN$2~_DDQ@iMhaC8>Nz@e0!?hpIn*=r
z9BTNc=j4?2(p0M7Td3!;V@;JG4WXAYi)*rMO#8vO9JC3$(Td5GSZ6?tAwPS?jOQw@
zDk>h?_x2g#Y(n_Why@*{jhPi?yNaE5DdT!{TEQY@x&K57X0Q32Pns`)){VoC53-yx*D`kyDI9EH(3O%7YwiT27pu)T-SdhuY(*^z;;_~q
z)i}7@1;@l!yS1+Ibt@0RvU=HH;(9zg7{eVzbBgAdc-|F7kPMYW2ha3SrD%xT)2UM)
z%LmTnrdyt;M^6jIfO#>aHa7`|-m^x4zUk>=jBUhKLb^_S8VLC996^XG>D_w^#(I@8p%sy51YB^7jYqX<1+MY1hNh{
zi$bGTVS7ZUsQrHX{*H@d_kH2A$KlDzF`Jt7s4lA8rJ6DmTunHsERk=KEcSRu)}jg~
zyixgp*x0ScuH&1zRHu2tiZ^LvvW@|{9Ws^L>XT9n9qeTElJ57|m9|0A*-+ShJ3?Y<
zG@;qyb?(wi`yMZ;@HV)k^(bv1D#wbY8sKi9eDH8bfjyFW_+Y$=@afRamr!W{u_#K(
zN?c=E)?_p6!2wk?ifa|G#C*XDU#@&%r<|^}trywfoEOkupPj}xPDrGu+nl?d{EdF!
zzpQdh%k)IGa8MoaZWCR{4B)Y+Z5Gn+!(y|9wocww_#`E+KtA{=xqQVVh6FXYs`bk7
z=^qTVUK+*hikD_K8{s@#jQAsBpVxU`q7NP);&zn2(fdBzu?8x0D_7gOA39sDFC5e}mbqWx9{C|#VW=KT|gg`-X>M`D`HkbMzKeveNVp`zAg}RZX2zLCn4t&Q_Ta5=05neBj6Rs
zuOg|zZw=j8$m+G8T2L!g6pOrUHZkrLKZlnMCu=V+DfX=J5-s*=k7hu&+YuQwx9AsT
zZVA5m609)T=wMQfrB~V=3tx9Tb*)T@TYFmN0FA@h)k$dZ`M}T=J33eZ>Fju?@XnkN
zy7YsjzS|h8nsT)1kY$Q$M*Ec
z->4+=2zpdJl2bP{#ZanYSS7)B3bjcY+&_hMds%#)Pf
zhe0}FB4obu_0S%MyS&mqFe{X!AG9QS=a+}&&_c}`*FMo))8G0bvrS~s&}secz_%U5
z@dZw7GbYW%;d{ZdD!5>GK|&1NJ|NZlN>C7`rg(G%f}vzgDlBa%$k?JLm6{hMWR$A=Kl9_87`vJ`w$-*_a*;S>QZHbD0FXK
z+GSvuY;;E$l@r&R{(3vL%Ap>`NKwN5fGSOg+S~i~EO<-j=jvc+0BDkC$2HS4%<$UL
zp8^*G53P_ica$yD!Fl5X)kh=I{J#RRySHfeZH}sY;Wwnz5|c!##wL#XHJm3>^lKB%lyuJtcNOZl_&r3?I!7d
z#eUR0dPgV~X?OVSTzRI|s?lk^Kxls8c-&F(425sdm3
z-u+@R9pBaXCm|i*QfeS}%=~$_B#Qe@`WN9g-q|kt2cMkhL2#YX`&$&|+FU3j^^J;%
ztW{YgY2*iQt#&FM3zSq^lCZdV4&2gT5-zzeOYtc{$|ngk`gv!@YqYCI7oHSeT%^V9
znBuap%$QUH3}yY>&a-Ab+!S_uxJBdj~gCQ}U^55{hJTG$e$I@|@&oX%vsGzHlh
z41YJZ&;jXyQ*11x&#^g)XqmQ)$5GNddJaDbOQ{7f
zOV{d&)ofZ1EZN1V(kjCoEb9rPrp%_^=)AQY6S;xb*|8zwiM^iKv%6`Rn9<%MJZT})
zI=s~Nhn-u?(r>f<{D_F|0@8lcD(i|;XjpR?ZINPQ{{55&t@XXurq#}{%Dat45`!R_
zCRMfa1moF*W{?sjsaC49Oy~49i>vq@ts#Xzk7`a2Z-tQDoSXqxrTQBc#zv%I9-}RM
zY0C~Nd(}2-aYS?|E#Upwl&)(195h!Eib@czv!IJS-+~H}u6IVmMCIW0pj?q*UUisO
zSM3y{!*{DJ*z0uP=U6Jc(+OU*$(HO{{5{)R39mn%)C^P1K%9HusOZ14a|h*;*MRt5
zoD$xX8^<=8G`FC
zhK%1B9NyjJ1^+bVJ6bV0GMD4+Aq`Qys>bvO|HbQPpcalZ7-cc;>E%8!PYp`w#xeS=
z817?R#nqU}29}4-Hwl2LeqJY{;l_4Z^QP#W;2~#WvsxbQY^Jo(50*Du?xyy7&e}69
zc$*m^hh`n;wZLhU=yvFmxQ~hwM*pK;nE?rHC1}oaOP2`vChS;U8Mp0c)S3IQ|
z{e168kcGWgrC**N=eQ`P#=Nxw3K|h?2Ot;;@#GRh&C!hg$p2^
zxsfOPk0TBUDm5R9lXndA^go<)teky`5%hH*dTvP8y6R9>AWMM&N7$ynQMp85pSlu1
zlOuNc1NnT5wqgp|;kNPCYkh;yGM)-C;nhyPe2c*8;&G66|BKzDMjOR4T`T+wqf_xo
z>1LAg8x`eAnuAFmsAD~U@EhVa1O}JwkId&6p1Ih+6zS&G+Ah&!T^_7Uu_BmQ-I_DMWX>uJvFE3WPn0A5pP-}eCByDGNRH3u|$
z(;?}hJ=Bq~0Yp)|wyb0bUcPg%+~aLvrjgTHr>3U&C+=*Ym2CG$fLltbzZE&J4MM5ouFYol|%?Ug!!>o`BNSz|KIVO}MVnNtOx=P_%T
zDxS7-hL4I}7p;ZouJ$}
zJG#tHJJvQDMCDg9VmI@P#EKLRA#TGr?)E)YlgWpDPe(~0kL-1+rY(4W4
zE3zzaC!=?*`v}VnhWorX8NTgo4?Q3^p>UhA%w5?2^#H5O2M;57y*uyFm~mRhx`$~Cm^>>=aua=wjyVMpiU4uPZJhHb;%kURZker8Pp_^2SseO<
z&ln4q4X~mo5YJ<#(n?75#>{Cxx+&N~w`2L6SNo9tm%DgYnw~8~MiCKAXMjEY^iEX2
zZ%$i>3v6J)>`}=UCLrpQjPBmBH!73&)E+eWX0qapt#(>_I|29UAy6@E5(D#ddB)RB=X>Apk>aY}TVAB$-=vG@dM+;Ev4n$++D{%S6)
zSb|ZUL6*@Cm>p2erP<(>AyeP1PJnCYa_Q=n)`8fi#QxgoW^743S{Rpmeq*`RUN4*jgjjgt$cjNpe=4#shwufFSsF)v(
z<5Y0USjgbY2ajqO_Tb|aA86%j&}DX~b^aJ7K0n!AV0OMDPJ#iy0CJ$6{EW-)(6P8^
ztM5cFlaua>T`m*F=Ob3O41lt|ElJl2G06?$PW8SYAyJ2e$&MGb&QIJQ5QjXa1zd|g
z^?2!(sG$*1SJ}#3CI_c|;4no|d?0r7#=u^~Gs1tEDcbz{p5D+{s3A%uB+xxXOEdPf+am
z5&t-V_P9?=T%RaKEsof=Btch%^B_5+IC_TRqi5GgLq7lfSrfU+n1x43EVU8DXh~9IvFpg0Vzka$_i8>OK-o(M`Mv_AZzEvRD)j
z*VE*MNjSEBe0^QIdDAOt!)qIdTJNW(Y37h9sN6coD7CiriB7DzeL!TYR!elVM_=g_
z?^JR!m~grWs?j`-k&o#gYXQJ%X7MkJ*Z(xS5G`j$V^q=C1^#uY%$@a2
za^1|?ooL6Cqx0t*Du*NL!#ZpoSaFgQqWB`p(JfJ^?I|dzb`Sw*PgV0>CmH_trNvpj
zF*A_HA+(?Ly8VHpRbbM^&B76)>gScCvs^_~mxH^*Y@T`>3>LF}jzyj|?S2qiROQEz
z2$-RO&;|cXLiRLWKVrKpo50r^LSqSde*xOxz_?Lb?PgLSn0Zy(xrgf7zUMU0Y(5v(
zcxARHiJv6)Z(xTGI$Ae}eB^|YL4IhvSgDwVyHVTi6o3X}CFBot7b4ZvF6vUU7q=kk;<
z>PfC)F0nPZ6;PisU8Ku(XbrND>DU)R8#>w+#5OyI@H)?iUdU;;$pqpCt+C=1YRV({
zhDOmXxIjddSmTSN2U*FF=c&4^N&f)x^NBqd+Oi;F>y&V1B4&PL2eP$$z5?f?n_Q*$
z=?{G;;pS8i>&7p|t+oaR6ZUbWmPXZHd8>6&C`WHJ0-Y3s{)VQfFtAsowkA5yN2A4^
zJwn+KYCUP1=T^DW6R=A7%pg9F9@kp$PjW8&4#_KHoBP&i57oUahhc;2U2KXE66;zb
zyW)IQv8cR&63?u0c8;l*ftjs6T9Z)0y0?yBLE2P`LcJD7(2YAF5!C+H^xo(B3Wu1l
zWxKmKA6JK?3(y&@kgma=1VGTU?WUeul`Pp+FXKY4`Gc1V)b9D(96D4@f4mk8u?t!m
zmqHCOV{cTN#Nlz~A9VBHeU^2f-d^RChb4t`8mf0)pdo0=rl6s?)vyWpocnIF
zKwGKb0N(pk{}|m@@-<_^)8lEkO&!0Wc?6=?gVVX^heTY;e?Ct!4-0rohyD
zb{W(;Ua{LfTOIE{zfQN3OGkD{M`QN1#u4G80c%SXS@$M&s9K
z#e~y>&I`rEEnb#s^ZskvF6${r9T0XYk6|ux%-SzS23DEHxq;2FwW5vLj6do-v|%7$
zz3p4c#dcOnCtE~^!cXqMQK5PDc%N|vbX5Esg4(w31f;)IZ}jKS>YIn<)CUNIH7wO8
zhQkQk6>dE#>GnUW{F*Yz7om4kKFdn^Xx9jk1^nsP7D0YHG;a3Jj~6&;^&V;i%l%-U
zvZim5UZ{Es&ZI_QB#i#eWiq8?J{iDYnSU{A9u;vm*v?&;nZZk2RDM4kg~4hJq0BTn
z4YbRWxSTSCW35Tl@cahp{#2^s(Fk`rzIE-pl_6xLXHQR;$+M?p25W5OQgrl2fc%z!
z^OJVRhpG};AAzA|Xz|e$?>IfmEiw51PzszH7~^i1ckD*f(P%k6@YKO+r42yAOEaaI
zE>m_HY@hc>qq{jrUNC;{}lhi_DX$lcC;z8pAMqLH>`(3fe$;3stoy;lA+sVB4Q8cp5_=P^udL>9teOk6;;{AO
z410(deJ~t6PeWIBpt+b7RxtnW&01;=xQXoAyFm%
zVkam5ddTH9pavv1MPdsViC`uiV%Vq+6M}|(o)oPN}
zTDnQZMPnSS)7!wBVw)of)FcO-c}h=M-L1{tXK(7?ji`Lz98sC+4}Hk6NvSY=qjJ92
z=^)P>%cLkYendT6^=a_Oq*61(%DW&4?n!yN_!g5zPI7-qt;Y82o8JO-sqz!FSY
z=JNx3Q{fi}jzELc&nMMvK#
zHuralyKf~i)4QtoDeax%7wxzGo(v#sQZ$o`&hcE~je^m%y}Lgcgc@5FjCVkic;|ik
zZnWTq{Xvy)RMd5smIRYCoUB#pmqfPD(c$oIit51pLP3KX+8d5qTUswBK(7sU1#QY=
z{RZT-6BGA2n~99h-adG%I%F$;=9urT5Y;Qvqe|CN66SJI5yeJaYI@(nm);Wpw#;ja
z^kC`~bO0bYFXm;7;T=g^x3)~d9KJYmAH#-I=FGh(N}fIC6Y>d^{9aawo#jH9JG9&*(Y6
znaFl9&&4{}O04$c$Hy3_PQ6~=jhGd@u3JFV1xSI~yvks^tvI|CdwXtCbl{zAb8~ih
zt~>i(w(*%AdOq(88nV42XucBs
zP*m4A$Z&GGB>yN{?Cywhwr{q{4ZNQ_vCWGFm}$Iwr=6c#O+I7MqT}6#IcyTYXy)Xh
z=XSn-L&=4*HYfKLU*c9}2QMEx-E%W7pKZRr1(?%{Wgtr#jw)qJ~qGos8$(sXaPfr#938ipaBU73#E=vIn<*P;__@IO85OTREExi$U+b7XMYyjg9{qvT{H)XRD*C3v
z*>-4ts*YWj2Lgp}bF2K;Ctfxc?p?Xr7Q@*_st9L+SfI58>~kE3Yc92gBsQ?m
zPV>%pFSShts^gb?uC>rDWg<`ZT51eNHxyxu)#1!b58W9pW3MLqFs629${Sv79%(4j
z4ljS(7NBg3d@h8~jgy?;sJu~$4SAz7)7R>?Di$3Mgj=>j!Se0YY)^oZ5K}HN2Pt2W
z1!)!yYU@ES3mR
z;vQTviEMkTI2UvEJBJB$gt0w*{34AK0hmG_?o^Pl&^#lC01n5B%Wi
zRYarBX4rddOOT_x?M{_2bdZfOe*a1J^9!99KKc?o_V)-?jFCcJ*85oO(S>^HplyE-q-h3-mQ2=1uKskopFfug?;l>b;6s
zgGGh(qMwR0XCS}#fnIr|QdP(+ZaBhSF`L8$`6#@Nu2~+ZXyLbie2zN%)WFX5Y^Zrd>20aY{MWyIYZe?Rs>*Nj?_*X+tCR
zi*-^v*OXaV-Q8frg)WE@6=@@F`^T|Qi+9!3<{N)EUJA!2wY!GUX~Cb}Y4~5>>%aDw
z|6HH<@A_Ws{5&dz%4>dy0EK#Zw0)gRei?L3n<*`2p>lB
zKW!|b3&x<8ByT9DQt43RA!bQ#5Gr=U
z-n)nFR%0ctdbEWd)V?@+f4w3R19i0-mKdV4_`I;ak^QPIZA1ABCH#6NJLJXBKQx}8
z{{Q#mLkZ9FZ(rs_)veFOoo35SN>g}k*VhOwNArMtoHntzOqyNWqKc%Oimeh8`8j<(
z4~N~ukz}c~?w%q9CVNLsA3oIwCX1~(<-M?BFf6e~9^~=cf;kzxnfAk%_
zBQ916`}41Pzkjv1-~DCeR|)eA@+0tI^~kWd9{j6y@mKAS+AkyjZ;K!&;kUQ^alDO~
z^^D5W5HF9J3>vTjO#j9vfL>IJmt%n*8F+E{T>hJ!0^E(S|N9iUl=vIn1D?o_I6FSA
zEI0ZsP5|dKRlvyS^hZ30^ooEp@Hb}y{WAWO?KR}zunDxj_VY=23UsVTHGVJ6E9BA@&_Phj=cm!nF};62_tiht&cEqJZ~rp#FMn}_>S~X$&Z%_&n~vlk
z>-PW01#B~Z`O`aJ{9_IMn_gx3i}QOg%_roN>#60E=->V3mOn7^|1AHX;~@W8{{LD2
z|5^TD{hMy&|4_I8bNc^X0Qf(r|8MT}|8IVeht1Cs@bxZW0{qAN|A+RGf7grtXY2pB
m`S0ffVc_4g^><=K0ObGvv;F@@xBpB3Z}8){X8pby|9=2zlM(g+

literal 0
HcmV?d00001