diff --git a/build.gradle b/build.gradle index b7baea0a..7cd5ffdc 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,8 @@ dependencies { //S3 관련 의존성 부여 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.32.0' + implementation 'io.jsonwebtoken:jjwt:0.9.1' implementation 'javax.xml.bind:jaxb-api:2.3.0' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/org/cotato/csquiz/api/member/controller/MemberController.java b/src/main/java/org/cotato/csquiz/api/member/controller/MemberController.java index 47217db7..8b1b9873 100644 --- a/src/main/java/org/cotato/csquiz/api/member/controller/MemberController.java +++ b/src/main/java/org/cotato/csquiz/api/member/controller/MemberController.java @@ -1,13 +1,16 @@ package org.cotato.csquiz.api.member.controller; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.cotato.csquiz.api.admin.dto.MemberInfoResponse; import org.cotato.csquiz.api.member.dto.MemberMyPageInfoResponse; import org.cotato.csquiz.api.member.dto.UpdatePasswordRequest; +import org.cotato.csquiz.api.member.dto.UpdatePhoneNumberRequest; import org.cotato.csquiz.common.config.jwt.JwtTokenProvider; import org.cotato.csquiz.domain.auth.service.MemberService; +import org.springframework.context.annotation.Description; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -42,6 +45,16 @@ public ResponseEntity updatePassword(@RequestHeader("Authorization") Strin return ResponseEntity.noContent().build(); } + @Operation(summary = "멤버 전화번호 수정", description = "멤버 전화번호 수정하기") + @PatchMapping("/phone-number") + public ResponseEntity updatePhoneNumber( + @RequestHeader("Authorization") String authorizationHeader, + @RequestBody @Valid UpdatePhoneNumberRequest request) { + String accessToken = jwtTokenProvider.getBearer(authorizationHeader); + memberService.updatePhoneNumber(accessToken,request.phoneNumber()); + return ResponseEntity.noContent().build(); + } + @GetMapping("/{memberId}/mypage") public ResponseEntity findMyPageInfo(@PathVariable("memberId") Long memberId) { return ResponseEntity.ok().body(memberService.findMyPageInfo(memberId)); diff --git a/src/main/java/org/cotato/csquiz/api/member/dto/UpdatePhoneNumberRequest.java b/src/main/java/org/cotato/csquiz/api/member/dto/UpdatePhoneNumberRequest.java new file mode 100644 index 00000000..eedc67f3 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/member/dto/UpdatePhoneNumberRequest.java @@ -0,0 +1,11 @@ +package org.cotato.csquiz.api.member.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record UpdatePhoneNumberRequest( + @NotNull(message = "전화번호를 입력해주세요.") + @Size(min = 11, max = 11, message = "'-'없이 11자리의 전화번호를 입력해주세요.") + String phoneNumber +) { +} diff --git a/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java b/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java index 7b7bfdac..8cbd7682 100644 --- a/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java +++ b/src/main/java/org/cotato/csquiz/api/session/controller/SessionController.java @@ -1,21 +1,28 @@ package org.cotato.csquiz.api.session.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.cotato.csquiz.api.session.dto.AddSessionPhotoRequest; +import org.cotato.csquiz.api.session.dto.AddSessionPhotoResponse; import org.cotato.csquiz.api.session.dto.AddSessionRequest; import org.cotato.csquiz.api.session.dto.AddSessionResponse; import org.cotato.csquiz.api.session.dto.CsEducationOnSessionNumberResponse; +import org.cotato.csquiz.api.session.dto.DeleteSessionPhotoRequest; import org.cotato.csquiz.api.session.dto.SessionListResponse; import org.cotato.csquiz.api.session.dto.UpdateSessionDescriptionRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionNumberRequest; +import org.cotato.csquiz.api.session.dto.UpdateSessionPhotoOrderRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionPhotoRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionRequest; import org.cotato.csquiz.domain.generation.service.SessionService; import org.cotato.csquiz.common.error.exception.ImageException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; @@ -33,11 +40,13 @@ public class SessionController { private final SessionService sessionService; + @Operation(summary = "Session 리스트 정보 얻기", description = "Get Session Infos") @GetMapping("") public ResponseEntity> findSessionsByGenerationId(@RequestParam Long generationId) { return ResponseEntity.status(HttpStatus.OK).body(sessionService.findSessionsByGenerationId(generationId)); } + @Operation(summary = "Session 추가하기", description = "세션 추가하기") @PostMapping(value = "/add", consumes = "multipart/form-data") public ResponseEntity addSession(@ModelAttribute @Valid AddSessionRequest request) throws ImageException { @@ -45,8 +54,7 @@ public ResponseEntity addSession(@ModelAttribute @Valid AddS } @PatchMapping(value = "/update", consumes = "multipart/form-data") - public ResponseEntity updateSession(@ModelAttribute @Valid UpdateSessionRequest request) - throws ImageException { + public ResponseEntity updateSession(@RequestBody @Valid UpdateSessionRequest request) { sessionService.updateSession(request); return ResponseEntity.noContent().build(); } @@ -57,16 +65,24 @@ public ResponseEntity updateSessionNumber(@RequestBody @Valid UpdateSessio return ResponseEntity.noContent().build(); } - @PatchMapping("/description") - public ResponseEntity updateSessionDescription(@RequestBody @Valid UpdateSessionDescriptionRequest request) { - sessionService.updateSessionDescription(request); + @Operation(summary = "Session 수정 - 사진 순서", description = "세션 사진 순서 바꾸기") + @PatchMapping("/photo/order") + public ResponseEntity updateSessionPhotoOrder(@RequestBody UpdateSessionPhotoOrderRequest request) { + sessionService.updateSessionPhotoOrder(request); return ResponseEntity.noContent().build(); } - @PatchMapping(value = "/update/photo", consumes = "multipart/form-data") - public ResponseEntity updateSessionPhoto(@ModelAttribute @Valid UpdateSessionPhotoRequest request) + @Operation(summary = "Session 수정 - 사진 추가하기", description = "세션 수정 시 사진 추가하기, photoId 반환") + @PostMapping(value = "/photo", consumes = "multipart/form-data") + public ResponseEntity additionalSessionPhoto(@ModelAttribute @Valid AddSessionPhotoRequest request) throws ImageException { - sessionService.updateSessionPhoto(request); + return ResponseEntity.status(HttpStatus.CREATED).body(sessionService.additionalSessionPhoto(request)); + } + + @Operation(summary = "Session 수정 - 사진 삭제하기", description = "사진 삭제하기") + @DeleteMapping(value = "/photo") + public ResponseEntity deleteSessionPhoto(@RequestBody DeleteSessionPhotoRequest request) { + sessionService.deleteSessionPhoto(request); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionPhotoRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionPhotoRequest.java new file mode 100644 index 00000000..7fe1ee71 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionPhotoRequest.java @@ -0,0 +1,13 @@ +package org.cotato.csquiz.api.session.dto; + +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record AddSessionPhotoRequest( + + @NotNull + Long sessionId, + @NotNull + MultipartFile photo +) { +} diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionPhotoResponse.java b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionPhotoResponse.java new file mode 100644 index 00000000..6da92af3 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionPhotoResponse.java @@ -0,0 +1,17 @@ +package org.cotato.csquiz.api.session.dto; + +import jakarta.validation.constraints.NotNull; +import org.cotato.csquiz.domain.generation.entity.SessionPhoto; +import org.springframework.web.multipart.MultipartFile; + +public record AddSessionPhotoResponse( + Long photoId, + String photoUrl, + Integer order +) { + public static AddSessionPhotoResponse from(SessionPhoto sessionPhoto) { + return new AddSessionPhotoResponse(sessionPhoto.getId(), + sessionPhoto.getS3Info().getUrl(), + sessionPhoto.getOrder()); + } +} diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java index a8f10f0d..b266462f 100644 --- a/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java +++ b/src/main/java/org/cotato/csquiz/api/session/dto/AddSessionRequest.java @@ -1,5 +1,6 @@ package org.cotato.csquiz.api.session.dto; +import java.util.List; import org.cotato.csquiz.domain.generation.enums.CSEducation; import org.cotato.csquiz.domain.generation.enums.DevTalk; import org.cotato.csquiz.domain.generation.enums.ItIssue; @@ -10,7 +11,7 @@ public record AddSessionRequest( @NotNull Long generationId, - MultipartFile sessionImage, + List photos, @NotNull String title, @NotNull diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/DeleteSessionPhotoRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/DeleteSessionPhotoRequest.java new file mode 100644 index 00000000..aa254a1c --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/session/dto/DeleteSessionPhotoRequest.java @@ -0,0 +1,9 @@ +package org.cotato.csquiz.api.session.dto; + +import jakarta.validation.constraints.NotNull; + +public record DeleteSessionPhotoRequest( + @NotNull + Long photoId +) { +} diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/SessionListPhotoInfoResponse.java b/src/main/java/org/cotato/csquiz/api/session/dto/SessionListPhotoInfoResponse.java new file mode 100644 index 00000000..4841e3ca --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/session/dto/SessionListPhotoInfoResponse.java @@ -0,0 +1,23 @@ +package org.cotato.csquiz.api.session.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.List; +import org.cotato.csquiz.domain.generation.entity.SessionPhoto; + +public record SessionListPhotoInfoResponse( + Long photoId, + String photoUrl, + Integer order +) { + public static SessionListPhotoInfoResponse from(SessionPhoto sessionPhoto) { + return new SessionListPhotoInfoResponse(sessionPhoto.getId(), + sessionPhoto.getS3Info().getUrl(), + sessionPhoto.getOrder()); + } + + public static List from(List sessionPhotos) { + return sessionPhotos.stream() + .map(SessionListPhotoInfoResponse::from) + .toList(); + } +} diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/SessionListResponse.java b/src/main/java/org/cotato/csquiz/api/session/dto/SessionListResponse.java index 987ecb8c..a8f0317e 100644 --- a/src/main/java/org/cotato/csquiz/api/session/dto/SessionListResponse.java +++ b/src/main/java/org/cotato/csquiz/api/session/dto/SessionListResponse.java @@ -1,5 +1,6 @@ package org.cotato.csquiz.api.session.dto; +import java.util.List; import org.cotato.csquiz.domain.generation.embedded.SessionContents; import org.cotato.csquiz.domain.generation.entity.Session; @@ -7,7 +8,7 @@ public record SessionListResponse( Long sessionId, Integer sessionNumber, String title, - String photoUrl, + List photoInfos, String description, Long generationId, SessionContents sessionContents @@ -17,7 +18,7 @@ public static SessionListResponse from(Session session) { session.getId(), session.getNumber(), session.getTitle(), - (session.getPhotoS3Info() != null) ? session.getPhotoS3Info().getUrl() : null, + SessionListPhotoInfoResponse.from(session.getSessionPhotos()), session.getDescription(), session.getGeneration().getId(), session.getSessionContents() diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoOrderInfoRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoOrderInfoRequest.java new file mode 100644 index 00000000..a0554184 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoOrderInfoRequest.java @@ -0,0 +1,11 @@ +package org.cotato.csquiz.api.session.dto; + +import jakarta.validation.constraints.NotNull; + +public record UpdateSessionPhotoOrderInfoRequest( + @NotNull + Long photoId, + @NotNull + Integer order +) { +} diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoOrderRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoOrderRequest.java new file mode 100644 index 00000000..73d487d9 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoOrderRequest.java @@ -0,0 +1,9 @@ +package org.cotato.csquiz.api.session.dto; + +import java.util.List; + +public record UpdateSessionPhotoOrderRequest( + Long sessionId, + List orderInfos +) { +} diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoRequest.java index e11f7d15..4cddfcb3 100644 --- a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoRequest.java +++ b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionPhotoRequest.java @@ -8,6 +8,6 @@ public record UpdateSessionPhotoRequest( @NotNull Long sessionId, - MultipartFile sessionImage + MultipartFile photo ) { } diff --git a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java index 7044b43b..995566ec 100644 --- a/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java +++ b/src/main/java/org/cotato/csquiz/api/session/dto/UpdateSessionRequest.java @@ -5,14 +5,10 @@ import org.cotato.csquiz.domain.generation.enums.ItIssue; import org.cotato.csquiz.domain.generation.enums.Networking; import jakarta.validation.constraints.NotNull; -import org.springframework.web.multipart.MultipartFile; public record UpdateSessionRequest( @NotNull Long sessionId, - MultipartFile sessionImage, - @NotNull - Boolean isPhotoUpdated, String title, String description, @NotNull @@ -21,7 +17,6 @@ public record UpdateSessionRequest( Networking networking, @NotNull CSEducation csEducation, - @NotNull DevTalk devTalk ) { diff --git a/src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java b/src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java index b98c9acf..7d3b8816 100644 --- a/src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java +++ b/src/main/java/org/cotato/csquiz/common/S3/S3Uploader.java @@ -1,11 +1,12 @@ package org.cotato.csquiz.common.S3; +import static org.cotato.csquiz.common.util.FileUtil.checkAllowedImageFileExtension; +import static org.cotato.csquiz.common.util.FileUtil.extractFileExtension; + import com.amazonaws.SdkClientException; import com.amazonaws.services.s3.AmazonS3Client; import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.PutObjectRequest; -import java.util.List; -import java.util.Objects; import org.cotato.csquiz.common.entity.S3Info; import org.cotato.csquiz.common.error.ErrorCode; import org.cotato.csquiz.common.error.exception.ImageException; @@ -67,7 +68,7 @@ private void removeNewFile(File targetFile) { if (targetFile.delete()) { log.info("삭제 완료"); } else { - log.info("삭제 에러"); + log.error("삭제 에러"); } } @@ -78,7 +79,10 @@ private String putS3(File uploadFile, String fileName) { } private Optional convert(MultipartFile file) throws ImageException { - File convertFile = new File(System.getProperty("user.dir") + "/" + UUID.randomUUID()); + String fileExtension = extractFileExtension(file); + checkAllowedImageFileExtension(fileExtension); + + File convertFile = new File(System.getProperty("user.dir") + "/" + UUID.randomUUID() + "." + fileExtension); log.info("converted file name: {}", convertFile.getName()); try { diff --git a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java index c1258a05..a77d458e 100644 --- a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java +++ b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java @@ -45,6 +45,13 @@ public enum ErrorCode { EDUCATION_STATUS_NOT_BEFORE(HttpStatus.BAD_REQUEST, "E-402", "이미 시작한 적이 있는 교육입니다."), MEMBER_CANT_ACCESS(HttpStatus.BAD_REQUEST, "E-403", "해당 멤버의 ROLE로 접근할 수 없습니다"), + //세션 사진 + SESSION_PHOTO_COUNT_MISMATCH(HttpStatus.BAD_REQUEST, "P-101", "저장된 사진 수와 요청 사진 수가 다릅니다."), + SESSION_ORDER_INVALID(HttpStatus.BAD_REQUEST, "P-102", "입력한 순서는 유효하지 않습니다."), + + FILE_EXTENSION_FAULT(HttpStatus.BAD_REQUEST, "F-001", "해당 파일은 등록 할 수 없는 확장자명입니다."), + + INVALID_ANSWER(HttpStatus.BAD_REQUEST, "Q-101", "객관식 문제는 숫자 형식의 값만 정답으로 추가할 수 있습니다."), CONTENT_IS_NOT_ANSWER(HttpStatus.BAD_REQUEST, "Q-201", "추가되지 않은 정답을 추가할 수 없습니다."), QUIZ_NUMBER_DUPLICATED(HttpStatus.CONFLICT, "Q-301", "퀴즈 번호는 중복될 수 없습니다."), @@ -69,6 +76,7 @@ public enum ErrorCode { IMAGE_DELETE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "S-003", "s3 이미지 삭제처리를 실패했습니다"), INTERNAL_SQL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S-004", "SQL 관련 에러 발생"), ENUM_NOT_RESOLVED(HttpStatus.BAD_REQUEST, "S-005", "입력한 Enum이 존재하지 않습니다."), + SCORER_LOCK_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S-006", "득점자 락 획득 과정에서 에러 발생"); ; private final HttpStatus httpStatus; diff --git a/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java b/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java index 1f57ccf9..c9a0d35d 100644 --- a/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/org/cotato/csquiz/common/error/handler/GlobalExceptionHandler.java @@ -39,8 +39,9 @@ public ResponseEntity handleAppCustomException(AppException e, Ht public ResponseEntity handleImageException(ImageException e, HttpServletRequest request) { log.error("이미지 처리 실패 예외 발생: {}", e.getErrorCode().getMessage()); log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI()); - ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.IMAGE_PROCESSING_FAIL, request); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode(), request); + return ResponseEntity.status(e.getErrorCode().getHttpStatus()) + .body(errorResponse); } @Override diff --git a/src/main/java/org/cotato/csquiz/common/util/FileUtil.java b/src/main/java/org/cotato/csquiz/common/util/FileUtil.java new file mode 100644 index 00000000..34c06e22 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/util/FileUtil.java @@ -0,0 +1,26 @@ +package org.cotato.csquiz.common.util; + +import java.util.Arrays; +import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.exception.ImageException; +import org.springframework.web.multipart.MultipartFile; + +public class FileUtil { + + private static final String[] ALLOWED_IMAGE_FILE_EXTENSIONS = {"png", "jpg", "jpeg", "heif"}; + + public static String extractFileExtension(MultipartFile file) throws ImageException { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || !originalFilename.contains(".")) { + throw new ImageException(ErrorCode.FILE_EXTENSION_FAULT); + } + + return originalFilename.substring(originalFilename.lastIndexOf(".") + 1); + } + + public static void checkAllowedImageFileExtension(String fileExtension) throws ImageException { + if (!Arrays.asList(ALLOWED_IMAGE_FILE_EXTENSIONS).contains(fileExtension)) { + throw new ImageException(ErrorCode.FILE_EXTENSION_FAULT); + } + } +} diff --git a/src/main/java/org/cotato/csquiz/domain/auth/entity/Member.java b/src/main/java/org/cotato/csquiz/domain/auth/entity/Member.java index 9b5befca..4dc0c4c5 100644 --- a/src/main/java/org/cotato/csquiz/domain/auth/entity/Member.java +++ b/src/main/java/org/cotato/csquiz/domain/auth/entity/Member.java @@ -75,6 +75,10 @@ public void updateGeneration(Integer passedGenerationNumber) { this.passedGenerationNumber = passedGenerationNumber; } + public void updatePhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + public void updatePosition(MemberPosition position) { this.position = position; } diff --git a/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java b/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java index 62d303af..ea466e33 100644 --- a/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java +++ b/src/main/java/org/cotato/csquiz/domain/auth/service/MemberService.java @@ -6,6 +6,7 @@ import org.cotato.csquiz.api.admin.dto.MemberInfoResponse; import org.cotato.csquiz.api.member.dto.MemberInfo; import org.cotato.csquiz.api.member.dto.MemberMyPageInfoResponse; +import org.cotato.csquiz.api.member.dto.UpdatePhoneNumberRequest; import org.cotato.csquiz.common.config.jwt.JwtTokenProvider; import org.cotato.csquiz.domain.auth.entity.Member; import org.cotato.csquiz.common.error.exception.AppException; @@ -60,6 +61,16 @@ private void validateIsSameBefore(String originPassword, String newPassword) { } } + @Transactional + public void updatePhoneNumber(String accessToken, String phoneNumber) { + Long memberId = jwtTokenProvider.getMemberId(accessToken); + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new EntityNotFoundException("해당 회원을 찾을 수 없습니다.")); + + String encryptedPhoneNumber = encryptService.encryptPhoneNumber(phoneNumber); + findMember.updatePhoneNumber(encryptedPhoneNumber); + } + public MemberMyPageInfoResponse findMyPageInfo(Long memberId) { Member findMember = memberRepository.findById(memberId) .orElseThrow(() -> new EntityNotFoundException("해당 회원을 찾을 수 없습니다.")); diff --git a/src/main/java/org/cotato/csquiz/domain/education/cache/ScorerExistRedisRepository.java b/src/main/java/org/cotato/csquiz/domain/education/cache/ScorerExistRedisRepository.java deleted file mode 100644 index 0510a982..00000000 --- a/src/main/java/org/cotato/csquiz/domain/education/cache/ScorerExistRedisRepository.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.cotato.csquiz.domain.education.cache; - -import org.cotato.csquiz.domain.education.entity.Quiz; -import org.cotato.csquiz.domain.education.repository.QuizRepository; -import java.util.List; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class ScorerExistRedisRepository { - - private static final String KEY_PREFIX = "$Scorer for "; - private static final Long NONE_VALUE = Long.MAX_VALUE; - private static final Integer SCORER_EXPIRATION = 60 * 24; - private final RedisTemplate redisTemplate; - - public void saveAllScorerNone(List quizzes) { - for (Quiz quiz : quizzes) { - String quizKey = KEY_PREFIX + quiz.getId(); - redisTemplate.opsForValue().set( - quizKey, - NONE_VALUE, - SCORER_EXPIRATION, - TimeUnit.MINUTES - ); - } - } - - public void saveScorer(Quiz quiz, Long ticketNumber) { - String quizKey = KEY_PREFIX + quiz.getId(); - redisTemplate.opsForValue().set( - quizKey, - ticketNumber, - SCORER_EXPIRATION, - TimeUnit.MINUTES - ); - } - - public boolean saveScorerIfIsFastest(Quiz quiz, Long ticketNumber) { - if (getScorerTicketNumber(quiz) > ticketNumber) { - saveScorer(quiz, ticketNumber); - return true; - } else { - return false; - } - } - - public Long getScorerTicketNumber(Quiz quiz) { - String quizKey = KEY_PREFIX + quiz.getId(); - if (redisTemplate.opsForValue().get(quizKey) == null) { - return NONE_VALUE; - } - return redisTemplate.opsForValue().get(quizKey); - } -} diff --git a/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java b/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java index 9f3f8d4c..c90ee26f 100644 --- a/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java +++ b/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java @@ -29,16 +29,25 @@ public class Scorer extends BaseTimeEntity { @Column(name = "quiz_id", nullable = false) private Long quizId; - private Scorer(final Long memberId, Quiz quiz) { + @Column(name = "ticket_number", nullable = false) + private Long ticketNumber; + + private Scorer(final Long memberId, Quiz quiz, Long ticketNumber) { this.memberId = memberId; this.quizId = quiz.getId(); + this.ticketNumber = ticketNumber; } - public static Scorer of(final Long memberId, Quiz quiz) { - return new Scorer(memberId, quiz); + public static Scorer of(final Long memberId, Quiz quiz, Long ticketNumber) { + return new Scorer(memberId, quiz, ticketNumber); } public void updateMemberId(final Long memberId) { this.memberId = memberId; } + + public void updateScorer(final Long memberId, Long ticketNumber){ + this.memberId = memberId; + this.ticketNumber = ticketNumber; + } } diff --git a/src/main/java/org/cotato/csquiz/domain/education/facade/RedissonScorerFacade.java b/src/main/java/org/cotato/csquiz/domain/education/facade/RedissonScorerFacade.java new file mode 100644 index 00000000..738ca59b --- /dev/null +++ b/src/main/java/org/cotato/csquiz/domain/education/facade/RedissonScorerFacade.java @@ -0,0 +1,48 @@ +package org.cotato.csquiz.domain.education.facade; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.exception.AppException; +import org.cotato.csquiz.domain.education.entity.Quiz; +import org.cotato.csquiz.domain.education.entity.Record; +import org.cotato.csquiz.domain.education.service.ScorerService; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedissonScorerFacade { + + private static final String KEY_PREFIX = "$Scorer_lock_"; + private final ScorerService scorerService; + private final RedissonClient redissonClient; + + public void checkAndThenUpdateScorer(Record memberReply) { + RLock lock = redissonClient.getLock(generateKey(memberReply.getQuiz())); + + try { + boolean available = lock.tryLock(30, 1, TimeUnit.SECONDS); + + if (!available) { + log.error("[락 획득 실패 (Record : {})]", memberReply.getId()); + throw new AppException(ErrorCode.SCORER_LOCK_ERROR); + } + scorerService.checkAndThenUpdateScorer(memberReply); + + } catch (InterruptedException e) { + throw new AppException(ErrorCode.SCORER_LOCK_ERROR); + } finally { + if (lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + private String generateKey(final Quiz quiz) { + return KEY_PREFIX + quiz.getId(); + } +} diff --git a/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java b/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java index dc23e6c6..89cac76c 100644 --- a/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java +++ b/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java @@ -14,21 +14,21 @@ import org.cotato.csquiz.api.record.dto.ScorerResponse; import org.cotato.csquiz.api.socket.dto.QuizOpenRequest; import org.cotato.csquiz.api.socket.dto.QuizSocketRequest; +import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.exception.AppException; +import org.cotato.csquiz.domain.auth.entity.Member; +import org.cotato.csquiz.domain.auth.repository.MemberRepository; +import org.cotato.csquiz.domain.auth.service.MemberService; +import org.cotato.csquiz.domain.education.cache.QuizAnswerRedisRepository; +import org.cotato.csquiz.domain.education.cache.TicketCountRedisRepository; import org.cotato.csquiz.domain.education.entity.MultipleQuiz; import org.cotato.csquiz.domain.education.entity.Quiz; import org.cotato.csquiz.domain.education.entity.Record; import org.cotato.csquiz.domain.education.entity.Scorer; +import org.cotato.csquiz.domain.education.facade.RedissonScorerFacade; import org.cotato.csquiz.domain.education.repository.QuizRepository; import org.cotato.csquiz.domain.education.repository.RecordRepository; import org.cotato.csquiz.domain.education.repository.ScorerRepository; -import org.cotato.csquiz.domain.auth.entity.Member; -import org.cotato.csquiz.common.error.exception.AppException; -import org.cotato.csquiz.common.error.ErrorCode; -import org.cotato.csquiz.domain.auth.repository.MemberRepository; -import org.cotato.csquiz.domain.auth.service.MemberService; -import org.cotato.csquiz.domain.education.cache.QuizAnswerRedisRepository; -import org.cotato.csquiz.domain.education.cache.ScorerExistRedisRepository; -import org.cotato.csquiz.domain.education.cache.TicketCountRedisRepository; import org.cotato.csquiz.domain.education.util.AnswerUtil; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +40,7 @@ public class RecordService { private static final String INPUT_DELIMITER = ","; + private final RedissonScorerFacade redissonScorerFacade; private final MemberService memberService; private final RecordRepository recordRepository; private final QuizRepository quizRepository; @@ -47,7 +48,7 @@ public class RecordService { private final ScorerRepository scorerRepository; private final QuizAnswerRedisRepository quizAnswerRedisRepository; private final TicketCountRedisRepository ticketCountRedisRepository; - private final ScorerExistRedisRepository scorerExistRedisRepository; + @Transactional public ReplyResponse replyToQuiz(ReplyRequest request) { @@ -68,16 +69,8 @@ public ReplyResponse replyToQuiz(ReplyRequest request) { String reply = String.join(INPUT_DELIMITER, inputs); Record createdRecord = Record.of(reply, isCorrect, findMember, findQuiz, ticketNumber); - if (isCorrect && scorerExistRedisRepository.saveScorerIfIsFastest(findQuiz, ticketNumber)) { - scorerRepository.findByQuizId(findQuiz.getId()) - .ifPresentOrElse( - scorer -> { - scorer.updateMemberId(findMember.getId()); - scorerRepository.save(scorer); - }, - () -> createScorer(createdRecord) - ); - log.info("득점자 생성 : {}, 티켓번호: {}", findMember.getId(), ticketNumber); + if (isCorrect) { + redissonScorerFacade.checkAndThenUpdateScorer(createdRecord); } recordRepository.save(createdRecord); @@ -106,7 +99,6 @@ private Quiz findQuizById(Long quizId) { public void saveAnswersToCache(QuizOpenRequest request) { List quizzes = quizRepository.findAllByEducationId(request.educationId()); - scorerExistRedisRepository.saveAllScorerNone(quizzes); quizAnswerRedisRepository.saveAllQuizAnswers(quizzes); } @@ -123,11 +115,9 @@ public void regradeRecords(RegradeRequest request) { .min(Comparator.comparing(Record::getTicketNumber)) .orElseThrow(() -> new AppException(ErrorCode.REGRADE_FAIL)); - scorerRepository.findByQuizId(quiz.getId()) - .ifPresentOrElse( - scorer -> updateScorer(scorer, fastestRecord), - () -> createScorer(fastestRecord) - ); + // 기존 득점자가 있어 -> 비교 후 업데이트 + // 없어 -> 본인을 득점자로 등록 + redissonScorerFacade.checkAndThenUpdateScorer(fastestRecord); } private void checkQuizType(Quiz quiz) { @@ -136,26 +126,6 @@ private void checkQuizType(Quiz quiz) { } } - private void updateScorer(Scorer previousScorer, Record fastestRecord) { - if (isFaster(previousScorer, fastestRecord)) { - log.info("[득점자 변경] 새로운 티켓 번호: {}", fastestRecord.getTicketNumber()); - previousScorer.updateMemberId(fastestRecord.getMemberId()); - scorerRepository.save(previousScorer); - } - } - - private boolean isFaster(Scorer previousScorer, Record fastestRecord) { - Quiz findQuiz = quizRepository.findById(previousScorer.getQuizId()) - .orElseThrow(() -> new EntityNotFoundException("이전 득점자가 맞춘 퀴즈가 존재하지 않습니다.")); - return scorerExistRedisRepository.getScorerTicketNumber(findQuiz) > fastestRecord.getTicketNumber(); - } - - private void createScorer(Record fastestRecord) { - Scorer scorer = Scorer.of(fastestRecord.getMemberId(), fastestRecord.getQuiz()); - scorerRepository.save(scorer); - scorerExistRedisRepository.saveScorer(fastestRecord.getQuiz(), fastestRecord.getTicketNumber()); - } - @Transactional public RecordsAndScorerResponse findRecordsAndScorer(Long quizId) { Quiz findQuiz = findQuizById(quizId); @@ -188,6 +158,5 @@ public void saveAnswer(QuizSocketRequest request) { Quiz findQuiz = quizRepository.findById(request.quizId()) .orElseThrow(() -> new EntityNotFoundException("해당 퀴즈를 찾을 수 없습니다.")); quizAnswerRedisRepository.saveQuizAnswer(findQuiz); - scorerExistRedisRepository.saveScorer(findQuiz, Long.MAX_VALUE); } } diff --git a/src/main/java/org/cotato/csquiz/domain/education/service/ScorerService.java b/src/main/java/org/cotato/csquiz/domain/education/service/ScorerService.java new file mode 100644 index 00000000..15cccda2 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/domain/education/service/ScorerService.java @@ -0,0 +1,46 @@ +package org.cotato.csquiz.domain.education.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cotato.csquiz.domain.education.entity.Quiz; +import org.cotato.csquiz.domain.education.entity.Record; +import org.cotato.csquiz.domain.education.entity.Scorer; +import org.cotato.csquiz.domain.education.repository.ScorerRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ScorerService { + + private final ScorerRepository scorerRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void checkAndThenUpdateScorer(Record memberReply) { + Optional maybeScorer = scorerRepository.findByQuizId(memberReply.getQuiz().getId()); + + maybeScorer.ifPresentOrElse( + scorer -> { + if (scorer.getTicketNumber() > memberReply.getTicketNumber()) { + scorer.updateScorer(memberReply.getMemberId(), memberReply.getTicketNumber()); + scorerRepository.save(scorer); + log.info("득점자 업데이트 : 티켓번호: {}", memberReply.getTicketNumber()); + } + }, + () -> { + createScorer(memberReply.getMemberId(), memberReply.getQuiz(), memberReply.getTicketNumber()); + log.info("득점자 생성 : {}, 티켓번호: {}", memberReply.getMemberId(), memberReply.getTicketNumber()); + } + + ); + } + + @Transactional + public void createScorer(final Long memberId, final Quiz quiz, final Long ticketNumber){ + scorerRepository.save(Scorer.of(memberId, quiz, ticketNumber)); + } +} diff --git a/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java b/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java index 847563d1..449e4352 100644 --- a/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java +++ b/src/main/java/org/cotato/csquiz/domain/generation/entity/Session.java @@ -12,6 +12,9 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -38,8 +41,8 @@ public class Session extends BaseTimeEntity { @Column(name = "session_title", length = 100) private String title; - @Embedded - private S3Info photoS3Info; + @OneToMany(mappedBy = "session", orphanRemoval = true) + private List sessionPhotos = new ArrayList<>(); @Column(name = "session_description") private String description; @@ -61,9 +64,8 @@ public class Session extends BaseTimeEntity { private SessionContents sessionContents; @Builder - public Session(Integer number, S3Info s3Info, String title, String description, Generation generation, SessionContents sessionContents) { + public Session(Integer number, String title, String description, Generation generation, SessionContents sessionContents) { this.number = number; - this.photoS3Info = s3Info; this.title = title; this.description = description; this.generation = generation; @@ -78,10 +80,6 @@ public void updateDescription(String description) { this.description = description; } - public void changePhotoUrl(S3Info photoUrl) { - this.photoS3Info = photoUrl; - } - public void updateSessionContents(SessionContents sessionContents) { this.sessionContents = sessionContents; } diff --git a/src/main/java/org/cotato/csquiz/domain/generation/entity/SessionPhoto.java b/src/main/java/org/cotato/csquiz/domain/generation/entity/SessionPhoto.java new file mode 100644 index 00000000..6903ca63 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/domain/generation/entity/SessionPhoto.java @@ -0,0 +1,58 @@ +package org.cotato.csquiz.domain.generation.entity; + +import static jakarta.persistence.FetchType.LAZY; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.cotato.csquiz.api.session.dto.SessionListPhotoInfoResponse; +import org.cotato.csquiz.common.entity.BaseTimeEntity; +import org.cotato.csquiz.common.entity.S3Info; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SessionPhoto extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "session_photo_id") + private Long id; + + @Embedded + private S3Info s3Info; + + @Column(name = "session_photo_order") + private Integer order; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "session_id") + private Session session; + + @Builder + public SessionPhoto(Session session, Integer order, S3Info s3Info) { + this.session = session; + this.order = order; + this.s3Info = s3Info; + } + + public void updateOrder(Integer order) { + this.order = order; + } + + public void decreaseOrder() { + if (order > 0) { + order--; + } + } +} diff --git a/src/main/java/org/cotato/csquiz/domain/generation/repository/SessionPhotoRepository.java b/src/main/java/org/cotato/csquiz/domain/generation/repository/SessionPhotoRepository.java new file mode 100644 index 00000000..c14d607d --- /dev/null +++ b/src/main/java/org/cotato/csquiz/domain/generation/repository/SessionPhotoRepository.java @@ -0,0 +1,13 @@ +package org.cotato.csquiz.domain.generation.repository; + +import java.util.List; +import java.util.Optional; +import org.cotato.csquiz.domain.generation.entity.Session; +import org.cotato.csquiz.domain.generation.entity.SessionPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SessionPhotoRepository extends JpaRepository { + List findAllBySession(Session session); + + Optional findFirstBySessionOrderByOrderDesc(Session session); +} diff --git a/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java b/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java index f6525bfe..b1b1f50f 100644 --- a/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java +++ b/src/main/java/org/cotato/csquiz/domain/generation/service/SessionService.java @@ -1,27 +1,41 @@ package org.cotato.csquiz.domain.generation.service; import jakarta.persistence.EntityNotFoundException; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.cotato.csquiz.api.session.dto.AddSessionPhotoResponse; +import org.cotato.csquiz.api.session.dto.DeleteSessionPhotoRequest; +import org.cotato.csquiz.api.session.dto.UpdateSessionPhotoOrderInfoRequest; +import org.cotato.csquiz.api.session.dto.UpdateSessionPhotoOrderRequest; +import org.cotato.csquiz.api.session.dto.AddSessionPhotoRequest; import org.cotato.csquiz.api.session.dto.AddSessionRequest; import org.cotato.csquiz.api.session.dto.AddSessionResponse; import org.cotato.csquiz.api.session.dto.CsEducationOnSessionNumberResponse; import org.cotato.csquiz.api.session.dto.SessionListResponse; -import org.cotato.csquiz.api.session.dto.UpdateSessionDescriptionRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionNumberRequest; -import org.cotato.csquiz.api.session.dto.UpdateSessionPhotoRequest; import org.cotato.csquiz.api.session.dto.UpdateSessionRequest; import org.cotato.csquiz.common.entity.S3Info; +import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.exception.AppException; import org.cotato.csquiz.domain.education.entity.Education; import org.cotato.csquiz.domain.education.service.EducationService; import org.cotato.csquiz.domain.generation.embedded.SessionContents; +import org.cotato.csquiz.domain.generation.entity.SessionPhoto; import org.cotato.csquiz.domain.generation.enums.CSEducation; import org.cotato.csquiz.domain.generation.entity.Generation; import org.cotato.csquiz.domain.generation.entity.Session; import org.cotato.csquiz.common.error.exception.ImageException; import org.cotato.csquiz.common.S3.S3Uploader; import org.cotato.csquiz.domain.generation.repository.GenerationRepository; +import org.cotato.csquiz.domain.generation.repository.SessionPhotoRepository; import org.cotato.csquiz.domain.generation.repository.SessionRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,23 +50,20 @@ public class SessionService { private static final String SESSION_BUCKET_DIRECTORY = "session"; private final SessionRepository sessionRepository; private final GenerationRepository generationRepository; + private final SessionPhotoRepository sessionPhotoRepository; private final EducationService educationService; private final S3Uploader s3Uploader; @Transactional public AddSessionResponse addSession(AddSessionRequest request) throws ImageException { - S3Info s3Info = null; - if (isImageExist(request.sessionImage())) { - s3Info = s3Uploader.uploadFiles(request.sessionImage(), SESSION_BUCKET_DIRECTORY); - } Generation findGeneration = generationRepository.findById(request.generationId()) .orElseThrow(() -> new EntityNotFoundException("해당 기수를 찾을 수 없습니다.")); int sessionNumber = calculateLastSessionNumber(findGeneration); log.info("해당 기수에 추가된 마지막 세션 : {}", sessionNumber); + Session session = Session.builder() .number(sessionNumber + 1) - .s3Info(s3Info) .description(request.description()) .generation(findGeneration) .title(request.title()) @@ -66,6 +77,27 @@ public AddSessionResponse addSession(AddSessionRequest request) throws ImageExce Session savedSession = sessionRepository.save(session); log.info("세션 생성 완료"); + if (request.photos() != null && !request.photos().isEmpty()) { + AtomicInteger index = new AtomicInteger(0); + + List sessionPhotos = new ArrayList<>(); + + for (MultipartFile photoFile : request.photos()) { + S3Info s3Info = s3Uploader.uploadFiles(photoFile, SESSION_BUCKET_DIRECTORY); + + SessionPhoto sessionPhoto = SessionPhoto.builder() + .session(savedSession) + .s3Info(s3Info) + .order(index.getAndIncrement()) + .build(); + + sessionPhotos.add(sessionPhoto); + } + + sessionPhotoRepository.saveAll(sessionPhotos); + log.info("세션 이미지 생성 완료"); + } + return AddSessionResponse.from(savedSession); } @@ -82,13 +114,7 @@ public void updateSessionNumber(UpdateSessionNumberRequest request) { } @Transactional - public void updateSessionDescription(UpdateSessionDescriptionRequest request) { - Session session = findSessionById(request.sessionId()); - session.updateDescription(request.description()); - } - - @Transactional - public void updateSession(UpdateSessionRequest request) throws ImageException { + public void updateSession(UpdateSessionRequest request) { Session session = findSessionById(request.sessionId()); session.updateDescription(request.description()); @@ -99,35 +125,88 @@ public void updateSession(UpdateSessionRequest request) throws ImageException { .itIssue(request.itIssue()) .networking(request.networking()) .build()); - if (request.isPhotoUpdated()) { - updatePhoto(session, request.sessionImage()); - } sessionRepository.save(session); } @Transactional - public void updateSessionPhoto(UpdateSessionPhotoRequest request) throws ImageException { + public AddSessionPhotoResponse additionalSessionPhoto(AddSessionPhotoRequest request) throws ImageException { Session session = findSessionById(request.sessionId()); - updatePhoto(session, request.sessionImage()); + + S3Info imageInfo = s3Uploader.uploadFiles(request.photo(), SESSION_BUCKET_DIRECTORY); + + Integer imageOrder = sessionPhotoRepository.findFirstBySessionOrderByOrderDesc(session) + .map(sessionPhoto -> sessionPhoto.getOrder() + 1).orElse(0); + + SessionPhoto sessionPhoto = SessionPhoto.builder() + .session(session) + .s3Info(imageInfo) + .order(imageOrder) + .build(); + + return AddSessionPhotoResponse.from(sessionPhotoRepository.save(sessionPhoto)); } - private void updatePhoto(Session session, MultipartFile sessionImage) throws ImageException { - if (isImageExist(sessionImage)) { - S3Info s3Info = s3Uploader.uploadFiles(sessionImage, SESSION_BUCKET_DIRECTORY); - deleteOldImage(session); - session.changePhotoUrl(s3Info); + @Transactional + public void deleteSessionPhoto(DeleteSessionPhotoRequest request) { + SessionPhoto deletePhoto = sessionPhotoRepository.findById(request.photoId()) + .orElseThrow(() -> new EntityNotFoundException("해당 사진을 찾을 수 없습니다.")); + s3Uploader.deleteFile(deletePhoto.getS3Info()); + sessionPhotoRepository.delete(deletePhoto); + + List reOrderPhotos = sessionPhotoRepository.findAllBySession(deletePhoto.getSession()).stream() + .filter(photo -> photo.getOrder() > deletePhoto.getOrder()) + .toList(); + + for (SessionPhoto sessionPhoto : reOrderPhotos) { + sessionPhoto.decreaseOrder(); + } + } + + @Transactional + public void updateSessionPhotoOrder(UpdateSessionPhotoOrderRequest request) { + Session sessionById = findSessionById(request.sessionId()); + List orderList = request.orderInfos(); + + List savedPhotos = sessionPhotoRepository.findAllBySession(sessionById); + + if (savedPhotos.size() != orderList.size()) { + throw new AppException(ErrorCode.SESSION_PHOTO_COUNT_MISMATCH); + } + + if (checkValidOrderRange(orderList)) { + throw new AppException(ErrorCode.SESSION_ORDER_INVALID); + } + + if (!checkOrderUnique(orderList)) { + throw new AppException(ErrorCode.SESSION_ORDER_INVALID); } - if (!isImageExist(sessionImage)) { - deleteOldImage(session); - session.changePhotoUrl(null); + + Map orderMap = orderList.stream() + .collect(Collectors.toMap(UpdateSessionPhotoOrderInfoRequest::photoId, Function.identity())); + + for (SessionPhoto savedPhoto : savedPhotos) { + if (orderMap.get(savedPhoto.getId()) == null) { + throw new EntityNotFoundException("해당 사진을 찾을 수 없습니다."); + } + savedPhoto.updateOrder(orderMap.get(savedPhoto.getId()).order()); } } - private void deleteOldImage(Session session) { - if (session.getPhotoS3Info() != null) { - s3Uploader.deleteFile(session.getPhotoS3Info()); + private boolean checkValidOrderRange(List orderList) { + return orderList.stream().noneMatch(orderInfo -> + orderInfo.order() < 0 || orderInfo.order() >= orderList.size()); + } + + private boolean checkOrderUnique(List orderList) { + Set uniqueOrders = new HashSet<>(); + for (UpdateSessionPhotoOrderInfoRequest orderInfo : orderList) { + if (!uniqueOrders.add(orderInfo.order())) { + return false; + } } + + return true; } public List findSessionsByGenerationId(Long generationId) { @@ -160,8 +239,4 @@ public List findAllNotLinkedCsOnSessionsByGe .map(CsEducationOnSessionNumberResponse::from) .toList(); } - - private boolean isImageExist(MultipartFile sessionImage) { - return sessionImage != null && !sessionImage.isEmpty(); - } }